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, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Wild Dragon Capture Service listening on port ${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;
|
const _srcType = process.env.SOURCE_TYPE;
|
||||||
if (process.env.RECORDER_ID && (_srcType === 'deltacast' || _srcType === 'sdi')) {
|
const _standby = process.env.STANDBY === '1';
|
||||||
setTimeout(() => captureManager.startIdlePreview(), 3000);
|
|
||||||
|
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,
|
project_id,
|
||||||
bin_id,
|
bin_id,
|
||||||
clip_name,
|
clip_name,
|
||||||
|
asset_id, // pre-created by mam-api in standby mode; skip asset creation when set
|
||||||
device,
|
device,
|
||||||
source_type = 'sdi',
|
source_type = 'sdi',
|
||||||
source_url,
|
source_url,
|
||||||
listen = false,
|
listen = false,
|
||||||
listen_port,
|
listen_port,
|
||||||
stream_key,
|
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;
|
} = req.body;
|
||||||
|
|
||||||
if (!project_id || !clip_name) {
|
if (!project_id || !clip_name) {
|
||||||
|
|
@ -316,9 +338,9 @@ router.post('/start', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source-specific validation
|
// Source-specific validation
|
||||||
if (source_type === 'sdi') {
|
if (source_type === 'sdi' || source_type === 'blackmagic') {
|
||||||
if (device === undefined || device === null) {
|
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') {
|
} else if (source_type === 'srt' || source_type === 'rtmp') {
|
||||||
if (!listen && !source_url) {
|
if (!listen && !source_url) {
|
||||||
|
|
@ -332,48 +354,93 @@ router.post('/start', async (req, res) => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json({
|
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
|
// If asset_id provided (standby mode — mam-api already created it), skip creation.
|
||||||
let assetId;
|
// Otherwise create the live asset here (legacy on-demand path).
|
||||||
try {
|
let assetId = asset_id || null;
|
||||||
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
if (!assetId) {
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) },
|
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
projectId: project_id,
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { Authorization: `Bearer ${MAM_API_TOKEN}` } : { 'X-Requested-With': 'dragonflight-ui' }) },
|
||||||
binId: bin_id,
|
body: JSON.stringify({
|
||||||
clipName: clip_name,
|
projectId: project_id,
|
||||||
sourceType: source_type,
|
binId: bin_id,
|
||||||
status: 'live',
|
clipName: clip_name,
|
||||||
}),
|
sourceType: source_type,
|
||||||
});
|
status: 'live',
|
||||||
|
}),
|
||||||
if (!mamResponse.ok) {
|
});
|
||||||
const errText = await mamResponse.text();
|
if (!mamResponse.ok) {
|
||||||
throw new Error(`MAM API returned ${mamResponse.status}: ${errText}`);
|
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({
|
const session = await captureManager.start({
|
||||||
projectId: project_id,
|
projectId: project_id,
|
||||||
binId: bin_id || null,
|
binId: bin_id || null,
|
||||||
clipName: clip_name,
|
clipName: clip_name,
|
||||||
device,
|
device: device !== undefined ? device : parseInt(process.env.DEVICE_INDEX || '0', 10),
|
||||||
sourceType: source_type,
|
sourceType: source_type,
|
||||||
sourceUrl: source_url,
|
sourceUrl: source_url,
|
||||||
listen,
|
listen,
|
||||||
listenPort: listen_port,
|
listenPort: listen_port,
|
||||||
streamKey: stream_key,
|
streamKey: stream_key,
|
||||||
assetId,
|
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);
|
res.json(session);
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,58 @@ async function nodeHasGpuCapability(nodeId) {
|
||||||
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
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
|
// 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
|
// finalize its master. The asset row was pre-created at start with
|
||||||
// status='live' (display_name = current_session_id); the ingest/finalize step
|
// status='live' (display_name = current_session_id); the ingest/finalize step
|
||||||
|
|
@ -374,7 +426,50 @@ router.post('/', async (req, res, next) => {
|
||||||
values
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
@ -636,10 +731,71 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let containerId;
|
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.
|
// Remote node: delegate container lifecycle to that node's agent.
|
||||||
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
|
|
||||||
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -658,7 +814,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
const sidecarData = await sidecarRes.json();
|
const sidecarData = await sidecarRes.json();
|
||||||
containerId = sidecarData.containerId;
|
containerId = sidecarData.containerId;
|
||||||
} else {
|
} else if (!containerId) {
|
||||||
// Local spawn via Docker socket.
|
// Local spawn via Docker socket.
|
||||||
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
|
||||||
const alias = `recorder-${id}`;
|
const alias = `recorder-${id}`;
|
||||||
|
|
@ -778,8 +934,70 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
return res.json(result.rows[0]);
|
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) {
|
if (isRemote) {
|
||||||
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -790,9 +1008,7 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Issue #162 — stop local container in the background so the HTTP stop
|
// Issue #162 — stop local container in the background so the HTTP stop
|
||||||
// request returns immediately. The container teardown (SIGTERM -> ffmpeg
|
// request returns immediately.
|
||||||
// exit -> S3 upload -> post-stop callback) takes up to 180s for large files,
|
|
||||||
// which would otherwise timeout the browser/API connection.
|
|
||||||
const containerId = recorder.container_id;
|
const containerId = recorder.container_id;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -803,7 +1019,6 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[recorders] failed local background stop:', e.message);
|
console.error('[recorders] failed local background stop:', e.message);
|
||||||
// Attempt finalize and cleanup even if stop call timed out
|
|
||||||
await waitForFinalize(recorder).catch(() => {});
|
await waitForFinalize(recorder).catch(() => {});
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).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);
|
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
|
||||||
|
|
||||||
try {
|
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();
|
if (captureRes.ok) live = await captureRes.json();
|
||||||
} catch (_) { /* not ready yet */ }
|
} 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) {
|
async function handleSidecarStop(containerId, res) {
|
||||||
try {
|
try {
|
||||||
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
|
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
|
||||||
|
|
@ -1274,6 +1396,11 @@ const server = http.createServer((req, res) => {
|
||||||
ip: getIp(),
|
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') {
|
} else if (req.method === 'POST' && pathname === '/sidecar/start') {
|
||||||
readBody(req)
|
readBody(req)
|
||||||
.then(body => handleSidecarStart(body, res))
|
.then(body => handleSidecarStart(body, res))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue