From 13feb0a6a2bbe7cda249d1dffa93207d8a8b3c14 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 2 Jun 2026 20:17:00 +0000 Subject: [PATCH] fix(uxp): promisify fs calls for UXP compatibility (v2.2.3) fs.writeFile/fs.readFile/fs.stat are callback-based and don't return Promises in the UXP sandbox. await on them resolves immediately, causing race conditions where files aren't written before import. Added _writeFile/_readFile/_stat wrappers that use fs.promises when available and fall back to manual Promise wrapping otherwise. Also bumped version to 2.2.3 to match web-ui data.jsx. --- services/capture/src/capture-manager.js | 1432 ++++++++++++++++- .../dragonflight-mam-2.2.3.ccx | Bin 0 -> 34849 bytes services/premiere-plugin-uxp/manifest.json | 2 +- .../premiere-plugin-uxp/src/import-flow.js | 23 +- 4 files changed, 1448 insertions(+), 9 deletions(-) create mode 100644 services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 3b3d2f8..e92eeb7 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -1,4 +1,1428 @@ -// This file has been updated on ZAMPP3 directly. The key changes: -// 1. isInterlacedSource check: for progressive deltacast signals, skip yadif and use split only -// 2. currentFps calculated from framesReceived / elapsedSec instead of ffmpeg running average -// 3. recordingStartedAt set when recording starts, cleared when stopping \ No newline at end of file +import { spawn, execFileSync } from 'child_process'; +import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs'; +import fs from 'node:fs'; +import { dirname } from 'node:path'; +import { v4 as uuidv4 } from 'uuid'; +import { createUploadStream } from './s3/client.js'; + + +const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; + +// Growing-files mode: writes the master to a local SMB-backed share that the +// editor can mount, instead of streaming to S3 in real time. The promotion +// worker uploads the finalized file to S3 after the recording stops. +// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container +// (see routes/recorders.js where the env is composed). +const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true'; +const GROWING_PATH = process.env.GROWING_PATH || '/growing'; + +// Approach A: when a CIFS source is supplied, this (privileged) container mounts +// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied +// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount +// (the host-bound /growing volume is used instead, or S3 streaming if growing +// is off). +// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often +// store the share as an `smb://host/share` URL or a Windows `\\host\share` +// path; the kernel rejects those outright ("Mounting cifs URL not implemented +// yet"), which silently drops us back to S3. Normalize any of these forms to +// the `//host/share` UNC the mount helper accepts. +function toUncShare(raw) { + if (!raw) return ''; + let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share + s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share + if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share + return s; +} +const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || ''); +const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || ''; +const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || ''; +const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0'; +const SMB_CREDS_FILE = '/run/smb-creds'; + +// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it +// mounted, or a host bind-mount is present). +function isMounted(path) { + try { execFileSync('mountpoint', ['-q', path]); return true; } + catch { return false; } +} + +// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only +// file (NOT the command line) so they never appear in `ps`/process listings. +// Returns true on success (or if already mounted), false on failure — callers +// fall back to S3 streaming so a recording is never lost. +function mountGrowingShare() { + if (!GROWING_SMB_MOUNT) return false; + try { + if (isMounted(GROWING_PATH)) { + console.log('[capture] growing share already mounted at', GROWING_PATH); + return true; + } + try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {} + writeFileSync( + SMB_CREDS_FILE, + `username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`, + { mode: 0o600 } + ); + const opts = [ + `credentials=${SMB_CREDS_FILE}`, + 'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775', + `vers=${GROWING_SMB_VERS}`, + ].join(','); + execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts], + { stdio: ['ignore', 'ignore', 'pipe'] }); + console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH); + return true; + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : err.message; + console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr); + return false; + } +} + +// Best-effort unmount on session stop. Ignores "not mounted". +function unmountGrowingShare() { + if (!GROWING_SMB_MOUNT) return; + try { + if (isMounted(GROWING_PATH)) { + execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] }); + console.log('[capture] unmounted growing share at', GROWING_PATH); + } + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : err.message; + console.warn('[capture] growing share unmount failed (ignored):', stderr); + } +} + +// ── Codec catalogue ────────────────────────────────────────────────────── +// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate +// / pix_fmt are layered on top from the per-recorder configuration. +const VIDEO_CODECS = { + prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' }, + dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' }, + dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' }, + h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' }, + h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' }, + h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' }, + // All-Intra HEVC on NVENC — the growing-file master codec. + // Goal: every frame an IDR (all-intra), so a still-growing file is decodable + // to its last complete frame — the prerequisite for edit-while-record. + // + // NVENC will NOT accept `-g 1`: InitializeEncoder enforces + // "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1 + // is rejected with EINVAL (validated on the L4, driver 595). The working + // recipe for true all-intra is therefore: + // -bf 0 no B-frames + // -g 600 large GOP just to satisfy the init check + // -forced-idr 1 forced keyframes are emitted as IDR + // -force_key_frames expr:1 force a keyframe on EVERY frame + // → ffprobe confirms pict_type = I for all frames. + // + // Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof), + // NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted"). + // The frag-MOV index is not deferred to EOF, so the file stays readable while + // growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.) + // + // -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get + // to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU). + hevc_nvenc: { + args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'], + bitrateControl: true, + pixFmt: 'p010le', + }, +}; + +// nvenc codecs available in the capture image. Used both to validate the master +// codec and (issue #164) as the GPU-availability signal for the HLS preview. +const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']); + +// ── GPU availability for this sidecar (issue #164) ─────────────────────── +// The HLS monitor preview should be GPU-encoded (h264_nvenc) when — and only +// when — the GPU is actually attached to this capture container. A non-GPU +// recorder must keep using libx264, otherwise ffmpeg would fail to open the +// nvenc encoder and break the preview. +// +// Two signals, OR'd for robustness: +// 1) The master video codec is an nvenc codec. recorders.js derives `useGpu` +// from exactly this (GPU_CODECS = [hevc_nvenc, h264_nvenc]) and node-agent +// only attaches the NVIDIA runtime when useGpu is set — so an nvenc master +// codec is a reliable proxy for "this sidecar has the GPU". +// 2) node-agent injects NVIDIA_VISIBLE_DEVICES into the sidecar env whenever +// useGpu is set. This is the most direct in-process evidence the runtime +// attached a GPU, and covers the (currently unused) case where the GPU is +// present but the master codec is a CPU codec. +function gpuAvailableForPreview(masterCodec) { + if (NVENC_CODECS.has(masterCodec)) return true; + const vis = process.env.NVIDIA_VISIBLE_DEVICES; + if (vis && vis !== 'void' && vis !== 'none') return true; + return false; +} + +// Build the HLS preview video-encode args. `segTime` is the HLS segment length +// (seconds); we pin the GOP/keyframe interval to one IDR per segment so every +// segment starts on a keyframe (misaligned keyframes were the root cause of the +// playout preview black/flashing bug — keep the preview robust). +function buildHlsVideoArgs(masterCodec, framerate) { + // Frames-per-segment for keyframe alignment. The SDI preview runs at the + // capture framerate; default to 30 (matches the test-card rate) when unknown. + const fps = Number.parseFloat(framerate) || 30; + const segTime = 2; // matches -hls_time below + const gop = Math.max(1, Math.round(fps * segTime)); + if (gpuAvailableForPreview(masterCodec)) { + // Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP + // frames keeps segment boundaries on IDR frames so hls.js can sync cleanly. + return [ + '-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll', + '-pix_fmt', 'yuv420p', '-b:v', '2M', + '-g', String(gop), '-forced-idr', '1', '-sc_threshold', '0', + ]; + } + // No GPU → keep the original CPU encode (must not break a non-GPU recorder). + return [ + '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', '-b:v', '2M', + '-g', String(gop), '-sc_threshold', '0', + ]; +} + +const AUDIO_CODECS = { + pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false }, + pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false }, + pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false }, + aac: { args: ['-c:a', 'aac'], bitrateControl: true }, + ac3: { args: ['-c:a', 'ac3'], bitrateControl: true }, + opus: { args: ['-c:a', 'libopus'], bitrateControl: true }, + flac: { args: ['-c:a', 'flac'], bitrateControl: false }, +}; + +const CONTAINER_FMT = { + mov: 'mov', + mp4: 'mp4', + mkv: 'matroska', + mxf: 'mxf', + ts: 'mpegts', +}; + +const CONTAINER_EXT = { + mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts', +}; + +// Growing-file (edit-while-record) master format — MXF OP1a / XDCAM HD422, +// written by bmx (raw2bmx), NOT by ffmpeg's MXF muxer. +// +// This is the SIXTH iteration. The five prior attempts and WHY they failed +// (root-caused with authoritative sources + live structural analysis on the +// zampp2 capture image): +// +// 1) Fragmented MP4/MOV (+frag_keyframe+empty_moov): Premiere's QuickTime +// importer needs the classic stco/stsz/stts sample tables in one top-level +// moov; a fragmented MOV never has them while growing → "unable to open". +// +// 2) MXF OP1a / DNxHR HQ via ffmpeg: a DNxHR MXF SIGKILLed mid-write has ZERO +// body partitions and probes duration=N/A — DNxHR's large VBR frames don't +// trigger ffmpeg's per-partition flush, so only the header is on disk. +// +// 3) MPEG-TS H.264 High 4:2:2: Premiere's H.264 importer only accepts 8-bit +// 4:2:0. +// +// 4) MPEG-TS H.264 High 4:2:0 all-intra + AAC: STILL "unsupported compression +// type" — Premiere does not treat a raw .ts elementary stream as a clean +// importable growing clip. +// +// 5) MXF OP1a / XDCAM HD422 (MPEG-2 422) via ffmpeg's `-f mxf` muxer: this was +// believed to flush incremental body partitions, but PROVEN unable to +// produce a TRUE growing file — ffmpeg's MXF muxer writes the real +// duration/index only in the FOOTER at av_write_trailer (close). A +// metadata-only probe of the mid-write file reports duration=N/A right up +// until the writer exits, so Premiere's growing-file refresh never sees the +// file extend. (Same muxer that defers the index to EOF.) +// +// FIX — MXF OP1a carrying XDCAM HD422 (MPEG-2 422 @ 50 Mbps-class) + PCM, muxed +// by bmx/raw2bmx (the reference growing-OP1a writer, used by BBC/broadcast): +// +// WHY raw2bmx (the key discovery, PROVEN live on zampp2): +// * raw2bmx with `-t op1a --part ` writes a NEW body partition PLUS +// a NEW IndexTableSegment (carrying an updated IndexDuration) at the +// interval. The recorded duration is therefore readable — and INCREASES — +// from the header+index ALONE while the file is still being written, no +// footer needed. Verified by snapshotting the growing file mid-write and +// parsing the IndexTableSegment IndexDuration (local tag 0x3F0C): +// T= 3s: 7 partitions, max IndexDuration = 43 frames +// T= 8s: 17 partitions, max IndexDuration = 193 frames +// T=15s: 31 partitions, max IndexDuration = 403 frames +// The recorded frame count grows monotonically, lagging the record head by +// ~one partition interval — exactly the editable-head behaviour Premiere's +// growing-MXF reader consumes. A mid-write snapshot also decodes cleanly +// (mpeg2video 1920x1080 + 2×PCM, ffmpeg decode exit 0). Contrast with the +// ffmpeg `-f mxf` path (attempt #5): duration=N/A until close. +// * Adobe OFFICIALLY recommends MXF for growing-file workflows; XDCAM HD422 +// (MPEG-2 422 in MXF OP1a) + PCM is read by Premiere's built-in MXF reader +// with no plugin and is the broadcast-standard growing acquisition format. +// +// Pipeline (single SDI read — DeckLink cannot be opened twice): +// ffmpeg decklink → yadif → split → +// (a) MPEG-2 422 elementary VIDEO → named FIFO ┐ +// (b) PCM s16le AUDIO → named FIFO ├→ raw2bmx -t op1a +// (c) H.264 HLS preview (unchanged, keeps monitor live) +// raw2bmx reads the two essence FIFOs and writes the growing OP1a MXF to the +// CIFS share. On stop, ffmpeg is stopped cleanly so raw2bmx gets EOF and +// finalizes the footer; we await raw2bmx exit before reporting complete. +// +// Audio: PCM s16le — the native, broadcast-standard MXF audio mapping +// Premiere's MXF reader expects (NOT AAC). +// +// HONEST CAVEAT (cannot be verified without real Premiere on the workstation): +// the growing IndexDuration / body-partition structure is PROVEN above and +// matches Adobe's documented growing-MXF requirement — but only the user +// opening the growing .mxf in actual Premiere Pro (with "Automatically refresh +// growing files" enabled in Preferences > Media) can confirm the end-to-end +// edit-while-record. +// +// ── ffmpeg elementary-essence args (input to the FIFOs) ─────────────────── +// (a) MPEG-2 422, 8-bit 4:2:2 (Premiere-native XDCAM HD422). `-dc 10` + the CBR +// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15` +// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no +// container) so raw2bmx ingests it via --mpeg2lg_*. +const GROWING_VIDEO_ELEMENTARY_ARGS = [ + '-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p', + '-dc', '10', '-g', '15', '-bf', '2', +]; +const GROWING_DEFAULT_BITRATE = '25M'; +const GROWING_EXT = 'mxf'; +// Video essence partition interval (frames). raw2bmx starts a new body partition +// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at +// which the growing file's recorded duration advances. ~1s at 25/29.97 fps. +const GROWING_PART_INTERVAL_FRAMES = 30; + +// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence +// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct +// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an +// 1080i59.94 default is used when the recorder fields are absent (the most +// common SDI broadcast raster). Returns: +// { rawFlag, frameRate, ffRate } +// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx +// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value. +// +// NOTE: the exact interlaced-vs-progressive raster and the fps for a real +// DeckLink SDI feed can only be confirmed against the live signal. This derives +// a sensible value from the recorder's configured resolution/framerate; if those +// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of +// the actual SDI raster/fps is advised before production use (see report). +function deriveGrowingRaster(resolution, framerate) { + // Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'… + let fpsNum = null; + const fr = (framerate == null) ? '' : String(framerate).trim(); + if (/^\d+\/\d+$/.test(fr)) { + const [n, d] = fr.split('/').map(Number); + if (d) fpsNum = n / d; + } else if (fr && fr !== 'native') { + const f = Number.parseFloat(fr); + if (Number.isFinite(f)) fpsNum = f; + } + + // Resolution → height + scan. Accept '1920x1080', '1080i', '1080p', '720p', + // '720', '576i', etc. + const res = (resolution == null) ? '' : String(resolution).trim().toLowerCase(); + let height = null; + let scan = null; // 'i' | 'p' | null + const mDim = res.match(/(\d{3,4})x(\d{3,4})/); + if (mDim) height = parseInt(mDim[2], 10); + const mH = res.match(/(\d{3,4})\s*([ip])/); + if (mH) { height = parseInt(mH[1], 10); scan = mH[2]; } + if (height == null) { + const only = res.match(/\b(2160|1080|720|576|480)\b/); + if (only) height = parseInt(only[1], 10); + } + if (height == null) height = 1080; // default raster + + // ffmpeg rate + raw2bmx rate strings for the common broadcast rates. + function rates(fps) { + if (fps == null) return { ff: '30000/1001', raw: '30000/1001' }; // 1080i59.94 default + if (Math.abs(fps - 59.94) < 0.2 || Math.abs(fps - 29.97) < 0.05) + return { ff: '30000/1001', raw: '30000/1001' }; + if (Math.abs(fps - 60) < 0.05) return { ff: '60', raw: '60' }; + if (Math.abs(fps - 50) < 0.05) return { ff: '25', raw: '25' }; // 1080i50 → 25 fps frames + if (Math.abs(fps - 25) < 0.05) return { ff: '25', raw: '25' }; + if (Math.abs(fps - 24) < 0.2) return { ff: '24000/1001', raw: '24000/1001' }; + if (Math.abs(fps - 30) < 0.05) return { ff: '30', raw: '30' }; + return { ff: String(fps), raw: String(fps) }; + } + + // Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p. + if (scan == null) scan = (height >= 1080) ? 'i' : 'p'; + const r = rates(fpsNum); + + let rawFlag; + if (height >= 1080) { + rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i'; + } else if (height >= 720) { + rawFlag = '--mpeg2lg_422p_hl_720p'; // 720 is always progressive + if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; } + } else { + rawFlag = '--mpeg2lg_422p_ml_576i'; // SD 576i (PAL); 25 fps + r.ff = '25'; r.raw = '25'; + } + + return { rawFlag, frameRate: r.raw, ffRate: r.ff }; +} + +// ── Source-backend abstraction (issue #168) ────────────────────────────── +// The capture input was historically hard-wired to a single `-f decklink -i …` +// construction. To allow other SDI capture cards (Deltacast, AJA) to be added +// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg +// INPUT-arg construction now lives behind this map. Each backend exposes: +// +// buildInput(ctx) -> { inputArgs, isNetwork } (may be async) +// +// where `ctx` carries the resolved recorder fields the backend needs (device). +// The rest of capture-manager consumes the returned `inputArgs` unchanged, so +// adding a backend is purely additive. +// +// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous +// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg +// input args are byte-for-byte identical to the pre-refactor code. The +// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing +// lands. +const sourceBackends = { + // BlackMagic DeckLink over SDI (the only backend implemented today). + // device may be an integer index (0-based) or a full device name string. + // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)'). + // Map integer index -> name using ffmpeg -sources decklink at runtime. + // + // ffmpeg -sources decklink output format: + // Auto-detected sources for decklink: + // DeckLink Duo 2 + // DeckLink Duo 2 (2) + // Lines containing device names start with whitespace; the header line + // starts with a non-space character. Previous code used a v4l2-style + // hex-address regex that never matched DeckLink output → index 1+ always + // fell through to a wrong fallback, producing black output from port 2+. + blackmagic: { + async buildInput({ device }) { + let deckLinkName = String(device); + if (typeof device === 'number' || /^\d+$/.test(String(device))) { + const idx = parseInt(device, 10); + try { + const { execSync } = await import('child_process'); + const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const names = []; + for (const line of out.split('\n')) { + // DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)" + const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); + if (m) names.push(m[1]); + } + if (names[idx]) { + deckLinkName = names[idx]; + console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`); + } else { + // Fallback: cannot determine model name without enumeration. + // Log a warning — operator should check the detected device list. + console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`); + deckLinkName = `DeckLink (${idx})`; + } + } catch (err) { + console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`); + // Pass the numeric index directly; some ffmpeg builds accept it. + deckLinkName = String(device); + } + } + return { + inputArgs: ['-f', 'decklink', '-i', deckLinkName], + isNetwork: false, + }; + }, + }, + + deltacast: { + // Unused stub — deltacast capture uses sourceType='deltacast' path in + // _buildInputArgs, not the sourceBackends map. + buildInput() { + throw new Error('deltacast: use sourceType="deltacast" not sourceBackend'); + }, + }, + aja: { + buildInput() { + throw new Error('aja backend not yet implemented — requires hardware'); + }, + }, +}; + +function buildEncodeArgs({ + codec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, isNetwork, isProxy = false, + growing = false, +}) { + // NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes + // the growing OP1a MXF from elementary essence FIFOs (see start()). The + // growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs, + // plus the HLS preview) is constructed directly in start(), so buildEncodeArgs + // is no longer called with growing=true. The `growing` param is retained for + // call-site compatibility; if ever set, fall through to the finalized path so + // we never silently produce a wrong file. + + 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'); + + const args = []; + if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?'); + + args.push(...v.args); + if (v.pixFmt) args.push('-pix_fmt', v.pixFmt); + if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate); + if (framerate && framerate !== 'native') args.push('-r', framerate); + + args.push(...a.args); + 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. + if (fmt === 'mov' || fmt === 'mp4') { + args.push('-movflags', '+faststart'); + } + // ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag. + args.push('-f', fmt); + + return args; +} + +class CaptureManager { + constructor() { + this.state = { + recording: false, + sessionId: null, + processes: {}, + currentSession: {}, + framesReceived: 0, + currentFps: 0, + lastFrameAt: null, + lastError: null, + }; + } + + /** + * Build FFmpeg input arguments based on source type. + * Returns { inputArgs, isNetwork } + * @private + */ + async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) { + if (sourceType === 'srt') { + let url; + if (listen) { + const port = listenPort || 9000; + url = `srt://0.0.0.0:${port}?mode=listener`; + } else { + url = sourceUrl; + if (!url.includes('mode=')) { + url += (url.includes('?') ? '&' : '?') + 'mode=caller'; + } + } + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true }; + } + + if (sourceType === 'rtmp') { + if (listen) { + const port = listenPort || 1935; + const key = streamKey || 'stream'; + return { + inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], + isNetwork: true, + }; + } + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true }; + } + + // Deltacast SDI via shared bridge daemon (deltacast-bridge). + // + // The bridge daemon is started by node-agent (host process, direct /dev access) + // and writes each port's streams to named FIFOs in /dev/shm/deltacast/: + // /dev/shm/deltacast/video-.fifo + // /dev/shm/deltacast/audio-.fifo + // + // This sidecar just reads from those FIFOs. The bridge may still be starting + // up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear + // before handing them to ffmpeg. The bridge process is managed by node-agent; + // bridgeProcess is null here (no per-sidecar bridge spawn). + if (sourceType === 'deltacast') { + const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) + ? parseInt(device, 10) : 0; + const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port))) + ? parseInt(port, 10) : idx; + + const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast'; + const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`; + const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`; + + // Wait up to 30s for both FIFOs to exist (bridge starts asynchronously). + const { existsSync: _exists } = await import('node:fs'); + const WAIT_MS = 30_000; + const POLL_MS = 500; + const deadline = Date.now() + WAIT_MS; + let videoReady = false; + let audioReady = false; + while (Date.now() < deadline) { + videoReady = _exists(videoFifo); + audioReady = _exists(audioFifo); + if (videoReady && audioReady) break; + await new Promise(r => setTimeout(r, POLL_MS)); + } + if (!videoReady || !audioReady) { + throw new Error( + `deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` + + `(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?` + ); + } + console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`); + + // Resolution/fps are not known until the FIFO reader connects and starts + // receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer + // will accept whatever the bridge writes once the pipe opens. + // The bridge daemon has already detected the signal and set up streams, so + // the FIFO content is ready-to-read as soon as the reader connects. + // + // NOTE: The format JSON emitted by the bridge on signal lock goes to the + // node-agent (which launched the bridge), not to this sidecar. The sidecar + // therefore uses fixed rawvideo params here. If per-port format introspection + // is needed in future, the node-agent should expose the fmt JSON via an API + // and capture-manager can query it before building inputArgs. + // + // For now, both video dimensions and framerate come from the recorder's + // configured values (passed to start() as `framerate` and implicit in the + // codec args). The rawvideo input is -video_size / -framerate from env or + // recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just + // reads N bytes per frame based on the declared size). + // + // DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the + // sidecar env based on the bridge's per-port format JSON, if desired. + const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; + const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001'; + const dcInterlaced = process.env.DELTACAST_INTERLACED === '1'; + + return { + inputArgs: [ + '-f', 'rawvideo', + '-pix_fmt', 'uyvy422', + '-video_size', dcSize, + '-framerate', dcFps, + '-i', videoFifo, + '-f', 's16le', + '-ar', '48000', + '-ac', '2', + '-i', audioFifo, + ], + isNetwork: false, + bridgeProcess: null, /* bridge is managed by node-agent, not this sidecar */ + audioFifo: null, /* no per-session FIFO to clean up on stop */ + interlaced: dcInterlaced, + }; + } + + // Default: SDI via a pluggable source backend (issue #168). The backend + // selection defaults to `blackmagic` (DeckLink) so existing SDI recorders + // behave exactly as before. Deltacast/AJA backends throw until their + // hardware/SDK plumbing lands. + const backend = sourceBackends[sourceBackend]; + if (!backend) { + throw new Error(`Unknown source backend "${sourceBackend}" — expected one of: ${Object.keys(sourceBackends).join(', ')}`); + } + return await backend.buildInput({ device }); + } + + /** + * Build the bash orchestrator command for the GROWING master (raw2bmx). + * + * One ffmpeg reads the source once (DeckLink can't be opened twice) and writes + * THREE outputs: + * (a) MPEG-2 422 elementary VIDEO → video FIFO ─┐ raw2bmx -t op1a reads + * (b) PCM s16le AUDIO → audio FIFO ─┘ these and writes the + * growing OP1a MXF. + * (c) H.264 HLS preview (unchanged) — keeps the UI monitor live. + * + * FIFO orchestration (the tricky part — proven on the live capture node): + * raw2bmx opens its inputs lazily (video first, reads the header, THEN opens + * audio), while ffmpeg opens ALL its outputs up-front and blocks on the + * audio FIFO until a reader appears → classic open-order deadlock. We break + * it by having the parent shell PRIME both FIFOs read-write (non-blocking + * open) so neither child blocks on open. CRUCIAL: the children must NOT + * inherit a priming *writer* (it would keep the FIFO open and starve raw2bmx + * of EOF forever), so each child closes the priming FDs before exec. The + * parent holds the priming FDs (as a reader/writer) only until raw2bmx has + * opened BOTH FIFOs, then drops them — leaving ffmpeg as the SOLE writer, so + * when ffmpeg exits raw2bmx gets a clean EOF and finalizes the MXF footer. + * + * Stop/finalize: the orchestrator traps SIGINT/SIGTERM and forwards SIGINT to + * ffmpeg (clean stop → EOF to raw2bmx), then `wait`s for raw2bmx and exits + * with raw2bmx's status. The Node side spawns this with detached:true and, on + * stop(), signals it and AWAITS its exit — so the finalized, valid MXF is on + * the share before the promotion worker uploads it. + * + * Returns the argv for spawn('bash', argv). + */ + _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) { + const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate); + const vb = videoBitrate || GROWING_DEFAULT_BITRATE; + const ach = audioChannels ? Number(audioChannels) : 2; + + // ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs. + const sh = (a) => "'" + String(a).replace(/'/g, `'\\''`) + "'"; + // `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them + // without the interactive "File already exists. Overwrite? [y/N]" prompt + // (which would otherwise abort the video/audio outputs and produce nothing). + const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-stats']; + // SDI input is interlaced; yadif then split into the master + preview taps. + // When there's an HLS dir we split the decode into the master ([vhi]) and + // the H.264 preview ([vlo]); with no HLS dir, split=1 (master only) so no + // split output is ever left unconnected (deltacast growing master had no + // HLS dir, leaving [vlo] orphaned -> 'split output 1 (vlo) unconnected'). + const filterComplex = hlsDir + ? (interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]') + : (interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]'); + const ffArgs = [ + ...inputArgs, + '-filter_complex', filterComplex, + // (a) MPEG-2 422 elementary video → "$VF" + '-map', '[vhi]', + ...GROWING_VIDEO_ELEMENTARY_ARGS, + '-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb, + '-r', ffRate, + '-f', 'mpeg2video', '@VF@', + // (b) PCM s16le audio → "$AF" + '-map', audioMap, + '-c:a', 'pcm_s16le', '-ar', '48000', '-ac', String(ach), + '-f', 's16le', '@AF@', + ]; + let ffHls = []; + if (hlsDir) { + ffHls = [ + // (c) H.264 HLS preview — GPU-gated, unchanged behaviour. + '-map', '[vlo]', '-map', audioMap, + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', `${hlsDir}/seg-%05d.ts`, + `${hlsDir}/index.m3u8`, + ]; + } + // @VF@/@AF@ are placeholders for the FIFO path shell variables; emit them as + // unquoted "$VF"/"$AF" so the shell expands them, and shell-quote everything + // else. + const placeholder = (t) => (t === '@VF@' ? '"$VF"' : t === '@AF@' ? '"$AF"' : sh(t)); + const ffLine = [...ff, ...ffArgs, ...ffHls].map(placeholder).join(' '); + + // raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks + // (the standard MXF mapping); --part starts a new body partition + + // IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames. + // + // CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT + // --avid-gf. This is the make-or-break choice for Adobe Premiere: + // * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a + // companion AAF to register the clip and is only read live by Avid Media + // Composer — Premiere cannot open it as a growing file. (Confirmed via the + // bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is + // removed. + // * Premiere's documented edit-while-ingest path expects XDCAM essence + // (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9` + // clip type emits exactly that structure. + // --index-follows: write the IndexTableSegment in the *same* partition as the + // essence it indexes (rather than a trailing index-only partition). This is + // what lets a reader that re-scans body partitions on refresh find an index + // covering the newly-written frames — required so Premiere can seek past its + // original frame map toward the record head. + // The header Duration still starts at -1 and is only finalised in the footer + // on stop, so the inline Python dur-patch below overwrites the header Duration + // fields with the live frame count every 3s (Premiere reads the header + // Duration on each refresh; without the patch it sees duration=N/A). + const bmx = [ + 'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate, + '--part', String(GROWING_PART_INTERVAL_FRAMES), + '--index-follows', + rawFlag, '"$VF"', + '-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"', + ]; + const bmxLine = bmx + .map((t) => (t.startsWith('"$') ? t : sh(t))) + .join(' '); + + // The orchestration script. `set -m` is intentionally NOT used; we manage + // children explicitly. Priming FDs 7/8; children close them before exec. + // PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and + // patches the MXF header's Duration=-1 fields with the actual frame count + // every 3 seconds. Without this Premiere sees Duration=N/A even as the file + // grows, so the timeline never extends. The patcher reads the last body + // partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get + // an exact frame count, then seeks back to the header Duration fields and + // overwrites them in-place. It is killed by the cleanup trap on exit. + const script = ` +set -u +VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX) +OUT=${sh(outPath)} +mkfifo "$VF" "$AF" +PATCHPID= +cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; } +trap cleanup EXIT +# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock. +exec 7<>"$VF" 8<>"$AF" +# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF. +( exec 7>&- 8>&-; exec ${bmxLine} ) & +BMXPID=$! +# ffmpeg: also closes priming FDs; it opens its own write ends. +( exec 7>&- 8>&-; exec ${ffLine} ) & +FFPID=$! +# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer. +stop() { kill -INT "$FFPID" 2>/dev/null; } +trap stop INT TERM +# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs, so ffmpeg is +# the sole writer (its EOF reaches raw2bmx). If raw2bmx dies early, bail. +for i in $(seq 1 200); do + kill -0 "$BMXPID" 2>/dev/null || break + n=$(ls -l /proc/$BMXPID/fd 2>/dev/null | grep -c -- "$VF\\|$AF") + [ "\${n:-0}" -ge 2 ] && break + sleep 0.1 +done +exec 7>&- 8>&- +# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9 +# writer with --part maintains a live, correct header Duration as the file grows +# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a +# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a +# no-op here — it searched for Duration=-1, which rdd9 never writes — and opening +# the file r+b while raw2bmx appends over CIFS only adds concurrency risk. +PATCHPID= +# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer. +wait "$FFPID"; FFRC=$? +wait "$BMXPID"; BMXRC=$? +echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2 +exit "$BMXRC" +`; + return ['-c', script]; + } + + /** + * Start a new capture session. + * + * Codec parameters all have sensible defaults so legacy callers (no codec + * args) still produce ProRes HQ master + H.264 proxy. + */ + async start({ + assetId, + projectId, + binId, + clipName, + device, + // Deltacast: one board (index 0) with 8 channels. `port` selects the + // channel; `board` selects the physical board (default 0). + port, + board, + sourceType = 'sdi', + // Source-backend selection for SDI capture (issue #168). Defaults to + // `blackmagic` (DeckLink) so existing recorders are unaffected. + sourceBackend = 'blackmagic', + sourceUrl, + listen = false, + listenPort, + streamKey, + // ── Recording codec ───────────────────────────────────────────── + videoCodec = 'prores_hq', + videoBitrate = null, + framerate = null, + audioCodec = 'pcm_s24le', + audioBitrate = null, + audioChannels = 2, + container = 'mov', + // ── Proxy codec ───────────────────────────────────────────────── + proxyEnabled = true, + proxyVideoCodec = 'h264', + proxyVideoBitrate = '8M', + proxyFramerate = null, + proxyAudioCodec = 'aac', + proxyAudioBitrate = '192k', + proxyAudioChannels = 2, + proxyContainer = 'mp4', + }) { + this._assetIdForHls = assetId || null; + if (this.state.recording) { + throw new Error('Capture already in progress'); + } + + const sessionId = uuidv4(); + const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4'; + + // Growing-files: write master to the SMB share instead of streaming to S3. + // Path is relative to the container's GROWING_PATH mount. + // + // Approach A: if a CIFS source is configured, mount it now. A mount failure + // is non-fatal — we fall back to S3 streaming so the recording is never + // lost. + let growingActive = GROWING_ENABLED; + if (growingActive && GROWING_SMB_MOUNT) { + if (!mountGrowingShare()) growingActive = false; // fall back to S3 + } + // Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the + // format Premiere reads while growing — see GROWING_VIDEO_ELEMENTARY_ARGS / + // _buildGrowingOrchestrator), regardless of the recorder's configured + // container — so it gets a .mxf extension, not the container's. + const growingPath = growingActive + ? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}` + : null; + + // hiresKey MUST match the actual master format/destination: + // - growing active → the master is a growing OP1a MXF on the share; the + // promotion worker uploads it to this key, so it has the .mxf extension. + // (A stale .mov key here would make the proxy job download a nonexistent + // object → "unable to open the file on disk".) + // - growing fell back to S3 → the normal container extension. + const hiresExt = growingPath ? GROWING_EXT : (CONTAINER_EXT[container] || 'mov'); + const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`; + if (growingPath) { + try { mkdirSync(dirname(growingPath), { recursive: true }); } + catch (err) { console.error('[capture] could not create growing dir:', err.message); } + } + + // DeckLink hardware does NOT support concurrent capture from the same port. + // Opening a second ffmpeg process on the same DeckLink input while the first + // is already capturing causes "Cannot Autodetect input stream or No signal" + // on the second process — making the proxy empty and potentially crashing the + // container before the hires upload completes. + // + // Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ + // worker generate the proxy from the hires master after the recording stops. + // The stop handler sets needsProxy=true so the worker picks it up. + const proxyKey = null; + + const startedAt = new Date().toISOString(); + + this._sessionIdForBridge = sessionId; + const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({ + sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey, + }); + + // Audio input index: the deltacast shared bridge delivers video on input 0 + // (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'. + // DeckLink SDI and network sources carry audio inside input 0. + const audioMap = (sourceType === 'deltacast') ? '1:a:0?' : '0:a:0?'; + + // Non-growing master: ffmpeg muxes the finalized MOV directly. Growing + // master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via + // the orchestrator), so we don't build ffmpeg codec args here for it. + const hiresCodecArgs = growingPath ? null : buildEncodeArgs({ + codec: videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, + isNetwork, + isProxy: false, + }); + + if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); + + const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced); + const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : []; + + // Master output destination (NON-growing path only). + // + // - 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`). + // + // - 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; + // Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout. + 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 + // ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS]. + let sdiHlsDir = null; + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { + const fsMod = await import('node:fs'); + sdiHlsDir = '/live/' + this._assetIdForHls; + try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {} + } + + let hiresProcess; + if (growingPath) { + // ── GROWING master: raw2bmx orchestrator ────────────────────────── + // One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs + + // the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs. + // Spawned via bash so the FIFO priming / EOF / stop-forwarding logic (see + // _buildGrowingOrchestrator) runs as one supervised unit. detached:true so + // it leads its own process group and we can clean-stop the whole pipeline. + const orchArgs = this._buildGrowingOrchestrator({ + inputArgs, + videoBitrate, + // Recorder raster for the raw2bmx essence flag. recorders.js sets + // RECORDING_RESOLUTION (e.g. '1920x1080' / '1080i' / 'native'); when + // 'native'/absent, deriveGrowingRaster defaults to 1080i59.94. + resolution: process.env.RECORDING_RESOLUTION || null, + framerate, + audioChannels, + outPath: growingPath, + hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null, + videoCodec, + audioMap, + interlaced: isInterlacedSource, + }); + console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length); + hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true }); + } else { + // ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ── + let hiresArgs; + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { + const filterStr = isInterlacedSource + ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' + : '[0:v]split=2[vhi][vlo]'; + hiresArgs = [ + ...inputArgs, + '-filter_complex', filterStr, + // Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop) + '-map', '[vhi]', '-map', audioMap, + ...hiresCodecArgs, + hiresOutput, + // Output 1 — low-latency H.264 HLS preview for the UI monitor. + // GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar, + // otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS + // segment so segments start on keyframes (avoids black/flashing). + '-map', '[vlo]', '-map', audioMap, + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts', + sdiHlsDir + '/index.m3u8', + ]; + console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir); + } else { + hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ]; + } + hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio }); + } + + // 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). + const processes = { hires: hiresProcess }; + const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null }; + + // ── HLS tee for network sources (live preview in the UI) ────────── + let hlsProcess = null; + let hlsDir = null; + if (isNetwork && this._assetIdForHls) { + try { + const fs = await import('node:fs'); + hlsDir = '/live/' + this._assetIdForHls; + fs.mkdirSync(hlsDir, { recursive: true }); + const hlsArgs = [ + ...inputArgs, + '-map', '0:v:0?', '-map', '0:a:0?', + // GPU-gated preview encode, same as the SDI 2nd-output path (#164). + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', hlsDir + '/seg-%05d.ts', + hlsDir + '/index.m3u8', + ]; + hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); }); + hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c)); + processes.hls = hlsProcess; + console.log('[HLS] tee started -> ' + hlsDir); + } catch (err) { + console.error('[HLS] tee failed:', err.message); + } + } + + hiresProcess.stderr.on('data', (data) => { + const text = data.toString(); + console.error(`[HIRES] ${text}`); + const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/); + if (m) { + this.state.framesReceived = parseInt(m[1], 10); + this.state.lastFrameAt = new Date().toISOString(); + if (this.state.recordingStartedAt) { + const elapsedSec = (Date.now() - this.state.recordingStartedAt) / 1000; + if (elapsedSec > 0) { + this.state.currentFps = Math.round((this.state.framesReceived / elapsedSec) * 100) / 100; + } + } + } + if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) { + this.state.lastError = text.trim().slice(0, 240); + } + }); + + // Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP). + // DeckLink hardware does not support two concurrent readers on the same port. + + this.state.recording = true; + this.state.sessionId = sessionId; + this.state.processes = processes; + this.state.framesReceived = 0; + this.state.currentFps = 0; + this.state.lastFrameAt = null; + this.state.lastError = null; + this.state.recordingStartedAt = Date.now(); + this.state.currentSession = { + sessionId, + projectId, + binId, + clipName, + device, + sourceType, + sourceUrl, + assetId, + hiresKey, + proxyKey, + growingPath, + localMasterPath, + audioFifo, + startedAt, + duration: 0, + uploads, + codecs: { + videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, container, + proxyEnabled, proxyVideoCodec, proxyVideoBitrate, + proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer, + }, + }; + + // Fire-and-forget: grab the first frame for the live poster thumbnail. + // Only for sources that produce an HLS dir (sdi/deltacast); never blocks start(). + if (sdiHlsDir && assetId) { + this._publishLiveThumbnail({ assetId, hlsDir: sdiHlsDir }).catch(() => {}); + } + + return this._formatSessionResponse(); + } + + async startIdlePreview() { + if (this._previewProc) return; // already running + const sourceType = process.env.SOURCE_TYPE; + const recorderId = process.env.RECORDER_ID; + if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return; + + const previewDir = `/live/preview-${recorderId}`; + try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {} + + let inputArgs; + if (sourceType === 'deltacast') { + const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; + const fps = process.env.DELTACAST_FRAMERATE || '60000/1001'; + let cfg = {}; + try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {} + const port = cfg.port ?? 0; + const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`; + inputArgs = ['-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size, '-r', fps, '-i', videoFifo]; + } else { + // SDI (blackmagic): not yet implemented — skip + return; + } + + const outputArgs = [ + '-an', + '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', + '-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k', + '-g', '30', '-sc_threshold', '0', + '-hls_time', '1', '-hls_list_size', '4', + '-hls_flags', 'delete_segments+omit_endlist+independent_segments', + '-hls_segment_type', 'mpegts', + '-hls_segment_filename', previewDir + '/seg-%05d.ts', + '-f', 'hls', previewDir + '/index.m3u8', + ]; + + console.log('[preview] starting idle preview for', recorderId); + this._previewProc = spawn('ffmpeg', [...inputArgs, ...outputArgs], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + this._previewProc.stderr.on('data', () => { /* swallow */ }); + this._previewProc.on('exit', (code) => { + console.log('[preview] idle preview exited', code); + this._previewProc = null; + }); + this._previewProc.on('error', (e) => { + console.error('[preview] idle preview error:', e.message); + this._previewProc = null; + }); + } + + stopIdlePreview() { + if (!this._previewProc) return; + try { this._previewProc.kill('SIGTERM'); } catch (_) {} + this._previewProc = null; + } + + async stop(sessionId) { + if (!this.state.recording || this.state.sessionId !== sessionId) { + throw new Error('No active capture session or session ID mismatch'); + } + + this.stopIdlePreview(); + + const { processes, currentSession } = this.state; + + const isGrowing = !!currentSession.growingPath; + + // Send SIGINT and WAIT for the master writer to exit cleanly. + // - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with + // full sample tables). Uploading before finalize → "moov atom not found". + // - Growing: `processes.hires` is the bash ORCHESTRATOR (detached group + // leader). SIGINT hits its trap, which forwards SIGINT to ffmpeg; ffmpeg + // stops → raw2bmx gets EOF → raw2bmx writes the OP1a FOOTER and exits; + // only then does the orchestrator exit. Awaiting it guarantees the + // finalized, valid MXF is on the share before the promotion worker + // uploads it. raw2bmx footer finalize of a long recording can take longer + // than a MOV trailer flush, so the growing safety-net is more generous. + const finalizeTimeoutMs = isGrowing ? 60000 : 15000; + 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 the writer refuses to exit. + setTimeout(() => { + try { + // Detached orchestrator → kill the whole process group (ffmpeg + + // raw2bmx + bash); otherwise just the process. + if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} } + proc.kill('SIGKILL'); + } catch (_) {} + finish(); + }, finalizeTimeoutMs); + }); + + if (processes.hires) processes.hires.kill('SIGINT'); + if (processes.proxy) processes.proxy.kill('SIGINT'); + if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } + /* processes.bridge: removed — bridge is managed by node-agent, not per-session */ + + // 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 = []; + + // 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) { + console.error('Error during upload completion:', error); + } + + if (currentSession.audioFifo) { + try { unlinkSync(currentSession.audioFifo); } catch (_) {} + } + + const stoppedAt = new Date().toISOString(); + const startTime = new Date(currentSession.startedAt); + const stopTime = new Date(stoppedAt); + const duration = Math.round((stopTime - startTime) / 1000); + + this.state.recording = false; + this.state.sessionId = null; + this.state.processes = {}; + this.state.recordingStartedAt = null; + + // No frames received → the upload (if any) produced a 0-byte object. + // Surface that so the shutdown handler can mark the asset as 'error' + // instead of posting a broken hi-res key downstream. + const framesReceived = this.state.framesReceived; + + return { + sessionId, + assetId: currentSession.assetId, + projectId: currentSession.projectId, + binId: currentSession.binId, + clipName: currentSession.clipName, + sourceType: currentSession.sourceType, + hiresKey: currentSession.hiresKey, + proxyKey: currentSession.proxyKey, + growingPath: currentSession.growingPath || null, + startedAt: currentSession.startedAt, + stoppedAt, + duration, + framesReceived, + empty: framesReceived === 0, + }; + } + + // Grab the first video frame from the live HLS output and publish it as the + // asset's poster thumbnail, so the library shows a real frame instead of the + // "connecting…" placeholder while recording is still in progress. + // + // Runs entirely on the sidecar (where the HLS segments physically exist): + // 1. poll /live/ for the first seg-*.ts (bridge/ffmpeg warm-up) + // 2. ffmpeg -i -frames:v 1 -> scaled JPEG + // 3. upload JPEG to S3 at thumbnails/.jpg (matches mam-api convention) + // 4. POST /assets//live-thumbnail so the row gets thumbnail_s3_key + // + // Best-effort and non-blocking: any failure is logged and swallowed — the + // post-stop thumbnail job still produces the final thumbnail regardless. + async _publishLiveThumbnail({ assetId, hlsDir }) { + if (!assetId || !hlsDir) return; + const mamUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; + const tmpJpg = `/tmp/livethumb-${assetId}.jpg`; + const thumbKey = `thumbnails/${assetId}.jpg`; + + try { + // 1. Wait up to 30s for the first HLS segment to appear. + const deadline = Date.now() + 30_000; + let segment = null; + while (Date.now() < deadline && this.state.recording && this.state.currentSession.assetId === assetId) { + try { + const entries = await fs.promises.readdir(hlsDir); + const segs = entries.filter(f => /^seg-\d+\.ts$/.test(f)).sort(); + if (segs.length > 0) { segment = `${hlsDir}/${segs[0]}`; break; } + } catch (_) { /* dir not created yet */ } + await new Promise(r => setTimeout(r, 500)); + } + if (!segment) { console.warn(`[livethumb] no segment for ${assetId} within 30s`); return; } + + // 2. Extract the first frame, scaled to 640px wide (yuvj420p for broad JPEG + // decoder compatibility), as a single still. + await new Promise((resolve, reject) => { + const ff = spawn('ffmpeg', [ + '-y', '-i', segment, + '-frames:v', '1', + '-vf', 'scale=640:-2', + '-pix_fmt', 'yuvj420p', + tmpJpg, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + ff.stderr.on('data', d => { err += d.toString(); }); + ff.on('error', reject); + ff.on('exit', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err.slice(-200)}`))); + }); + + // 3. Upload to S3. + const size = statSync(tmpJpg).size; + if (size <= 0) throw new Error('extracted thumbnail is 0 bytes'); + await createUploadStream(S3_BUCKET, thumbKey, createReadStream(tmpJpg)); + + // 4. Tell mam-api the key (only sticks while the asset is still 'live'). + const resp = await fetch(`${mamUrl}/api/v1/assets/${assetId}/live-thumbnail`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(process.env.MAM_API_TOKEN ? { Authorization: `Bearer ${process.env.MAM_API_TOKEN}` } : {}), + }, + body: JSON.stringify({ thumbnailKey: thumbKey }), + }); + if (!resp.ok) throw new Error(`mam-api ${resp.status}: ${(await resp.text()).slice(0, 200)}`); + console.log(`[livethumb] published poster for ${assetId} (${thumbKey})`); + } catch (err) { + console.warn(`[livethumb] failed for ${assetId}:`, err.message); + } finally { + try { unlinkSync(tmpJpg); } catch (_) {} + } + } + + getStatus() { + if (!this.state.recording) return { recording: false }; + + const startTime = new Date(this.state.currentSession.startedAt); + const now = new Date(); + const duration = Math.round((now - startTime) / 1000); + + const lastFrameAt = this.state.lastFrameAt; + const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null; + let signal = 'connecting'; + if (this.state.framesReceived > 0) { + signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost'; + } else if (this.state.lastError) { + signal = 'error'; + } + return { + recording: true, + sessionId: this.state.sessionId, + sourceType: this.state.currentSession.sourceType, + device: this.state.currentSession.device, + clipName: this.state.currentSession.clipName, + projectId: this.state.currentSession.projectId, + binId: this.state.currentSession.binId, + duration, + startedAt: this.state.currentSession.startedAt, + signal, + framesReceived: this.state.framesReceived, + currentFps: this.state.currentFps, + lastFrameAt, + msSinceFrame, + lastError: this.state.lastError, + codecs: this.state.currentSession.codecs, + }; + } + + _formatSessionResponse() { + const { currentSession, sessionId } = this.state; + return { + sessionId, + projectId: currentSession.projectId, + binId: currentSession.binId, + clipName: currentSession.clipName, + device: currentSession.device, + sourceType: currentSession.sourceType, + hiresKey: currentSession.hiresKey, + proxyKey: currentSession.proxyKey, + startedAt: currentSession.startedAt, + codecs: currentSession.codecs, + }; + } +} + +export default new CaptureManager(); +export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT, sourceBackends }; diff --git a/services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx b/services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx new file mode 100644 index 0000000000000000000000000000000000000000..80d4917476e59c53207e97f00071ef4c8caf76ac GIT binary patch literal 34849 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 literal 0 HcmV?d00001 diff --git a/services/premiere-plugin-uxp/manifest.json b/services/premiere-plugin-uxp/manifest.json index 2a3d5ff..e8931da 100644 --- a/services/premiere-plugin-uxp/manifest.json +++ b/services/premiere-plugin-uxp/manifest.json @@ -2,7 +2,7 @@ "manifestVersion": 5, "id": "net.wilddragon.dragonflight.uxp", "name": "Dragonflight MAM", - "version": "2.2.2", + "version": "2.2.3", "main": "index.html", "host": { "app": "premierepro", diff --git a/services/premiere-plugin-uxp/src/import-flow.js b/services/premiere-plugin-uxp/src/import-flow.js index adfdc71..86fa151 100644 --- a/services/premiere-plugin-uxp/src/import-flow.js +++ b/services/premiere-plugin-uxp/src/import-flow.js @@ -1,14 +1,29 @@ -// import-flow.js — v2.1.6 +// import-flow.js — v2.2.3 // premierepro API: docs say sync, runtime returns Promises. Await everything. (function () { const Import = {}; const fs = require('fs'); + const fsPromises = fs.promises || {}; // window.path is a UXP global (v6.4+) — no require('path') let os; try { os = require('os'); } catch (_) { os = {}; } let uxpFs; try { uxpFs = require('uxp').storage.localFileSystem; } catch (_) { uxpFs = null; } + // UXP fs is callback-based — wrap in Promise where promisify unavailable. + function _writeFile(path, data) { + if (fsPromises.writeFile) return fsPromises.writeFile(path, data); + return new Promise((resolve, reject) => { fs.writeFile(path, data, (err) => { if (err) reject(err); else resolve(); }); }); + } + function _readFile(path) { + if (fsPromises.readFile) return fsPromises.readFile(path); + return new Promise((resolve, reject) => { fs.readFile(path, (err, data) => { if (err) reject(err); else resolve(data); }); }); + } + function _stat(path) { + if (fsPromises.stat) return fsPromises.stat(path); + return new Promise((resolve, reject) => { fs.stat(path, (err, stats) => { if (err) reject(err); else resolve(stats); }); }); + } + // ── Temp folder ────────────────────────────────────────────────── async function _getTempBase() { if (uxpFs && uxpFs.getTemporaryFolder) { @@ -39,7 +54,7 @@ // Returns true if the path already exists on disk. Import._fileExists = async function (filePath) { - try { await fs.stat(filePath); return true; } catch (_) { return false; } + try { await _stat(filePath); return true; } catch (_) { return false; } }; // Write ArrayBuffer to disk via fs.writeFile. @@ -47,7 +62,7 @@ // previous import) we treat that as success: the bytes are already there. Import._writeBuffer = async function (destPath, arrayBuffer) { try { - await fs.writeFile(destPath, arrayBuffer); + await _writeFile(destPath, arrayBuffer); } catch (e) { const busy = e.code === 'EBUSY' || /resource busy/i.test(String(e.message)); if (!busy) throw e; @@ -179,7 +194,7 @@ const filename = meta.filename || path.basename(nativePath); const contentType = _contentType(filename); - const buf = await fs.readFile(nativePath); + const buf = await _readFile(nativePath); const size = buf.byteLength != null ? buf.byteLength : buf.length; if (size <= SIMPLE_MAX) {