From 508e978fe50d2cd918d100581b400fda50de7ec3 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 23 May 2026 10:26:59 -0400 Subject: [PATCH] fix(worker): route SVG (and other image assets) through the image-poster path instead of failing the video transcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously IMAGE_CODECS contained the raster ffprobe codec names ('png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls') but not 'svg'. An SVG-as-asset (e.g. an architecture diagram dragged into a project) was correctly tagged media_type='image' in the DB but ffprobe reported its codec as 'svg', which fell through to the video branch, found durationMs===null, and died with 'Empty or truncated source: codec=svg, resolution=0x0'. That clogs the failed-jobs list with red rows that have nothing to do with broken captures. Two fixes here: 1) Add 'svg' to IMAGE_CODECS so the existing transcodeImage()/poster path handles it. 2) Also bail to the poster path when the asset row itself says media_type='image', even if ffprobe didn't return a codec name we recognize (defensive — catches future formats like AVIF without requiring an explicit catalog update). Closes part of #13. --- services/worker/src/workers/proxy.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/services/worker/src/workers/proxy.js b/services/worker/src/workers/proxy.js index 95f8467..f001156 100644 --- a/services/worker/src/workers/proxy.js +++ b/services/worker/src/workers/proxy.js @@ -39,7 +39,9 @@ async function loadProxyEncodingSettings() { // Codec names ffprobe reports for still-image inputs. These bypass the video // transcode entirely — see proxyWorker below. -const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']); +const IMAGE_CODECS = new Set([ + 'png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls', 'svg', +]); const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; @@ -66,13 +68,26 @@ export const proxyWorker = async (job) => { console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`); await downloadFromS3(S3_BUCKET, inputKey, inputPath); + // Look up the asset row early — we want media_type before deciding how + // to process. That lets us route 'image' assets to the poster path even + // when ffprobe doesn't return a codec name in IMAGE_CODECS (e.g. future + // formats like AVIF / HEIF / JPEG-XL). + const assetRow = await query( + 'SELECT media_type FROM assets WHERE id = $1', + [assetId] + ); + const dbMediaType = assetRow.rows[0]?.media_type || null; + // Reject obviously-empty inputs before handing them to ffmpeg. Aborted // SRT/RTMP recordings end up as 0-byte (or ftyp-only ~1KB) objects in S3 // when the source disconnects before any frame is received; the proxy // pipeline used to bomb on "moov atom not found", which buried the // real reason. Bail with a clear message and let the asset go to 'error'. + // + // Skip this check for image assets — a single PNG icon can legitimately + // be a few hundred bytes. const { size: inputBytes } = await stat(inputPath); - if (inputBytes < 4096) { + if (dbMediaType !== 'image' && inputBytes < 4096) { throw new Error( `Source is empty or truncated (${inputBytes} bytes). The recording ` + `likely ended before any frames were received — check the source ` + @@ -95,12 +110,17 @@ export const proxyWorker = async (job) => { // Still images skip the video proxy — they have no temporal stream and // x264 with a one-frame PNG input fails (Could not open encoder before EOF). // Generate a scaled JPEG poster instead; the thumbnail job will downsize it. - const isImage = mediaInfo.codec && IMAGE_CODECS.has(mediaInfo.codec.toLowerCase()); + // + // We treat the input as an image if EITHER the DB says so (media_type = + // 'image', set by upload.js based on Content-Type or extension), OR + // ffprobe reports a codec we know is a still-image format. + const codecLower = mediaInfo.codec ? mediaInfo.codec.toLowerCase() : null; + const isImage = dbMediaType === 'image' || (codecLower && IMAGE_CODECS.has(codecLower)); if (isImage) { const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg'); const imageOutputPath = outputPath.replace(/\.mp4$/, '.jpg'); - console.log(`[proxy] Image asset ${assetId} (${mediaInfo.codec}) — emitting poster instead of video proxy`); + console.log(`[proxy] Image asset ${assetId} (codec=${codecLower || 'unknown'}, db_media_type=${dbMediaType}) — emitting poster instead of video proxy`); await job.updateProgress(40); await transcodeImage(inputPath, imageOutputPath); await job.updateProgress(70);