From 784554e40d4eed26fbc4561e1aa9858c670b71cf Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 10 Mar 2026 19:04:42 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20video=20size=20error=20handling=20?= =?UTF-8?q?=E2=80=94=20validation,=20timeouts,=20failure=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Increase ffmpeg/ffprobe timeouts from 15s to 60s for large videos - Classify processing errors (timeout, OOM, corrupt) with user-facing messages - Emit failure details via media:job:update socket event (error field) - Bump refboard container memory 512M → 1G Frontend: - Client-side file size validation (200MB) before upload starts - Oversized files show immediate error in upload manager, skip upload - Handle media:job:update status='failed' — surface error in upload panel - Add processingFailed() to UploadManager for video processing errors --- backend/services/media-worker.js | 23 ++++++++++++++++++++--- backend/video-utils.js | 4 ++-- frontend/src/canvas/image-drop.ts | 20 ++++++++++++++++++++ frontend/src/hooks/useCanvasSetup.ts | 12 ++++++++++-- frontend/src/stores/uploadManager.ts | 12 ++++++++++++ 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/backend/services/media-worker.js b/backend/services/media-worker.js index 4515b45..41b1b5c 100644 --- a/backend/services/media-worker.js +++ b/backend/services/media-worker.js @@ -85,14 +85,14 @@ async function processJob(job) { const image = getImage(imageId); if (!image) { updateMediaJob(jobId, { status: 'failed', error: 'Image record not found' }); - emitJobUpdate(boardId, jobId, imageId, 'failed'); + emitJobFailed(boardId, jobId, imageId, 'Image record not found'); return; } const videoBuffer = await fetchFromMinio(image.minio_path); if (!videoBuffer) { updateMediaJob(jobId, { status: 'failed', error: 'Failed to fetch video from storage' }); - emitJobUpdate(boardId, jobId, imageId, 'failed'); + emitJobFailed(boardId, jobId, imageId, 'Failed to fetch video from storage'); return; } @@ -131,13 +131,23 @@ async function processJob(job) { } catch (err) { console.error('[media-worker] Job %s failed:', jobId, err.message); + // Classify error for user-facing message + let userError = 'Video processing failed'; + if (err.killed || err.signal === 'SIGTERM') { + userError = 'Video processing timed out — file may be too large or corrupt'; + } else if (err.message?.includes('ENOMEM') || err.message?.includes('Cannot allocate')) { + userError = 'Out of memory — video file is too large to process'; + } else if (err.message?.includes('Invalid data')) { + userError = 'Invalid or corrupt video file'; + } + const attempts = (job.attempts || 0) + 1; if (attempts < 3) { updateMediaJob(jobId, { status: 'retry', attempts, error: err.message }); emitJobUpdate(boardId, jobId, imageId, 'retry'); } else { updateMediaJob(jobId, { status: 'failed', attempts, error: err.message }); - emitJobUpdate(boardId, jobId, imageId, 'failed'); + emitJobFailed(boardId, jobId, imageId, userError); } } } @@ -173,4 +183,11 @@ function emitJobUpdate(boardId, jobId, imageId, status, result) { }); } +/** + * Emit a job failure with error details to the board room. + */ +function emitJobFailed(boardId, jobId, imageId, error) { + emitJobUpdate(boardId, jobId, imageId, 'failed', { error }); +} + module.exports = { startMediaWorker, stopMediaWorker }; diff --git a/backend/video-utils.js b/backend/video-utils.js index 5112ea5..05d94fe 100644 --- a/backend/video-utils.js +++ b/backend/video-utils.js @@ -34,7 +34,7 @@ function probeVideo(buffer) { '-show_format', '-show_streams', tmpFile, - ], { timeout: 15000 }, (err, stdout) => { + ], { timeout: 60000 }, (err, stdout) => { cleanup(tmpFile, tmpDir); if (err) { console.warn('[video-utils] ffprobe failed:', err.message); @@ -91,7 +91,7 @@ function extractPoster(buffer) { '-q:v', '3', // JPEG quality (2=best, 31=worst) '-y', // overwrite tmpOutput, - ], { timeout: 15000 }, (err) => { + ], { timeout: 60000 }, (err) => { if (err) { console.warn('[video-utils] ffmpeg poster extraction failed:', err.message); cleanup(tmpInput, tmpDir); diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index d0f4f9e..2e865c9 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -7,6 +7,10 @@ import { uploadImage, uploadImageFromUrl } from '../api'; type OnChange = () => void; +/** Client-side file size limit (matches backend MAX_FILE_SIZE_MB default) */ +const MAX_FILE_SIZE_MB = 200; +const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024; + /* ------------------------------------------------------------------ */ /* Placeholder helper */ /* ------------------------------------------------------------------ */ @@ -153,6 +157,15 @@ export function setupDragDrop( let cursorX = world.x; for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; + // Client-side size validation + if (file.size > MAX_FILE_SIZE) { + const jobId = uploads?.addJob(file, boardId); + if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`); + col++; + if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; } + continue; + } + const placeholder = createPlaceholder(viewport, cursorX, cursorY); const jobId = uploads?.addJob(file, boardId); @@ -241,6 +254,13 @@ export function setupPaste( const file = item.getAsFile(); if (!file) continue; + // Client-side size validation + if (file.size > MAX_FILE_SIZE) { + const jobId = uploads?.addJob(file, boardId); + if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`); + continue; + } + // Place at viewport center (world coords) const center = viewport.center; const cx = center.x; diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 6222062..fc0dffa 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -184,10 +184,18 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { // Media processing pipeline: upgrade videos when poster/metadata arrives socket.on('media:job:update', (data: any) => { - if (!data || data.status !== 'done') return; - const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data; + if (!data) return; + const { imageId, status } = data; if (!imageId) return; + // Surface processing failures to upload manager + if (status === 'failed') { + uploadManager.processingFailed(imageId, data.error || 'Video processing failed'); + return; + } + if (status !== 'done') return; + const { posterAssetKey, nativeWidth, nativeHeight, duration } = data; + // Update upload manager — transitions video jobs from "processing" to "done" uploadManager.processingComplete(imageId); diff --git a/frontend/src/stores/uploadManager.ts b/frontend/src/stores/uploadManager.ts index 46eb869..5633873 100644 --- a/frontend/src/stores/uploadManager.ts +++ b/frontend/src/stores/uploadManager.ts @@ -88,6 +88,18 @@ export class UploadManager { } } + /** Video processing failed (from media:job:update socket event with status='failed'). */ + processingFailed(imageId: string, error: string) { + for (const job of this.jobs.values()) { + if (job.imageId === imageId && (job.status === 'processing' || job.status === 'uploading')) { + job.status = 'failed'; + job.error = error; + this._notify(); + return; + } + } + } + /** Mark a job as failed. */ setFailed(jobId: string, error: string) { const job = this.jobs.get(jobId);