fix: video size error handling — validation, timeouts, failure surfacing
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
This commit is contained in:
@@ -85,14 +85,14 @@ async function processJob(job) {
|
|||||||
const image = getImage(imageId);
|
const image = getImage(imageId);
|
||||||
if (!image) {
|
if (!image) {
|
||||||
updateMediaJob(jobId, { status: 'failed', error: 'Image record not found' });
|
updateMediaJob(jobId, { status: 'failed', error: 'Image record not found' });
|
||||||
emitJobUpdate(boardId, jobId, imageId, 'failed');
|
emitJobFailed(boardId, jobId, imageId, 'Image record not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoBuffer = await fetchFromMinio(image.minio_path);
|
const videoBuffer = await fetchFromMinio(image.minio_path);
|
||||||
if (!videoBuffer) {
|
if (!videoBuffer) {
|
||||||
updateMediaJob(jobId, { status: 'failed', error: 'Failed to fetch video from storage' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +131,23 @@ async function processJob(job) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[media-worker] Job %s failed:', jobId, err.message);
|
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;
|
const attempts = (job.attempts || 0) + 1;
|
||||||
if (attempts < 3) {
|
if (attempts < 3) {
|
||||||
updateMediaJob(jobId, { status: 'retry', attempts, error: err.message });
|
updateMediaJob(jobId, { status: 'retry', attempts, error: err.message });
|
||||||
emitJobUpdate(boardId, jobId, imageId, 'retry');
|
emitJobUpdate(boardId, jobId, imageId, 'retry');
|
||||||
} else {
|
} else {
|
||||||
updateMediaJob(jobId, { status: 'failed', attempts, error: err.message });
|
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 };
|
module.exports = { startMediaWorker, stopMediaWorker };
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function probeVideo(buffer) {
|
|||||||
'-show_format',
|
'-show_format',
|
||||||
'-show_streams',
|
'-show_streams',
|
||||||
tmpFile,
|
tmpFile,
|
||||||
], { timeout: 15000 }, (err, stdout) => {
|
], { timeout: 60000 }, (err, stdout) => {
|
||||||
cleanup(tmpFile, tmpDir);
|
cleanup(tmpFile, tmpDir);
|
||||||
if (err) {
|
if (err) {
|
||||||
console.warn('[video-utils] ffprobe failed:', err.message);
|
console.warn('[video-utils] ffprobe failed:', err.message);
|
||||||
@@ -91,7 +91,7 @@ function extractPoster(buffer) {
|
|||||||
'-q:v', '3', // JPEG quality (2=best, 31=worst)
|
'-q:v', '3', // JPEG quality (2=best, 31=worst)
|
||||||
'-y', // overwrite
|
'-y', // overwrite
|
||||||
tmpOutput,
|
tmpOutput,
|
||||||
], { timeout: 15000 }, (err) => {
|
], { timeout: 60000 }, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.warn('[video-utils] ffmpeg poster extraction failed:', err.message);
|
console.warn('[video-utils] ffmpeg poster extraction failed:', err.message);
|
||||||
cleanup(tmpInput, tmpDir);
|
cleanup(tmpInput, tmpDir);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { uploadImage, uploadImageFromUrl } from '../api';
|
|||||||
|
|
||||||
type OnChange = () => void;
|
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 */
|
/* Placeholder helper */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -153,6 +157,15 @@ export function setupDragDrop(
|
|||||||
let cursorX = world.x;
|
let cursorX = world.x;
|
||||||
for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP;
|
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 placeholder = createPlaceholder(viewport, cursorX, cursorY);
|
||||||
const jobId = uploads?.addJob(file, boardId);
|
const jobId = uploads?.addJob(file, boardId);
|
||||||
|
|
||||||
@@ -241,6 +254,13 @@ export function setupPaste(
|
|||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) continue;
|
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)
|
// Place at viewport center (world coords)
|
||||||
const center = viewport.center;
|
const center = viewport.center;
|
||||||
const cx = center.x;
|
const cx = center.x;
|
||||||
|
|||||||
@@ -184,10 +184,18 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
|
|
||||||
// Media processing pipeline: upgrade videos when poster/metadata arrives
|
// Media processing pipeline: upgrade videos when poster/metadata arrives
|
||||||
socket.on('media:job:update', (data: any) => {
|
socket.on('media:job:update', (data: any) => {
|
||||||
if (!data || data.status !== 'done') return;
|
if (!data) return;
|
||||||
const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data;
|
const { imageId, status } = data;
|
||||||
if (!imageId) return;
|
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"
|
// Update upload manager — transitions video jobs from "processing" to "done"
|
||||||
uploadManager.processingComplete(imageId);
|
uploadManager.processingComplete(imageId);
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** Mark a job as failed. */
|
||||||
setFailed(jobId: string, error: string) {
|
setFailed(jobId: string, error: string) {
|
||||||
const job = this.jobs.get(jobId);
|
const job = this.jobs.get(jobId);
|
||||||
|
|||||||
Reference in New Issue
Block a user