fix(capture): restore direct-to-S3 streaming (pipe:1 + fragmented MOV)

Reverts the local-temp+faststart approach from 549ca6c. Masters now stream
ffmpeg stdout directly to S3 via multipart upload — no local disk consumed
on the worker. Uses +frag_keyframe+empty_moov+default_base_moof which
Premiere Pro 25.x handles natively (to be confirmed separately).

Zero /tmp/capture files. Worker disk stays flat during recording.
This commit is contained in:
Wild Dragon Dev 2026-06-03 21:40:58 +00:00
parent dc66833247
commit 37b325e1d8

View file

@ -1,5 +1,5 @@
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
@ -484,18 +484,13 @@ 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.
//
// 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.
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+faststart');
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
@ -1098,32 +1093,18 @@ exit "$BMXRC"
|| ((sourceType === 'blackmagic') && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination (NON-growing path only).
// Master output destination.
//
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
// are unused in this case (the master path is `growingPath`).
// elementary essence FIFOs + HLS preview.
//
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
// temp file, then we 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 Premiere-native master.
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 = localMasterPath;
// When bridgeProcess is an fc_pipe process its stdout is piped to ffmpeg
// stdin (pipe:0 input). For all other sources stdin is ignored.
const hiresStdio = bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'];
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI/framecache sources (including network via framecache) the live
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
@ -1197,7 +1178,7 @@ exit "$BMXRC"
hiresArgs = [
...inputArgs,
'-filter_complex', filterStr,
// Output 0 — master (local temp, uploaded to S3 on stop)
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
'-map', '[vhi]', ...masterAudioMap,
...masterAudioFilter,
...hiresCodecArgs,
@ -1226,13 +1207,15 @@ exit "$BMXRC"
}
}
// 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.
// bridgeProcess is null for deltacast (bridge managed by node-agent on the host).
// Growing: promotion worker handles S3 upload after stop.
// Non-growing: start streaming stdout directly to S3 now (multipart upload
// completes when ffmpeg exits and closes the pipe).
const processes = { hires: hiresProcess };
const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };
const uploads = {
hires: growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
};
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
@ -1469,30 +1452,11 @@ exit "$BMXRC"
unmountGrowingShare();
try {
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
// Growing: promotion worker handles S3.
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.hires) uploadPromises.push(currentSession.uploads.hires);
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {