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.
This commit is contained in:
OpenCode 2026-06-05 05:23:01 +00:00
parent 5c07b4e8b1
commit e64281c9fd
2 changed files with 38 additions and 10 deletions

View file

@ -295,28 +295,39 @@ export const conformWorker = async (job) => {
// Codec map. The panel sends 'prores_hq' / 'prores_4444' / 'h264' / 'h265' // Codec map. The panel sends 'prores_hq' / 'prores_4444' / 'h264' / 'h265'
// / 'dnxhr_hq'; old EDL callers send 'prores' / 'h265' / 'h264'. Match // / 'dnxhr_hq'; old EDL callers send 'prores' / 'h265' / 'h264'. Match
// both. prores_ks profiles: 0=proxy 1=lt 2=std 3=hq 4=4444. // 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') { if (codec === 'prores_hq' || codec === 'prores') {
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3']; videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3'];
pixFmtFlag = ['-pix_fmt', 'yuv422p10le'];
} else if (codec === 'prores_4444') { } else if (codec === 'prores_4444') {
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4']; videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4'];
pixFmtFlag = ['-pix_fmt', 'yuva444p10le'];
} else if (codec === 'h265' || codec === 'hevc') { } else if (codec === 'h265' || codec === 'hevc') {
videoCodec = 'libx265'; videoCodec = 'libx265';
} else if (codec === 'dnxhr_hq') { } else if (codec === 'dnxhr_hq') {
videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq']; videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq'];
pixFmtFlag = ['-pix_fmt', 'yuv422p'];
} else { } else {
videoCodec = 'libx264'; videoCodec = 'libx264';
} }
// prores_ks ignores -crf and uses -preset differently; libx264/x265 use // Quality args are libx264/libx265-specific (-preset/-crf). ProRes encodes
// crf-based quality. Branch the encode args. // quality via its profile; dnxhd (DNxHR) drives quality via the profile too
const isProRes = videoCodec === 'prores_ks'; // and rejects/ignores x264's -preset/-crf. Only emit these for the x26x
const qualityArgs = isProRes // encoders so we never feed an encoder flags it doesn't understand.
? [] // ProRes profile already encodes the quality target const isCrfCodec = videoCodec === 'libx264' || videoCodec === 'libx265';
: [ const qualityArgs = isCrfCodec
? [
'-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast', '-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast',
'-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28', '-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, // Concat: every segment was normalised at trim time (uniform fps,
// resolution, pixel format, sample rate, stereo). The demuxer can // resolution, pixel format, sample rate, stereo). The demuxer can
@ -335,6 +346,7 @@ export const conformWorker = async (job) => {
'-i', segmentListPath, '-i', segmentListPath,
'-c:v', videoCodec, '-c:v', videoCodec,
...profileFlag, ...profileFlag,
...pixFmtFlag,
...qualityArgs, ...qualityArgs,
...encodeAudio, ...encodeAudio,
'-y', outputPath, '-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 // 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. // 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. // 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( await query(
`UPDATE assets `UPDATE assets
SET status = 'error', updated_at = NOW() SET status = 'error', updated_at = NOW()
WHERE original_s3_key = $1`, 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)); ).catch(e => console.error('[conform] Failed to mark asset error:', e.message));
throw error; throw error;
} finally { } finally {

View file

@ -164,7 +164,15 @@ export const proxyWorker = async (job) => {
// Empty/truncated capture: probe returned a video stream but ffmpeg can't // 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 // read any frames. Bail with a clear message instead of dumping ~3KB of
// ffmpeg stderr into the failed-jobs list. // 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( throw new Error(
`Empty or truncated source: codec=${mediaInfo.codec}, ` + `Empty or truncated source: codec=${mediaInfo.codec}, ` +
`resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.` `resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`