From 6412b5c2525ed551c5a0d19e4ffa40e30ddcfc57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 14:32:49 -0400 Subject: [PATCH] =?UTF-8?q?fix(worker):=20conform=20=E2=80=94=20preserve?= =?UTF-8?q?=20audio=20+=20map=20ProRes/DNxHR=20codecs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cooperating bugs left the rendered output silent and in the wrong codec: 1. executor.js trimSegment used `-frames:v` with no audio mapping. ffmpeg dropped the audio track on each segment before they reached the concat step. Add `-c:a copy -shortest` so each segment carries its original audio. 2. conform.js audioFlag was `audio === 'include' ? aac : -an`. The panel's v2.2.1 defaults send `audio: 'broadcast'`, which didn't match 'include' → `-an` explicitly stripped audio at the encode step. Switch to the opposite default: only an explicit 'none' or 'off' disables audio; everything else gets AAC 320k @ 48kHz. 3. conform.js video codec map only matched `codec === 'prores'`. The panel sends `'prores_hq'` (and the conform slide panel can send `'prores_4444'` / `'dnxhr_hq'`). All of those fell through to libx264 and silently rendered H.264 instead of the requested codec. Add a real codec map with the right prores_ks profiles (3=HQ, 4=4444) and DNxHR. Skip -crf for ProRes since the profile encodes quality. The asset-row metadata's `codec` column is normalised the same way so the new asset record matches what was actually written. Co-Authored-By: Claude Opus 4.7 --- services/worker/src/ffmpeg/executor.js | 6 ++++ services/worker/src/workers/conform.js | 49 ++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/services/worker/src/ffmpeg/executor.js b/services/worker/src/ffmpeg/executor.js index ca2dbd1..c0e7308 100644 --- a/services/worker/src/ffmpeg/executor.js +++ b/services/worker/src/ffmpeg/executor.js @@ -185,10 +185,16 @@ export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => { const inSeconds = inPoint / fps; const frameCount = outPoint - inPoint; + // `-frames:v` bounds the video output but says nothing about audio. Without + // -c:a copy + -shortest the audio track is either dropped or runs past + // the trimmed video. We need audio in the final concat output, so passthrough + // the audio codec and stop on the shortest stream. const args = [ '-ss', inSeconds.toString(), '-i', inputPath, '-frames:v', frameCount.toString(), + '-c:a', 'copy', + '-shortest', '-start_number', '0', '-y', outputPath, diff --git a/services/worker/src/workers/conform.js b/services/worker/src/workers/conform.js index 566f2ff..8748973 100644 --- a/services/worker/src/workers/conform.js +++ b/services/worker/src/workers/conform.js @@ -233,15 +233,47 @@ export const conformWorker = async (job) => { await job.updateProgress(70); console.log(`[conform] Concatenating segments for job ${jobId}`); - // Use re-encode instead of stream copy for consistent output - const audioFlag = audio === 'include' ? ['-c:a', 'aac'] : ['-an']; + // Audio: be permissive. Anything that isn't an explicit 'none' should + // get encoded — the panel sends 'broadcast' (default), 'include' is the + // legacy value, and there's no reason to silently drop audio for any + // other label. 320k AAC is a safe broadcast-quality default in mp4. + const audioFlag = (audio === 'none' || audio === 'off') + ? ['-an'] + : ['-c:a', 'aac', '-b:a', '320k', '-ar', '48000']; + + // 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 = []; + if (codec === 'prores_hq' || codec === 'prores') { + videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3']; + } else if (codec === 'prores_4444') { + videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4']; + } else if (codec === 'h265' || codec === 'hevc') { + videoCodec = 'libx265'; + } else if (codec === 'dnxhr_hq') { + videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq']; + } 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 + : [ + '-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast', + '-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28', + ]; + await runFFmpeg([ '-f', 'concat', '-safe', '0', '-i', segmentListPath, - '-c:v', codec === 'prores' ? 'prores_ks' : codec === 'h265' ? 'libx265' : 'libx264', - '-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast', - '-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28', + '-c:v', videoCodec, + ...profileFlag, + ...qualityArgs, ...audioFlag, '-y', outputPath, ]); @@ -260,7 +292,12 @@ export const conformWorker = async (job) => { `conformed-${seqName.replace(/[^a-z0-9]/gi, '_')}.mp4`, `Conformed: ${seqName}`, outputKey, - codec === 'prores' ? 'prores' : codec === 'h265' ? 'hevc' : 'h264', + // Normalise the panel's codec id into the canonical name we store on + // the asset row. Keep aligned with the encode branch above. + (codec === 'prores_hq' || codec === 'prores_4444' || codec === 'prores') ? 'prores' + : (codec === 'h265' || codec === 'hevc') ? 'hevc' + : (codec === 'dnxhr_hq') ? 'dnxhd' + : 'h264', resolution !== 'match' ? resolution : '1920x1080', seqFps, null,