fix(worker): route SVG (and other image assets) through the image-poster

path instead of failing the video transcode

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.
This commit is contained in:
Zac Gaetano 2026-05-23 10:26:59 -04:00
parent d07fb13401
commit 508e978fe5

View file

@ -39,7 +39,9 @@ async function loadProxyEncodingSettings() {
// Codec names ffprobe reports for still-image inputs. These bypass the video // Codec names ffprobe reports for still-image inputs. These bypass the video
// transcode entirely — see proxyWorker below. // 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'; 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}`); console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
await downloadFromS3(S3_BUCKET, inputKey, inputPath); 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 // 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 // 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 // when the source disconnects before any frame is received; the proxy
// pipeline used to bomb on "moov atom not found", which buried the // 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'. // 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); const { size: inputBytes } = await stat(inputPath);
if (inputBytes < 4096) { if (dbMediaType !== 'image' && inputBytes < 4096) {
throw new Error( throw new Error(
`Source is empty or truncated (${inputBytes} bytes). The recording ` + `Source is empty or truncated (${inputBytes} bytes). The recording ` +
`likely ended before any frames were received — check the source ` + `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 // 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). // 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. // 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) { if (isImage) {
const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg'); const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg');
const imageOutputPath = outputPath.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 job.updateProgress(40);
await transcodeImage(inputPath, imageOutputPath); await transcodeImage(inputPath, imageOutputPath);
await job.updateProgress(70); await job.updateProgress(70);