fix(capture): finalized MOV master is non-fragmented so Premiere can open it
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 <noreply@anthropic.com>
This commit is contained in:
parent
ea615c8c76
commit
549ca6c73f
1 changed files with 91 additions and 13 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue