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:
parent
7172447644
commit
ef57900583
4 changed files with 467 additions and 43 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue