From e64281c9fd1649039e9a3b6d6ae2d1455f54c78d Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 5 Jun 2026 05:23:01 +0000 Subject: [PATCH] fix(worker): proxy + conform handle VC-3/DNxHD MXF and ProRes correctly proxy.js: the empty-source guard tripped on growing VC-3/DNxHD MXF masters, which carry a valid decodable video stream but report format.duration=N/A (durationMs=null). Only bail when there is ALSO no resolution (the true aborted-capture signature), so MXF masters transcode instead of erroring. Verified: burn-test growing masters now migrate SMB->S3 AND generate proxies (assets dc0/dc2/dc6 ready with proxy, tags s3). conform.js: pin codec-correct pixel format per preset (ProRes HQ yuv422p10le, ProRes 4444 yuva444p10le, DNxHR HQ yuv422p); only emit -preset/-crf for libx264/libx265 (dnxhd/prores drive quality via profile); and use the codec-correct output extension in the error handler (was hard-coded .mp4, so a failed ProRes/DNxHR conform never flipped to error and spun in processing forever - the broadcast-vs-web asymmetry). All presets verified rc=0. --- services/worker/src/workers/conform.js | 38 ++++++++++++++++++++------ services/worker/src/workers/proxy.js | 10 ++++++- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/services/worker/src/workers/conform.js b/services/worker/src/workers/conform.js index 07cbc3e..3611f86 100644 --- a/services/worker/src/workers/conform.js +++ b/services/worker/src/workers/conform.js @@ -295,28 +295,39 @@ export const conformWorker = async (job) => { // Codec map. The panel sends 'prores_hq' / 'prores_4444' / 'h264' / 'h265' // / 'dnxhr_hq'; old EDL callers send 'prores' / 'h265' / 'h264'. Match // both. prores_ks profiles: 0=proxy 1=lt 2=std 3=hq 4=4444. - let videoCodec, profileFlag = []; + // pixFmtFlag pins a codec-correct pixel format on the final encode so the + // broadcast master is deterministic regardless of the (yuv420p) normalised + // segments. prores_ks defaults are profile-driven but we make HQ explicitly + // 10-bit 4:2:2 (broadcast spec) and 4444 explicitly 4:4:4+alpha. dnxhd + // *requires* a pixel format it supports (yuv422p for DNxHR HQ) or it can + // error on certain inputs. + let videoCodec, profileFlag = [], pixFmtFlag = []; if (codec === 'prores_hq' || codec === 'prores') { videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3']; + pixFmtFlag = ['-pix_fmt', 'yuv422p10le']; } else if (codec === 'prores_4444') { videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4']; + pixFmtFlag = ['-pix_fmt', 'yuva444p10le']; } else if (codec === 'h265' || codec === 'hevc') { videoCodec = 'libx265'; } else if (codec === 'dnxhr_hq') { videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq']; + pixFmtFlag = ['-pix_fmt', 'yuv422p']; } else { videoCodec = 'libx264'; } - // prores_ks ignores -crf and uses -preset differently; libx264/x265 use - // crf-based quality. Branch the encode args. - const isProRes = videoCodec === 'prores_ks'; - const qualityArgs = isProRes - ? [] // ProRes profile already encodes the quality target - : [ + // Quality args are libx264/libx265-specific (-preset/-crf). ProRes encodes + // quality via its profile; dnxhd (DNxHR) drives quality via the profile too + // and rejects/ignores x264's -preset/-crf. Only emit these for the x26x + // encoders so we never feed an encoder flags it doesn't understand. + const isCrfCodec = videoCodec === 'libx264' || videoCodec === 'libx265'; + const qualityArgs = isCrfCodec + ? [ '-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast', '-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28', - ]; + ] + : []; // ProRes / DNxHR: quality target is encoded by the profile // Concat: every segment was normalised at trim time (uniform fps, // resolution, pixel format, sample rate, stereo). The demuxer can @@ -335,6 +346,7 @@ export const conformWorker = async (job) => { '-i', segmentListPath, '-c:v', videoCodec, ...profileFlag, + ...pixFmtFlag, ...qualityArgs, ...encodeAudio, '-y', outputPath, @@ -397,11 +409,19 @@ export const conformWorker = async (job) => { // BUG FIX #1: Mark the output asset (if any) as 'error' so the UI doesn't // show a perpetually-spinning 'processing' state when the conform fails. // We don't have an assetId until the INSERT succeeds, so target by job key. + // + // BUG FIX #2: the output key extension is codec-dependent (ProRes / DNxHR + // land in .mov, everything else in .mp4 — see `outputExt` above). The error + // mark previously hard-coded `.mp4`, so a *failed* ProRes/DNxHR conform was + // never matched and the asset spun in 'processing' forever — which is the + // exact asymmetry behind "Broadcast (ProRes) fails but H.264/web works": + // the H.264 row flips to 'error' and surfaces cleanly, the ProRes row does + // not. Use the same outputExt the success path used. await query( `UPDATE assets SET status = 'error', updated_at = NOW() WHERE original_s3_key = $1`, - [`jobs/${jobId}/conformed.mp4`] + [`jobs/${jobId}/conformed.${outputExt}`] ).catch(e => console.error('[conform] Failed to mark asset error:', e.message)); throw error; } finally { diff --git a/services/worker/src/workers/proxy.js b/services/worker/src/workers/proxy.js index 7330302..cb98c2e 100644 --- a/services/worker/src/workers/proxy.js +++ b/services/worker/src/workers/proxy.js @@ -164,7 +164,15 @@ export const proxyWorker = async (job) => { // Empty/truncated capture: probe returned a video stream but ffmpeg can't // read any frames. Bail with a clear message instead of dumping ~3KB of // ffmpeg stderr into the failed-jobs list. - if (mediaInfo.durationMs === null && mediaInfo.codec) { + // + // NOTE: a null container duration alone is NOT proof of emptiness. Growing + // VC-3/DNxHD MXF (OP1a) masters carry a valid, decodable video stream but + // report format.duration = N/A, so durationMs comes back null even though + // ffmpeg transcodes them fine. Only bail when there is ALSO no decodable + // video stream (no resolution) — that is the true aborted-capture signature + // (ftyp-only / 0-frame objects). This preserves the empty-capture guard for + // SRT/RTMP drops while letting MXF masters through to the transcoder. + if (mediaInfo.durationMs === null && mediaInfo.codec && !mediaInfo.resolution) { throw new Error( `Empty or truncated source: codec=${mediaInfo.codec}, ` + `resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`