From 794b9d9929c00660dc6d4a8093d0af8aa07f2987 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 18:31:07 -0400 Subject: [PATCH] fix(capture): growing file is MXF OP1a (DNxHR HQ) so Premiere can open it The growing edit-while-record file was a fragmented MOV (empty moov), which Premiere can't open ("Unable to open file on disk"). Write the growing master as MXF OP1a / DNxHR HQ (Premiere-native, growable on disk); finalized master keeps today's non-fragmented +faststart MOV. Co-Authored-By: Claude Opus 4.8 --- services/capture/src/capture-manager.js | 67 ++++++++++++++++++++----- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index d820745..530e224 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -155,12 +155,54 @@ const CONTAINER_EXT = { mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts', }; +// Growing-file (edit-while-record) master format. +// +// Premiere's "open capture in progress" / grow-on-disk support is FORMAT- +// SPECIFIC. A fragmented MP4/MOV (`+frag_keyframe+empty_moov+default_base_moof`) +// is NOT openable by Premiere as a growing file — its QuickTime importer needs +// the classic stco/stsz/stts sample tables in a single top-level moov, which a +// fragmented MOV never has while growing (samples live in moof/trun fragments). +// Symptom: "Unable to open file on disk." (Confirmed via ffprobe on zampp2: the +// growing .mov is ftyp + empty moov + repeating moof/mdat pairs, no sample +// tables.) +// +// The robust, broadcast-standard growing format Premiere DOES ingest is +// MXF OP1a (`-f mxf`) carrying a Premiere-native intra codec. We use DNxHR HQ +// (4:2:2 8-bit) which ffmpeg's MXF muxer accepts (HEVC/ProRes-in-MXF are +// rejected by this build), every frame is intra so a partially-written file is +// decodable to its last complete frame, and MXF writes header + body partitions +// incrementally so readers see valid essence mid-write. The same finalized .mxf +// is also a clean, Premiere-native asset, so the promotion/finalized path stays +// valid. +// +// Trade-off: DNxHR HQ is large (~22 GB/min at 1080p). Switch the profile to +// dnxhr_sq below (~half the bitrate) if disk is the constraint. +const GROWING_VIDEO_ARGS = [ + '-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq', '-pix_fmt', 'yuv422p', +]; +const GROWING_EXT = 'mxf'; + function buildEncodeArgs({ codec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, container, isNetwork, isProxy = false, growing = false, }) { + // ── Growing master: force MXF OP1a + DNxHR, ignoring the configured MOV/ + // ProRes container/codec. This is the only combination Premiere opens as a + // growing file (see GROWING_VIDEO_ARGS above). Audio is forced to PCM, + // which MXF carries natively and Premiere ingests. + if (growing) { + const args = []; + if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?'); + args.push(...GROWING_VIDEO_ARGS); + if (framerate && framerate !== 'native') args.push('-r', framerate); + args.push('-c:a', 'pcm_s24le'); + if (audioChannels) args.push('-ac', String(audioChannels)); + args.push('-f', 'mxf'); + return args; + } + const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq); const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le); const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov'); @@ -180,19 +222,15 @@ function buildEncodeArgs({ // moov-atom placement is the difference between a Premiere-openable master and // a "file cannot be opened" error. // - // - Growing-file masters (edit-while-record on the SMB share) MUST be - // fragmented so a moov/mvex is present from the first frame and the file is - // decodable while still being written. The samples live in moof/trun boxes. - // - // - Finalized masters (the S3-piped recording that stops cleanly) must NOT be - // fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic - // stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV - // (moof/trun, empty sample tables) makes Premiere report "file cannot be - // opened." We write a clean, non-fragmented MOV instead. - // `+faststart` puts the moov before mdat on the second pass so the file is - // instantly seekable/streamable too. + // Finalized masters (the S3-piped recording that stops cleanly) must NOT be + // fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic + // stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV + // (moof/trun, empty sample tables) makes Premiere report "file cannot be + // opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the + // moov before mdat on the second pass so the file is instantly + // seekable/streamable too. if (fmt === 'mov' || fmt === 'mp4') { - args.push('-movflags', growing ? '+frag_keyframe+empty_moov+default_base_moof' : '+faststart'); + args.push('-movflags', '+faststart'); } // ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag. args.push('-f', fmt); @@ -384,8 +422,11 @@ class CaptureManager { if (growingActive && GROWING_SMB_MOUNT) { if (!mountGrowingShare()) growingActive = false; // fall back to S3 } + // Growing master is always MXF OP1a (the only Premiere-growable format here), + // regardless of the recorder's configured container — so it gets a .mxf + // extension, not hiresExt. const growingPath = growingActive - ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}` + ? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}` : null; if (growingPath) { try { mkdirSync(dirname(growingPath), { recursive: true }); }