2026-04-07 21:58:29 -04:00
|
|
|
import express from 'express';
|
|
|
|
|
import cors from 'cors';
|
|
|
|
|
import dotenv from 'dotenv';
|
|
|
|
|
import captureRoutes from './routes/capture.js';
|
2026-05-18 09:25:55 -04:00
|
|
|
import captureManager from './capture-manager.js';
|
2026-04-07 21:58:29 -04:00
|
|
|
|
|
|
|
|
dotenv.config();
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const PORT = process.env.PORT || 3001;
|
2026-05-18 09:25:55 -04:00
|
|
|
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
2026-05-28 21:57:39 -04:00
|
|
|
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
|
2026-04-07 21:58:29 -04:00
|
|
|
|
|
|
|
|
app.use(cors());
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
|
|
|
|
|
app.get('/health', (req, res) => {
|
|
|
|
|
res.json({ status: 'ok' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.use('/capture', captureRoutes);
|
|
|
|
|
|
2026-05-18 09:25:55 -04:00
|
|
|
const server = app.listen(PORT, () => {
|
2026-04-07 21:58:29 -04:00
|
|
|
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
2026-06-02 07:23:39 -04:00
|
|
|
const _srcType = process.env.SOURCE_TYPE;
|
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
2026-06-03 17:59:33 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-02 07:23:39 -04:00
|
|
|
}
|
2026-04-07 21:58:29 -04:00
|
|
|
});
|
2026-05-18 07:29:50 -04:00
|
|
|
|
2026-05-21 00:16:03 -04:00
|
|
|
// Mapped from the env vars routes/recorders.js writes into the container.
|
|
|
|
|
// Empty strings collapse to undefined so capture-manager's defaults win.
|
|
|
|
|
function envOpt(name) {
|
|
|
|
|
const v = process.env[name];
|
|
|
|
|
return v === undefined || v === '' ? undefined : v;
|
|
|
|
|
}
|
|
|
|
|
function envInt(name) {
|
|
|
|
|
const v = envOpt(name);
|
|
|
|
|
if (v === undefined) return undefined;
|
|
|
|
|
const n = parseInt(v, 10);
|
|
|
|
|
return Number.isFinite(n) ? n : undefined;
|
|
|
|
|
}
|
|
|
|
|
function envBool(name) {
|
|
|
|
|
const v = envOpt(name);
|
|
|
|
|
if (v === undefined) return undefined;
|
|
|
|
|
return v === 'true' || v === '1' || v === 'yes';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 07:29:50 -04:00
|
|
|
async function bootstrapAutoStart() {
|
|
|
|
|
const recorderId = process.env.RECORDER_ID;
|
|
|
|
|
const sourceType = process.env.SOURCE_TYPE;
|
|
|
|
|
if (!recorderId || !sourceType) {
|
|
|
|
|
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projectId = process.env.PROJECT_ID;
|
|
|
|
|
const clipName = process.env.CLIP_NAME;
|
|
|
|
|
if (!projectId || !clipName) {
|
|
|
|
|
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
2026-05-21 00:16:03 -04:00
|
|
|
const listenPort = envInt('LISTEN_PORT');
|
|
|
|
|
const streamKey = envOpt('STREAM_KEY');
|
|
|
|
|
const sourceUrl = envOpt('SOURCE_URL');
|
|
|
|
|
const device = envInt('DEVICE_INDEX');
|
2026-06-01 14:58:58 -04:00
|
|
|
// SOURCE_CONFIG is the recorder's source_config JSON (set by recorders.js).
|
|
|
|
|
// For deltacast it carries the capture channel (`port`) and optional `board`.
|
|
|
|
|
let sourceConfig = {};
|
|
|
|
|
try { sourceConfig = JSON.parse(process.env.SOURCE_CONFIG || '{}') || {}; }
|
|
|
|
|
catch (e) { console.warn('[bootstrap] bad SOURCE_CONFIG JSON:', e.message); }
|
|
|
|
|
const port = Number.isInteger(sourceConfig.port) ? sourceConfig.port : undefined;
|
|
|
|
|
const board = Number.isInteger(sourceConfig.board) ? sourceConfig.board : undefined;
|
2026-05-18 07:29:50 -04:00
|
|
|
|
|
|
|
|
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
|
|
|
|
try {
|
|
|
|
|
const session = await captureManager.start({
|
2026-05-21 00:16:03 -04:00
|
|
|
assetId: envOpt('ASSET_ID') || null,
|
2026-05-18 07:29:50 -04:00
|
|
|
projectId,
|
2026-05-21 00:16:03 -04:00
|
|
|
binId: envOpt('BIN_ID') || null,
|
2026-05-18 07:29:50 -04:00
|
|
|
clipName,
|
2026-05-21 00:16:03 -04:00
|
|
|
device,
|
2026-06-01 14:58:58 -04:00
|
|
|
port,
|
|
|
|
|
board,
|
2026-05-18 07:29:50 -04:00
|
|
|
sourceType,
|
|
|
|
|
sourceUrl,
|
|
|
|
|
listen,
|
|
|
|
|
listenPort,
|
|
|
|
|
streamKey,
|
2026-05-21 00:16:03 -04:00
|
|
|
|
|
|
|
|
// Recording codec — recorders.js passes these straight through
|
|
|
|
|
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
|
|
|
|
|
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
|
|
|
|
|
framerate: envOpt('RECORDING_FRAMERATE'),
|
|
|
|
|
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
|
|
|
|
|
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
|
|
|
|
|
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
|
|
|
|
|
container: envOpt('RECORDING_CONTAINER') || 'mov',
|
|
|
|
|
|
|
|
|
|
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
|
|
|
|
|
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
|
|
|
|
|
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
|
|
|
|
|
proxyFramerate: envOpt('PROXY_FRAMERATE'),
|
|
|
|
|
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
|
|
|
|
|
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
|
|
|
|
|
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
|
|
|
|
|
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
|
2026-05-18 07:29:50 -04:00
|
|
|
});
|
|
|
|
|
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[bootstrap] failed to start capture:', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let shuttingDown = false;
|
|
|
|
|
async function gracefulShutdown(signal) {
|
|
|
|
|
if (shuttingDown) return;
|
|
|
|
|
shuttingDown = true;
|
|
|
|
|
console.log(`[shutdown] ${signal} received`);
|
|
|
|
|
|
|
|
|
|
const status = captureManager.getStatus();
|
|
|
|
|
|
|
|
|
|
if (status.recording) {
|
|
|
|
|
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
|
|
|
|
try {
|
|
|
|
|
const completed = await captureManager.stop(status.sessionId);
|
2026-05-22 23:52:30 -04:00
|
|
|
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
|
|
|
|
|
|
|
|
|
|
const liveAssetId = process.env.ASSET_ID || null;
|
|
|
|
|
|
|
|
|
|
// No frames received → the source never connected (bad SRT URL, dead
|
|
|
|
|
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
|
|
|
|
|
// point is 0 bytes and would just clog the proxy queue with "moov
|
|
|
|
|
// atom not found" failures. Mark the pre-created live asset as
|
|
|
|
|
// 'error' and skip the POST /assets registration entirely.
|
|
|
|
|
if (completed.empty) {
|
|
|
|
|
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
|
|
|
|
|
if (liveAssetId) {
|
|
|
|
|
try {
|
|
|
|
|
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
|
|
|
|
method: 'POST',
|
2026-05-28 21:57:39 -04:00
|
|
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
2026-05-22 23:52:30 -04:00
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[shutdown] failed to flag empty asset:', e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 19:41:28 -04:00
|
|
|
} else if (completed.growingPath) {
|
2026-06-02 20:38:50 -04:00
|
|
|
// Growing-files recorder: the master lives on the SMB share. Mark the asset
|
|
|
|
|
// as pending_migration so the UI shows it is on SMB and provides a manual
|
|
|
|
|
// right-click option to promote it to S3.
|
|
|
|
|
console.log(`[shutdown] growing capture finalized on share (${completed.growingPath}); flagging pending_migration`);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/pending-migration`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
|
|
|
|
body: JSON.stringify({ duration: completed.duration }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
console.warn(`[shutdown] mam-api pending-migration returned ${res.status}: ${await res.text()}`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[shutdown] live asset flagged pending_migration with mam-api');
|
|
|
|
|
}
|
|
|
|
|
} catch (mamErr) {
|
|
|
|
|
console.error('[shutdown] failed to flag pending_migration:', mamErr.message);
|
|
|
|
|
}
|
2026-05-28 23:20:02 -04:00
|
|
|
} else if (liveAssetId) {
|
|
|
|
|
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
|
|
|
|
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[shutdown] live asset finalised with mam-api');
|
|
|
|
|
}
|
|
|
|
|
} catch (mamErr) {
|
|
|
|
|
console.error('[shutdown] failed to finalise asset:', mamErr.message);
|
|
|
|
|
}
|
2026-05-22 23:52:30 -04:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
|
|
|
|
method: 'POST',
|
2026-05-28 21:57:39 -04:00
|
|
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
2026-05-22 23:52:30 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
projectId: completed.projectId,
|
|
|
|
|
binId: completed.binId,
|
|
|
|
|
clipName: completed.clipName,
|
|
|
|
|
sourceType: completed.sourceType,
|
|
|
|
|
hiresKey: completed.hiresKey,
|
|
|
|
|
proxyKey: completed.proxyKey,
|
|
|
|
|
needsProxy: completed.proxyKey === null,
|
|
|
|
|
duration: completed.duration,
|
|
|
|
|
capturedAt: completed.startedAt,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[shutdown] asset registered with mam-api');
|
|
|
|
|
}
|
|
|
|
|
} catch (mamErr) {
|
|
|
|
|
console.error('[shutdown] failed to register asset:', mamErr.message);
|
2026-05-18 07:29:50 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[shutdown] error during stop:', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server.close(() => {
|
|
|
|
|
console.log('[shutdown] http server closed - exiting');
|
|
|
|
|
process.exit(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setTimeout(() => process.exit(0), 5000).unref();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
|
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|