From f90c5b9e63df42e1d13dfca7ed0b5981589dc0a1 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 21 Jul 2026 20:27:17 +0000 Subject: [PATCH] fix: add PulseVault thumbnail support to eliminate 400 errors - Backend: Add thumbnail kind to PulseVault allowedExtensions (.jpg, .jpeg, .png) - Backend: Handle thumbnail uploads in onUploadComplete hook, linking to parent video via relatedTo - Frontend: Extract and upload thumbnails after Pulse video uploads complete - Frontend: Extract thumbnails in parallel for direct device uploads Fixes the '400 Rejected by server' error shown in Pulse app after successful video uploads. The error was from a separate thumbnail upload request that Pulse makes after the main video. Now both the video and thumbnail uploads succeed, providing better UX. --- meteor-backend/server/pulsevault.js | 21 ++++++++++++- src/features/media/PulseUploadButton.tsx | 40 +++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/meteor-backend/server/pulsevault.js b/meteor-backend/server/pulsevault.js index 7a383c6c..e6244028 100644 --- a/meteor-backend/server/pulsevault.js +++ b/meteor-backend/server/pulsevault.js @@ -76,7 +76,11 @@ const core = createPulseVaultCore({ // Pulse Cam and the web fallback both upload one pre-recorded MP4 per // session rather than per-clip "beats". uploadUnit: 'merged', - allowedExtensions: { video: ['.mp4'], captions: ['.vtt', '.srt'] }, + allowedExtensions: { + video: ['.mp4'], + captions: ['.vtt', '.srt'], + thumbnail: ['.jpg', '.jpeg', '.png'], + }, authorize: async (request, ctx) => { console.log('[pulsevault][hook] authorize called', { phase: ctx.phase, @@ -118,6 +122,21 @@ const core = createPulseVaultCore({ }, onUploadComplete: async (_request, ctx) => { console.log('[pulsevault][hook] onUploadComplete called', JSON.stringify(ctx)); + + // Handle thumbnail uploads separately - they relate to another video via relatedTo + if (ctx.kind === 'thumbnail') { + console.log('[pulsevault][hook] Thumbnail upload complete, relatedTo:', ctx.relatedTo); + if (ctx.relatedTo) { + // Update the media item with the thumbnail + await rawDb().collection('mediaitems').updateOne( + { videoid: ctx.relatedTo }, + { $set: { thumbnail: `/pulsevault/artifacts/${ctx.artifactId}` } } + ); + console.log('[pulsevault] Updated thumbnail for video:', ctx.relatedTo); + } + return; + } + console.log('[pulsevault][hook] reservationContext keys:', [...reservationContext.keys()]); const reservation = reservationContext.get(ctx.artifactId); reservationContext.delete(ctx.artifactId); diff --git a/src/features/media/PulseUploadButton.tsx b/src/features/media/PulseUploadButton.tsx index 922a8b91..d31875ff 100644 --- a/src/features/media/PulseUploadButton.tsx +++ b/src/features/media/PulseUploadButton.tsx @@ -15,7 +15,8 @@ import { QRCodeSVG } from 'qrcode.react'; import * as tus from 'tus-js-client'; import React, { useEffect, useRef, useState } from 'react'; -import { attachmentApi, TIMECORE_BASE_URL, videoApi } from '../../lib/api'; +import { attachmentApi, TIMECORE_BASE_URL, videoApi, mediaApi } from '../../lib/api'; +import { extractThumbnailFromVideoUrl, extractVideoThumbnail } from '../../lib/videoThumbnail'; /** * The Pulse Cam server base for deep links — origin plus the `/pulsevault` @@ -104,13 +105,26 @@ export const PulseUploadButton: React.FC = ({ const interval = setInterval(async () => { try { const attachments = await attachmentApi.list('ticket', ticketId); - const hasNew = attachments.some( + const newVideo = attachments.find( (a) => a.type === 'video' && !knownAttachmentIds.current.has(a.id), ); - if (hasNew) { + if (newVideo) { clearInterval(interval); clearStoredVideoid(ticketId); setModalOpen(false); + + // Extract and upload thumbnail for the video uploaded via Pulse. + // This replaces the thumbnail that Pulse tries (and fails) to upload via TUS + // with kind='thumbnail' (unsupported by PulseVault). + if (videoid && newVideo.url) { + extractThumbnailFromVideoUrl(newVideo.url) + .then((thumbnailBlob) => mediaApi.uploadThumbnail(videoid, thumbnailBlob)) + .catch((err) => { + console.warn('[PulseUpload] Thumbnail extraction/upload failed:', err); + // Non-blocking — video upload already succeeded + }); + } + onUploadComplete(); } } catch { @@ -118,7 +132,7 @@ export const PulseUploadButton: React.FC = ({ } }, 3000); return () => clearInterval(interval); - }, [modalOpen, ticketId, onUploadComplete]); + }, [modalOpen, ticketId, videoid, onUploadComplete]); const doReserve = async (): Promise<{ videoid: string; uploadLink: string } | null> => { setReserving(true); @@ -172,6 +186,9 @@ export const PulseUploadButton: React.FC = ({ setError(null); setProgress(0); + // Start thumbnail extraction in parallel with the upload. + const thumbnailPromise = extractVideoThumbnail(file).catch(() => null); + const upload = new tus.Upload(file, { endpoint: videoApi.uploadEndpoint(), retryDelays: [0, 3000, 5000, 10000], @@ -180,10 +197,23 @@ export const PulseUploadButton: React.FC = ({ onProgress(bytesUploaded, bytesTotal) { setProgress(Math.round((bytesUploaded / bytesTotal) * 100)); }, - onSuccess() { + async onSuccess() { clearStoredVideoid(ticketId); setUploadToken(null); setProgress(null); + + // Upload thumbnail if extraction succeeded. + // This replaces the thumbnail that would be uploaded via TUS with an unsupported kind. + const thumbnailBlob = await thumbnailPromise; + if (thumbnailBlob && videoid) { + try { + await mediaApi.uploadThumbnail(videoid, thumbnailBlob); + } catch (err) { + console.warn('[PulseUpload] Thumbnail upload failed:', err); + // Non-blocking — video upload already succeeded + } + } + onUploadComplete(); }, onError(err) {