import { spawn, execFileSync } from 'child_process'; import { mkdirSync, writeFileSync } 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', }, }; 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', }; function buildEncodeArgs({ codec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, container, isNetwork, isProxy = 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); 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)); if (fmt === 'mov' || fmt === 'mp4') { args.push('-movflags', '+frag_keyframe+empty_moov'); } 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, device, 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 VideoMaster SDK FFmpeg plugin. // FFmpeg input format is 'deltacast', device address is 'deltacast://'. // When the physical device is absent (/dev/deltacast missing), fall back // to a lavfi test card so development and integration testing work without hardware. if (sourceType === 'deltacast') { const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) ? parseInt(device, 10) : 0; const { existsSync } = await import('node:fs'); const deviceNode = `/dev/deltacast${idx}`; if (existsSync(deviceNode)) { console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`); return { inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`], isNetwork: false, }; } else { // No hardware — lavfi test card with port label + timecode burn-in. // Matches the deltacast-sdi-recorder standalone app fallback exactly so // recorded files look right in the MAM library during dev. console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`); const testSrc = [ `testsrc2=size=1920x1080:rate=30`, `drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`, `drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`, ].join(','); return { inputArgs: [ '-f', 'lavfi', '-i', testSrc, '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000', '-map', '0:v:0', '-map', '1:a:0', ], isNetwork: false, }; } } // Default: SDI via DeckLink // 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+. 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, }; } /** * 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, sourceType = 'sdi', 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 hiresExt = CONTAINER_EXT[container] || 'mov'; const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4'; const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`; // Growing-files: write master to the local 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 } const growingPath = growingActive ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}` : null; 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(); const { inputArgs, isNetwork } = await this._buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey, }); const hiresCodecArgs = buildEncodeArgs({ codec: videoCodec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, container, isNetwork, isProxy: false, }); 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']; // 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; let hiresArgs; if (sourceType === 'sdi' && this._assetIdForHls) { const fsMod = await import('node:fs'); sdiHlsDir = '/live/' + this._assetIdForHls; try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {} hiresArgs = [ ...inputArgs, '-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]', // Output 0 — ProRes master (S3 pipe or growing file) '-map', '[vhi]', '-map', '0:a:0?', ...hiresCodecArgs, hiresOutput, // Output 1 — low-latency H.264 HLS preview for the UI monitor '-map', '[vlo]', '-map', '0:a:0?', '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0', '-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 ]; } const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio }); const hiresUpload = growingPath ? Promise.resolve({ growingPath }) : createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout); const processes = { hires: hiresProcess }; const uploads = { hires: hiresUpload }; // ── 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?', '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0', '-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.currentFps = parseFloat(m[2]); this.state.lastFrameAt = new Date().toISOString(); } 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.currentSession = { sessionId, projectId, binId, clipName, device, sourceType, sourceUrl, hiresKey, proxyKey, growingPath, startedAt, duration: 0, uploads, codecs: { videoCodec, videoBitrate, framerate, audioCodec, audioBitrate, audioChannels, container, proxyEnabled, proxyVideoCodec, proxyVideoBitrate, proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer, }, }; return this._formatSessionResponse(); } async stop(sessionId) { if (!this.state.recording || this.state.sessionId !== sessionId) { throw new Error('No active capture session or session ID mismatch'); } const { processes, currentSession } = this.state; if (processes.hires) processes.hires.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } // 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]; if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy); await Promise.all(uploadPromises); } catch (error) { console.error('Error during upload completion:', error); } 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 = {}; // 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, 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, }; } 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 };