dragonflight/services/capture/src/index.js
Claude 858c9f7b97 fix(deltacast-bridge): call VHD_SetBiDirCfg before board open + set channel SDI mode
ROOT CAUSE of 'connecting' hangs and intermittent port failures:
The DELTA-12G-e-h 8c is a bidirectional card. Without calling
VHD_SetBiDirCfg(board_index, VHD_BIDIR_80) before streaming, the
board remains in its default bi-dir config (likely 4RX/4TX) — so
RX stream opens fail with VHDERR_RESOURCEUNAVAILABLE on channels
configured as TX, causing random 'connecting' hangs per the SDK docs.

Per SDK Tools.cpp SetNbChannels() pattern:
1. Open temporary board handle
2. Check IS_BIDIR + channel counts
3. Call VHD_SetBiDirCfg(board_index, VHD_BIDIR_80) for 8ch bidir
4. Close temp handle, then open real board handle for streaming

Also add VHD_SetChannelProperty(VHD_CHANNEL_MODE_SDI) for ASI-type
channels per Sample_RX.cpp — required for 12G-ASI/3G-ASI channel
types to correctly detect incoming video standard.

🤖 Generated with Claude Code
2026-06-02 11:23:39 +00:00

218 lines
8.8 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import captureRoutes from './routes/capture.js';
import captureManager from './capture-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
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);
}
});
// 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';
}
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';
const listenPort = envInt('LISTEN_PORT');
const streamKey = envOpt('STREAM_KEY');
const sourceUrl = envOpt('SOURCE_URL');
const device = envInt('DEVICE_INDEX');
// 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;
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
assetId: envOpt('ASSET_ID') || null,
projectId,
binId: envOpt('BIN_ID') || null,
clipName,
device,
port,
board,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
// 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',
});
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);
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',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
});
} catch (e) {
console.error('[shutdown] failed to flag empty asset:', e.message);
}
}
} else if (completed.growingPath) {
// Growing-files recorder: the master lives on the SMB share as a .ts,
// NOT in S3 yet. The promotion worker (which watches the same share)
// uploads it to S3 and enqueues the proxy from the real, finalized key.
// We must NOT call /finalize here: that sets original_s3_key to a key
// that doesn't exist yet and enqueues a proxy that instantly fails with
// "unable to open the file on disk." Leave the asset 'live' for the
// promotion worker to flip to 'ready'.
console.log(`[shutdown] growing capture finalized on share (${completed.growingPath}); leaving promotion worker to upload + proxy`);
} 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);
}
} else {
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
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);
}
}
} 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'));