feat(recorders): always-on standby sidecars for deltacast, sdi, blackmagic

Sidecars now spawn at recorder CREATE time instead of /start time.
The container boots in STANDBY=1 mode (idle preview only, no ffmpeg master).
On /start, mam-api sends per-session params (CLIP_NAME, ASSET_ID, PROJECT_ID)
to the running sidecar via HTTP POST /capture/start — ffmpeg starts in <1s.
On /stop, mam-api calls HTTP POST /capture/stop — container stays alive in
standby, ready for the next take immediately.
Container is only killed on recorder DELETE.

This eliminates: Docker create/start overhead (~1-2s), bridge startup (~2-5s),
and pre-roll wait (~5s). Latency from 'record' click to first encoded frame
drops from ~10s to ~1s.

Changes:
- capture/src/index.js: boot in standby when STANDBY=1 env is set; still
  start idle preview (live thumbnail visible before recording)
- capture/src/routes/capture.js: POST /start accepts full codec params and
  asset_id in body (skips mam-api asset creation when asset_id provided)
- node-agent/index.js: handleSidecarStandby() + POST /sidecar/standby route;
  warms bridge at recorder create time
- recorders.js POST /: spawn standby sidecar after DB insert (non-fatal)
- recorders.js POST /:id/start: HTTP fast-path to standby sidecar; falls
  back to on-demand spawn if standby not available
- recorders.js POST /:id/stop: HTTP /capture/stop, keep container in standby
- recorders.js GET /:id/status: use port-based URL for local capture status
This commit is contained in:
Wild Dragon Dev 2026-06-03 21:59:33 +00:00
parent 7172447644
commit ef57900583
4 changed files with 467 additions and 43 deletions

View file

@ -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);
}
}
});

View file

@ -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);

View file

@ -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 */ }
}

View file

@ -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))