diff --git a/services/capture/src/index.js b/services/capture/src/index.js index 2d8e561..ddbc403 100644 --- a/services/capture/src/index.js +++ b/services/capture/src/index.js @@ -22,12 +22,25 @@ app.use('/capture', captureRoutes); const server = app.listen(PORT, () => { console.log(`Wild Dragon Capture Service listening on port ${PORT}`); - bootstrapAutoStart(); - // Auto-start idle signal preview for deltacast/sdi sidecars. - // 3s delay lets the deltacast bridge FIFOs come up first. const _srcType = process.env.SOURCE_TYPE; - if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) { - setTimeout(() => captureManager.startIdlePreview(), 3000); + const _standby = process.env.STANDBY === '1'; + + if (_standby) { + // Standby mode — sidecar pre-spawned at recorder create time. + // Don't auto-start a recording session; wait for POST /capture/start. + // Still start idle preview so the live signal thumbnail is visible. + console.log('[bootstrap] standby mode — waiting for /capture/start HTTP call'); + if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi' || _srcType === 'blackmagic')) { + setTimeout(() => captureManager.startIdlePreview(), 3000); + } + } else { + // Legacy mode — env vars carry the session params, start immediately. + bootstrapAutoStart(); + // Auto-start idle signal preview for deltacast/sdi sidecars. + // 3s delay lets the deltacast bridge FIFOs come up first. + if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) { + setTimeout(() => captureManager.startIdlePreview(), 3000); + } } }); diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 3eb73f4..f9444aa 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -301,12 +301,34 @@ router.post('/start', async (req, res) => { project_id, bin_id, clip_name, + asset_id, // pre-created by mam-api in standby mode; skip asset creation when set device, source_type = 'sdi', source_url, listen = false, listen_port, stream_key, + // Codec params — accepted from body (standby mode) or fall back to container env vars + recording_codec, + recording_video_bitrate, + recording_framerate, + recording_audio_codec, + recording_audio_bitrate, + recording_audio_channels, + recording_container, + proxy_enabled, + proxy_codec, + proxy_video_bitrate, + proxy_framerate, + proxy_audio_codec, + proxy_audio_bitrate, + proxy_audio_channels, + proxy_container, + growing_enabled, + growing_smb_mount, + growing_smb_username, + growing_smb_password, + growing_smb_vers, } = req.body; if (!project_id || !clip_name) { @@ -316,9 +338,9 @@ router.post('/start', async (req, res) => { } // Source-specific validation - if (source_type === 'sdi') { + if (source_type === 'sdi' || source_type === 'blackmagic') { if (device === undefined || device === null) { - return res.status(400).json({ error: 'SDI source requires: device' }); + return res.status(400).json({ error: 'SDI/blackmagic source requires: device' }); } } else if (source_type === 'srt' || source_type === 'rtmp') { if (!listen && !source_url) { @@ -332,48 +354,93 @@ router.post('/start', async (req, res) => { } } else { return res.status(400).json({ - error: `Unknown source_type: ${source_type}. Must be sdi, srt, rtmp, or deltacast`, + error: `Unknown source_type: ${source_type}. Must be sdi, blackmagic, srt, rtmp, or deltacast`, }); } - // Create live asset in MAM API before starting capture - let assetId; - try { - const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) }, - body: JSON.stringify({ - projectId: project_id, - binId: bin_id, - clipName: clip_name, - sourceType: source_type, - status: 'live', - }), - }); - - if (!mamResponse.ok) { - const errText = await mamResponse.text(); - throw new Error(`MAM API returned ${mamResponse.status}: ${errText}`); + // If asset_id provided (standby mode — mam-api already created it), skip creation. + // Otherwise create the live asset here (legacy on-demand path). + let assetId = asset_id || null; + if (!assetId) { + try { + const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) }, + body: JSON.stringify({ + projectId: project_id, + binId: bin_id, + clipName: clip_name, + sourceType: source_type, + status: 'live', + }), + }); + if (!mamResponse.ok) { + const errText = await mamResponse.text(); + throw new Error(`MAM API returned ${mamResponse.status}: ${errText}`); + } + const asset = await mamResponse.json(); + assetId = asset.id; + } catch (mamError) { + console.error('Failed to create live asset:', mamError.message); + return res.status(500).json({ error: `Could not create live asset: ${mamError.message}` }); } - - const asset = await mamResponse.json(); - assetId = asset.id; - } catch (mamError) { - console.error('Failed to create live asset:', mamError.message); - return res.status(500).json({ error: `Could not create live asset: ${mamError.message}` }); } + // Helper: body value wins over container env var fallback + function bodyOr(bodyVal, envName) { + if (bodyVal !== undefined && bodyVal !== null && bodyVal !== '') return bodyVal; + const v = process.env[envName]; + return (v === undefined || v === '') ? undefined : v; + } + function bodyOrInt(bodyVal, envName) { + const v = bodyOr(bodyVal, envName); + if (v === undefined) return undefined; + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : undefined; + } + function bodyOrBool(bodyVal, envName) { + if (bodyVal !== undefined && bodyVal !== null) return Boolean(bodyVal); + const v = process.env[envName]; + if (v === undefined) return undefined; + return v === 'true' || v === '1' || v === 'yes'; + } + + // Inject body-supplied codec/session params into the process env so + // captureManager.start() picks them up via the existing env-read paths. + // This lets the standby container receive per-session params via HTTP. + if (growing_enabled !== undefined) process.env.GROWING_ENABLED = growing_enabled ? 'true' : 'false'; + if (growing_smb_mount) process.env.GROWING_SMB_MOUNT = growing_smb_mount; + if (growing_smb_username) process.env.GROWING_SMB_USERNAME = growing_smb_username; + if (growing_smb_password) process.env.GROWING_SMB_PASSWORD = growing_smb_password; + if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers; + const session = await captureManager.start({ projectId: project_id, binId: bin_id || null, clipName: clip_name, - device, + device: device !== undefined ? device : parseInt(process.env.DEVICE_INDEX || '0', 10), sourceType: source_type, sourceUrl: source_url, listen, listenPort: listen_port, streamKey: stream_key, assetId, + // Codec params: body wins, env falls back + videoCodec: bodyOr(recording_codec, 'RECORDING_CODEC') || 'prores_hq', + videoBitrate: bodyOr(recording_video_bitrate, 'RECORDING_VIDEO_BITRATE'), + framerate: bodyOr(recording_framerate, 'RECORDING_FRAMERATE'), + audioCodec: bodyOr(recording_audio_codec, 'RECORDING_AUDIO_CODEC') || 'pcm_s24le', + audioBitrate: bodyOr(recording_audio_bitrate, 'RECORDING_AUDIO_BITRATE'), + audioChannels: bodyOrInt(recording_audio_channels, 'RECORDING_AUDIO_CHANNELS') ?? 2, + container: bodyOr(recording_container, 'RECORDING_CONTAINER') || 'mov', + proxyEnabled: bodyOrBool(proxy_enabled, 'PROXY_ENABLED') ?? true, + proxyVideoCodec: bodyOr(proxy_codec, 'PROXY_CODEC') || 'h264', + proxyVideoBitrate: bodyOr(proxy_video_bitrate, 'PROXY_VIDEO_BITRATE') || '8M', + proxyFramerate: bodyOr(proxy_framerate, 'PROXY_FRAMERATE'), + proxyAudioCodec: bodyOr(proxy_audio_codec, 'PROXY_AUDIO_CODEC') || 'aac', + proxyAudioBitrate: bodyOr(proxy_audio_bitrate, 'PROXY_AUDIO_BITRATE') || '192k', + proxyAudioChannels: bodyOrInt(proxy_audio_channels, 'PROXY_AUDIO_CHANNELS') ?? 2, + proxyContainer: bodyOr(proxy_container, 'PROXY_CONTAINER') || 'mp4', }); res.json(session); diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 771cb67..592bd7a 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -227,6 +227,58 @@ async function nodeHasGpuCapability(nodeId) { const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +// Build the stable env array for a standby sidecar. Contains everything a +// capture container needs EXCEPT session params (CLIP_NAME, ASSET_ID, +// PROJECT_ID, BIN_ID) which arrive via HTTP on /capture/start. +function buildStandbyEnv(recorder) { + const s3Endpoint = process.env.S3_ENDPOINT || ''; + const s3Bucket = getS3Bucket(); + const s3AccessKey = process.env.S3_ACCESS_KEY || ''; + const s3SecretKey = process.env.S3_SECRET_KEY || ''; + const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; + const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`; + const liveDir = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live'; + const sourceConfig = recorder.source_config || {}; + const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0; + + return [ + `S3_ENDPOINT=${s3Endpoint}`, + `S3_BUCKET=${s3Bucket}`, + `S3_ACCESS_KEY=${s3AccessKey}`, + `S3_SECRET_KEY=${s3SecretKey}`, + `S3_REGION=${process.env.S3_REGION || 'us-east-1'}`, + // Use external URL — capture container runs on worker host network + `MAM_API_URL=${externalMamApiUrl}`, + `RECORDER_ID=${recorder.id}`, + `SOURCE_TYPE=${recorder.source_type}`, + `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`, + `DEVICE_INDEX=${deviceIndex}`, + `RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`, + `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`, + `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`, + `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`, + `RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`, + `RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`, + `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`, + `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`, + `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, + `PROXY_CODEC=${recorder.proxy_codec || 'h264'}`, + `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`, + `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`, + `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`, + `PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`, + `PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`, + `PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`, + `PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`, + `GROWING_ENABLED=false`, + `GROWING_PATH=/growing`, + `GROWING_SMB_MOUNT=`, + `LIVE_DIR=${liveDir}`, + `MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`, + `STANDBY=1`, + ]; +} + // Issue #162 — after a local-spawn stop, wait for the capture container to // finalize its master. The asset row was pre-created at start with // status='live' (display_name = current_session_id); the ingest/finalize step @@ -374,7 +426,50 @@ router.post('/', async (req, res, next) => { values ); - res.status(201).json(result.rows[0]); + const recorder = result.rows[0]; + + // Spawn a standby sidecar immediately for SDI/deltacast/blackmagic recorders + // that have an assigned node, so the container + bridge are ready before the + // user hits record. Non-fatal — recorder is still usable if this fails. + const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic']; + if (recorder.node_id && STANDBY_SOURCE_TYPES.includes(recorder.source_type)) { + const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false })); + if (isRemote && targetNodeApiUrl) { + const capturePort = SIDECAR_PORT_BASE + (recorder.device_index || 0); + const useGpu = GPU_CODECS.includes(recorder.recording_codec); + try { + const standbyRes = await fetch(`${targetNodeApiUrl}/sidecar/standby`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: 'wild-dragon-capture:latest', + env: buildStandbyEnv(recorder), + capturePort, + sourceType: recorder.source_type, + useGpu, + gpuUuid: recorder.gpu_uuid || null, + }), + signal: AbortSignal.timeout(15000), + }); + if (standbyRes.ok) { + const { containerId } = await standbyRes.json(); + await pool.query( + `UPDATE recorders SET container_id = $1, status = 'standby', updated_at = NOW() WHERE id = $2`, + [containerId, recorder.id] + ); + recorder.container_id = containerId; + recorder.status = 'standby'; + console.log(`[recorders] standby sidecar spawned for ${recorder.id}: ${containerId}`); + } else { + console.warn(`[recorders] standby spawn returned ${standbyRes.status} for ${recorder.id} — will spawn on start`); + } + } catch (e) { + console.warn(`[recorders] standby spawn failed for ${recorder.id} (non-fatal): ${e.message}`); + } + } + } + + res.status(201).json(recorder); } catch (err) { next(err); } @@ -636,10 +731,71 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { } let containerId; + const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0); - if (isRemote) { + // ── Standby fast-path ─────────────────────────────────────────────── + // If the recorder is already in standby (sidecar running idle), send the + // session params to its /capture/start HTTP endpoint instead of spawning + // a new container. This eliminates Docker create/start latency and bridge + // startup time — the user hits record and ffmpeg starts in <1s. + const isStandby = recorder.status === 'standby' && recorder.container_id; + if (isStandby) { + const captureStartUrl = isRemote + ? `http://${(await resolveNodeTarget(recorder.node_id)).ip}:${capturePort}/capture/start` + : `http://localhost:${capturePort}/capture/start`; + try { + const startBody = { + project_id: takeProjectId, + bin_id: null, + clip_name: clipName, + asset_id: assetIdLive, + source_type: sourceType, + device: deviceIndex, + // Codec params — sidecar already has these in env but we send them + // anyway so a config change on the recorder takes effect immediately. + recording_codec: recorder.recording_codec, + recording_video_bitrate: recorder.recording_video_bitrate, + recording_framerate: recorder.recording_framerate, + recording_audio_codec: recorder.recording_audio_codec, + recording_audio_bitrate: recorder.recording_audio_bitrate, + recording_audio_channels: recorder.recording_audio_channels, + recording_container: recorder.recording_container, + proxy_enabled: recorder.proxy_enabled, + proxy_codec: recorder.proxy_codec, + proxy_video_bitrate: recorder.proxy_video_bitrate, + proxy_framerate: recorder.proxy_framerate, + proxy_audio_codec: recorder.proxy_audio_codec, + proxy_audio_bitrate: recorder.proxy_audio_bitrate, + proxy_audio_channels: recorder.proxy_audio_channels, + proxy_container: recorder.proxy_container, + growing_enabled: growingEnabled, + growing_smb_mount: smbMount, + growing_smb_username: growingInfra.growing_smb_username || '', + growing_smb_password: growingInfra.growing_smb_password || '', + growing_smb_vers: growingInfra.growing_smb_vers || '3.0', + }; + const captureRes = await fetch(captureStartUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(startBody), + signal: AbortSignal.timeout(15000), + }); + if (captureRes.ok) { + containerId = recorder.container_id; + console.log(`[recorders] standby→recording: ${id} via HTTP /capture/start`); + } else { + const detail = await captureRes.json().catch(() => ({})); + console.warn(`[recorders] standby /capture/start returned ${captureRes.status}: ${JSON.stringify(detail)} — falling back to spawn`); + // Fall through to on-demand spawn below + } + } catch (e) { + console.warn(`[recorders] standby HTTP start failed: ${e.message} — falling back to spawn`); + // Fall through to on-demand spawn below + } + } + + if (!containerId && isRemote) { // Remote node: delegate container lifecycle to that node's agent. - const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0); const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -658,7 +814,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { } const sidecarData = await sidecarRes.json(); containerId = sidecarData.containerId; - } else { + } else if (!containerId) { // Local spawn via Docker socket. const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig); const alias = `recorder-${id}`; @@ -778,8 +934,70 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => { return res.json(result.rows[0]); } - const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id); + const { remote: isRemote, apiUrl: targetNodeApiUrl, ip: nodeIp } = await resolveNodeTarget(recorder.node_id); + const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0); + const capturePort = SIDECAR_PORT_BASE + deviceIndex; + const isStandby = recorder.status === 'standby'; + // ── Standby sidecar stop path ───────────────────────────────────────── + // If the recorder was in standby (container stays alive between sessions), + // stop only the capture session via HTTP — don't kill the container. + // The container returns to idle-preview mode and is ready for the next + // /start call immediately. + // + // If NOT in standby (legacy on-demand spawn), use the old docker-stop path. + const STANDBY_SOURCE_TYPES = ['deltacast', 'sdi', 'blackmagic']; + const isStandbySource = STANDBY_SOURCE_TYPES.includes(recorder.source_type); + + if (isStandbySource && recorder.container_id) { + // Call /capture/stop on the running sidecar. + // Return immediately — S3 upload streams to completion asynchronously. + const captureStopUrl = isRemote + ? `http://${nodeIp}:${capturePort}/capture/stop` + : `http://localhost:${capturePort}/capture/stop`; + + // Get session_id from the sidecar's status (it tracks its own sessionId). + let sessionId = null; + try { + const statusRes = await fetch( + isRemote ? `http://${nodeIp}:${capturePort}/capture/status` : `http://localhost:${capturePort}/capture/status`, + { signal: AbortSignal.timeout(3000) } + ); + if (statusRes.ok) { + const s = await statusRes.json(); + sessionId = s.sessionId || null; + } + } catch (_) {} + + if (sessionId) { + // Fire-and-forget — the S3 upload completes in the background inside + // the sidecar. The /capture/stop route calls /assets/:id/finalize when + // done, so the asset transitions from 'live' → 'processing' automatically. + fetch(captureStopUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + signal: AbortSignal.timeout(185000), + }).then(r => { + if (!r.ok) console.warn(`[recorders] /capture/stop returned ${r.status} for ${id}`); + else console.log(`[recorders] standby stop completed for ${id}`); + }).catch(e => { + console.error(`[recorders] /capture/stop error for ${id}: ${e.message}`); + }); + } else { + console.warn(`[recorders] could not get sessionId for ${id} — sidecar may not be recording`); + } + + // Container stays alive in standby — keep container_id, set status='standby' + const updateResult = await pool.query( + `UPDATE recorders SET status = 'standby', current_session_id = NULL, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id] + ); + return res.json(updateResult.rows[0]); + } + + // ── Legacy path: on-demand container, kill it on stop ──────────────── if (isRemote) { const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, { method: 'DELETE', @@ -790,9 +1008,7 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => { } } else { // Issue #162 — stop local container in the background so the HTTP stop - // request returns immediately. The container teardown (SIGTERM -> ffmpeg - // exit -> S3 upload -> post-stop callback) takes up to 180s for large files, - // which would otherwise timeout the browser/API connection. + // request returns immediately. const containerId = recorder.container_id; (async () => { try { @@ -803,7 +1019,6 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => { } } catch (e) { console.error('[recorders] failed local background stop:', e.message); - // Attempt finalize and cleanup even if stop call timed out await waitForFinalize(recorder).catch(() => {}); await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {}); } @@ -890,7 +1105,9 @@ router.get('/:id/status', async (req, res, next) => { duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000); try { - const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) }); + const _devIdx2 = recorder.device_index ?? (recorder.source_config?.device ?? 0); + const _statusPort = SIDECAR_PORT_BASE + _devIdx2; + const captureRes = await fetch(`http://localhost:${_statusPort}/capture/status`, { signal: AbortSignal.timeout(2000) }); if (captureRes.ok) live = await captureRes.json(); } catch (_) { /* not ready yet */ } } diff --git a/services/node-agent/index.js b/services/node-agent/index.js index e080827..3d6dac9 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -646,6 +646,128 @@ async function fetchContainerLogs(containerId) { }); } +// ── Standby: pre-spawn a sidecar at recorder create time ───────────────── +// Like handleSidecarStart but sets STANDBY=1 so the capture container boots +// into idle-preview mode instead of starting a recording session immediately. +// The bridge is started here (warms it up for zero-lag on first /start call). +// Per-session params (CLIP_NAME, ASSET_ID, PROJECT_ID) are NOT in the env — +// they arrive via HTTP POST /capture/start when the user hits record. +async function handleSidecarStandby(body, res) { + try { + const { + image = 'wild-dragon-capture:latest', + env = [], + capturePort = 3001, + sourceType = 'sdi', + useGpu = false, + gpuUuid = null, + } = body; + + const binds = [`${LIVE_DIR}:/live`]; + if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm'); + if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic'); + if (sourceType === 'deltacast') { + try { + const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)); + for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`); + } catch (_) {} + } + + const sidecarEnv = [...env, `PORT=${capturePort}`, 'STANDBY=1']; + if (useGpu) { + const visibleDevices = (gpuUuid != null && String(gpuUuid).trim() !== '') + ? String(gpuUuid).trim() : 'all'; + sidecarEnv.push(`NVIDIA_VISIBLE_DEVICES=${visibleDevices}`); + sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility'); + } + sidecarEnv.push(`FC_URL=${FC_URL}`); + + const hostConfig = { NetworkMode: 'host', Privileged: true, Binds: binds }; + if (useGpu) { + hostConfig.Runtime = 'nvidia'; + hostConfig.DeviceRequests = [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }]; + } + + // Warm up the bridge and inject FC_SLOT_ID (same as handleSidecarStart). + if (sourceType === 'deltacast') { + _dcSidecarCount++; + startDeltacastBridge(); + const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14); + let _portNum = NaN; + try { _portNum = JSON.parse(_srcCfg).port; } catch (_) {} + if (!Number.isFinite(_portNum)) _portNum = 0; + const _slotId = `deltacast-${DC_BOARD}-${_portNum}`; + sidecarEnv.push(`FC_SLOT_ID=${_slotId}`); + if (_dcPortFmt.has(_portNum)) { + const _fmt = _dcPortFmt.get(_portNum); + const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num); + sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`); + sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`); + sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`); + } + hostConfig.IpcMode = 'host'; + } + if (sourceType === 'sdi' || sourceType === 'blackmagic') { + _dlSidecarCount++; + const _bmdDevices = []; + try { + const _bmdEntries = fs.readdirSync('/dev/blackmagic').filter(n => /^(dv|io)\d+$/.test(n)); + _bmdEntries.forEach((_, i) => _bmdDevices.push(i)); + } catch (_) { _bmdDevices.push(0); } + startDecklinkBridge(_bmdDevices); + const _srcCfg = (env.find(e => e.startsWith('SOURCE_CONFIG=')) || '').slice(14); + let _devIdx = NaN; + try { _devIdx = JSON.parse(_srcCfg).device ?? JSON.parse(_srcCfg).index; } catch (_) {} + if (!Number.isFinite(_devIdx)) _devIdx = parseInt((env.find(e => e.startsWith('BMD_DEVICE_INDEX=')) || '=0').split('=')[1]) || 0; + const _slotId = `decklink-${FC_NODE_ID}-${_devIdx}`; + sidecarEnv.push(`FC_SLOT_ID=${_slotId}`); + if (_dlDevFmt.has(_devIdx)) { + const _fmt = _dlDevFmt.get(_devIdx); + const _fps = (_fmt.fps_den && _fmt.fps_den !== 1) ? `${_fmt.fps_num}/${_fmt.fps_den}` : String(_fmt.fps_num); + sidecarEnv.push(`DELTACAST_VIDEO_SIZE=${_fmt.width}x${_fmt.height}`); + sidecarEnv.push(`DELTACAST_FRAMERATE=${_fps}`); + sidecarEnv.push(`DELTACAST_INTERLACED=${_fmt.interlaced ? '1' : '0'}`); + } + hostConfig.IpcMode = 'host'; + } + + const _cleanupOnFailure = () => { + if (sourceType === 'deltacast') { + _dcSidecarCount--; + if (_dcSidecarCount <= 0) { _dcSidecarCount = 0; stopDeltacastBridge(); } + } else if (sourceType === 'sdi' || sourceType === 'blackmagic') { + _dlSidecarCount--; + if (_dlSidecarCount <= 0) { _dlSidecarCount = 0; stopDecklinkBridge(); } + } + }; + + let containerId; + try { + const createRes = await dockerApi('POST', '/containers/create', { Image: image, Env: sidecarEnv, HostConfig: hostConfig }); + if (createRes.status !== 201) { + _cleanupOnFailure(); + return jsonResponse(res, 502, { error: 'Failed to create standby container', details: createRes.data }); + } + containerId = createRes.data.Id; + console.log(`[sidecar-standby] ${containerId} image=${image} src=${sourceType}`); + const startRes = await dockerApi('POST', `/containers/${containerId}/start`); + if (startRes.status !== 204) { + await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {}); + _cleanupOnFailure(); + return jsonResponse(res, 502, { error: 'Failed to start standby container', details: startRes.data }); + } + if (sourceType === 'deltacast') _containerSourceType.set(containerId, 'deltacast'); + if (sourceType === 'sdi' || sourceType === 'blackmagic') _containerSourceType.set(containerId, 'blackmagic'); + jsonResponse(res, 201, { containerId, capturePort }); + } catch (err) { + _cleanupOnFailure(); + throw err; + } + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + async function handleSidecarStop(containerId, res) { try { console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`); @@ -1274,6 +1396,11 @@ const server = http.createServer((req, res) => { ip: getIp(), })); + } else if (req.method === 'POST' && pathname === '/sidecar/standby') { + readBody(req) + .then(body => handleSidecarStandby(body, res)) + .catch(() => jsonResponse(res, 400, { error: 'Invalid request body' })); + } else if (req.method === 'POST' && pathname === '/sidecar/start') { readBody(req) .then(body => handleSidecarStart(body, res))