From 549ca6c73fae156c74cb5ea3876ba290faa4e41e Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 18:14:59 -0400 Subject: [PATCH] fix(capture): finalized MOV master is non-fragmented so Premiere can open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hi-res master was streamed to S3 over a non-seekable pipe, which forced a fragmented MOV (+frag_keyframe+empty_moov) with empty stco/stsz sample tables — Premiere reports "file cannot be opened". Now: fragmentation only for the growing SMB file; finalized master writes to a seekable local temp with +faststart, stop() awaits ffmpeg exit to flush the moov, then uploads the finalized file and cleans up. Co-Authored-By: Claude Opus 4.8 --- services/capture/src/capture-manager.js | 104 +++++++++++++++++++++--- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index de6244c..d820745 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -1,5 +1,5 @@ import { spawn, execFileSync } from 'child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs'; import { dirname } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { createUploadStream } from './s3/client.js'; @@ -159,6 +159,7 @@ function buildEncodeArgs({ codec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, container, isNetwork, isProxy = false, + growing = false, }) { 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); @@ -176,9 +177,24 @@ function buildEncodeArgs({ if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate); if (audioChannels) args.push('-ac', String(audioChannels)); + // 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. if (fmt === 'mov' || fmt === 'mp4') { - args.push('-movflags', '+frag_keyframe+empty_moov'); + args.push('-movflags', growing ? '+frag_keyframe+empty_moov+default_base_moof' : '+faststart'); } + // ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag. args.push('-f', fmt); return args; @@ -399,17 +415,39 @@ class CaptureManager { container, isNetwork, isProxy: false, + // Only the growing-file master (written to the SMB share for + // edit-while-record) needs a fragmented MOV. The finalized, S3-piped + // master must be a clean non-fragmented MOV so Premiere can open it. + growing: !!growingPath, }); console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : []; - // When growing-files is on, write directly to the SMB share so Premier - // can mount and edit the live file. Promotion worker uploads to S3 on EOF. - // Otherwise, stream the master to S3 via stdout pipe (legacy behavior). - const hiresOutput = growingPath ? growingPath : 'pipe:1'; - const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe']; + // Master output destination. + // + // - Growing-files on → write directly to the SMB share (fragmented MOV) so + // Premiere can mount and edit the live file; promotion worker uploads on EOF. + // + // - Growing-files off → write to a LOCAL SEEKABLE temp file, then upload to + // S3 on stop. We must NOT pipe the MOV muxer to S3 directly: the MOV/MP4 + // muxer cannot write to a non-seekable pipe without `empty_moov`, and an + // empty_moov/fragmented MOV is exactly what makes Adobe Premiere report + // "file cannot be opened" (no classic stco/stsz sample tables — samples + // live in moof/trun). A seekable file lets ffmpeg write a single + // contiguous moov with full sample tables and `+faststart` moves it to the + // front, producing a master Premiere opens natively. + const localMasterPath = growingPath + ? null + : `/tmp/capture/${sessionId}.${hiresExt}`; + if (localMasterPath) { + try { mkdirSync(dirname(localMasterPath), { recursive: true }); } + catch (err) { console.error('[capture] could not create temp master dir:', err.message); } + } + const hiresOutput = growingPath ? growingPath : localMasterPath; + // ffmpeg now writes a file (not stdout) in both modes → stdout is unused. + const hiresStdio = ['ignore', 'ignore', 'pipe']; // For SDI we cannot open the DeckLink device a second time for a preview // tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires @@ -444,12 +482,12 @@ class CaptureManager { const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio }); - const hiresUpload = growingPath - ? Promise.resolve({ growingPath }) - : createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout); - + // Growing-files: nothing to upload here (promotion worker handles S3). + // Non-growing: the master is uploaded from the finalized local file in + // stop(), once ffmpeg has written the moov and exited cleanly — we can't + // upload while recording because the file isn't a valid MOV until finalize. const processes = { hires: hiresProcess }; - const uploads = { hires: hiresUpload }; + const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null }; // ── HLS tee for network sources (live preview in the UI) ────────── let hlsProcess = null; @@ -515,6 +553,7 @@ class CaptureManager { hiresKey, proxyKey, growingPath, + localMasterPath, startedAt, duration: 0, uploads, @@ -536,17 +575,56 @@ class CaptureManager { const { processes, currentSession } = this.state; + // Send SIGINT and WAIT for ffmpeg to exit. This is what flushes the MOV + // trailer (writes the moov atom with the full sample tables). If we uploaded + // before ffmpeg finalized, the object would have no moov → "moov atom not + // found" / "file cannot be opened" in Premiere. + const waitExit = (proc) => new Promise((resolve) => { + if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve(); + let done = false; + const finish = () => { if (!done) { done = true; resolve(); } }; + proc.once('exit', finish); + // Safety net: don't hang stop() forever if ffmpeg refuses to exit. + setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} finish(); }, 15000); + }); + if (processes.hires) processes.hires.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } + // Wait for the master writer to finalize before we read/upload the file. + await waitExit(processes.hires); + // Release the CIFS mount (best-effort) once the ffmpeg writers are done with // it. The promotion worker reads the staged file from the host/S3 side, not // through this container's mount, so unmounting here is safe. unmountGrowingShare(); try { - const uploadPromises = [currentSession.uploads.hires]; + const uploadPromises = []; + + // Non-growing: upload the finalized local master file to S3 now that the + // moov has been written. Growing: the promotion worker handles S3. + if (currentSession.localMasterPath) { + let size = 0; + try { size = statSync(currentSession.localMasterPath).size; } catch (_) {} + if (size > 0) { + uploadPromises.push( + createUploadStream( + S3_BUCKET, + currentSession.hiresKey, + createReadStream(currentSession.localMasterPath), + ).then(() => { + try { unlinkSync(currentSession.localMasterPath); } catch (_) {} + }) + ); + } else { + console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath); + } + } else if (currentSession.uploads.hires) { + uploadPromises.push(currentSession.uploads.hires); + } + if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy); await Promise.all(uploadPromises); } catch (error) {