diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 6a56d3d..c613e63 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -11,6 +11,11 @@ class CaptureManager { sessionId: null, processes: {}, currentSession: {}, + // Live signal metrics derived from ffmpeg stderr + framesReceived: 0, + currentFps: 0, + lastFrameAt: null, + lastError: null, }; } @@ -32,7 +37,7 @@ class CaptureManager { url += (url.includes('?') ? '&' : '?') + 'mode=caller'; } } - return { inputArgs: ['-i', url], isNetwork: true }; + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true }; } if (sourceType === 'rtmp') { @@ -40,11 +45,11 @@ class CaptureManager { const port = listenPort || 1935; const key = streamKey || 'stream'; return { - inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], + inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], isNetwork: true, }; } - return { inputArgs: ['-i', sourceUrl], isNetwork: true }; + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true }; } // Default: SDI via DeckLink @@ -105,6 +110,8 @@ class CaptureManager { // ProRes hires — fragmented moov for pipe-safe output on network sources const hiresCodecArgs = isNetwork ? [ + '-map', '0:v:0?', + '-map', '0:a:0?', '-c:v', 'prores_ks', '-profile:v', '3', '-c:a', 'pcm_s24le', @@ -133,7 +140,19 @@ class CaptureManager { const uploads = { hires: hiresUpload }; hiresProcess.stderr.on('data', (data) => { - console.error(`[HIRES] ${data}`); + const text = data.toString(); + console.error(`[HIRES] ${text}`); + // Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s + const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/); + if (m) { + this.state.framesReceived = parseInt(m[1], 10); + this.state.currentFps = parseFloat(m[2]); + this.state.lastFrameAt = new Date().toISOString(); + } + // Surface fatal-looking lines for the status endpoint + if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) { + this.state.lastError = text.trim().slice(0, 240); + } }); // SDI only: spawn a second FFmpeg process for the proxy. @@ -166,6 +185,10 @@ class CaptureManager { this.state.recording = true; this.state.sessionId = sessionId; this.state.processes = processes; + this.state.framesReceived = 0; + this.state.currentFps = 0; + this.state.lastFrameAt = null; + this.state.lastError = null; this.state.currentSession = { sessionId, projectId, @@ -254,6 +277,14 @@ class CaptureManager { const now = new Date(); const duration = Math.round((now - startTime) / 1000); + const lastFrameAt = this.state.lastFrameAt; + const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null; + let signal = 'connecting'; + if (this.state.framesReceived > 0) { + signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost'; + } else if (this.state.lastError) { + signal = 'error'; + } return { recording: true, sessionId: this.state.sessionId, @@ -264,6 +295,12 @@ class CaptureManager { binId: this.state.currentSession.binId, duration, startedAt: this.state.currentSession.startedAt, + signal, + framesReceived: this.state.framesReceived, + currentFps: this.state.currentFps, + lastFrameAt, + msSinceFrame, + lastError: this.state.lastError, }; } diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index c1496d9..29e511e 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -240,6 +240,8 @@ router.post('/:id/start', async (req, res, next) => { } // Build container config + // Stable network alias so mam-api can fetch live capture status by id + const alias = `recorder-${id}`; const containerConfig = { Image: 'wild-dragon-capture:latest', Env: env, @@ -249,7 +251,12 @@ router.post('/:id/start', async (req, res, next) => { NetworkMode: dockerNetwork, PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, }, - Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, + NetworkingConfig: { + EndpointsConfig: { + [dockerNetwork]: { Aliases: [alias] }, + }, + }, + Hostname: alias, }; // Create container @@ -396,10 +403,28 @@ router.get('/:id/status', async (req, res, next) => { const now = Date.now(); const duration = Math.floor((now - startedAt) / 1000); + // Try to fetch live signal from the recorder's capture sidecar via network alias + let signal = container.State.Running ? 'connecting' : 'stopped'; + let live = null; + try { + const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) }); + if (captureRes.ok) { + live = await captureRes.json(); + if (live && live.signal) signal = live.signal; + } + } catch (_) { + // Container may not be ready yet, or alias hasn't propagated. Leave signal as default. + } + res.json({ status: container.State.Running ? 'recording' : 'stopped', duration, containerId: recorder.container_id, + signal, + framesReceived: live ? live.framesReceived : null, + currentFps: live ? live.currentFps : null, + lastFrameAt: live ? live.lastFrameAt : null, + lastError: live ? live.lastError : null, }); } catch (err) { next(err); diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js index 619c09f..b7bff9e 100644 --- a/services/web-ui/public/js/api.js +++ b/services/web-ui/public/js/api.js @@ -73,7 +73,14 @@ async function captureApi(path, options = {}) { async function getAssets(filters = {}) { const params = new URLSearchParams(filters); const path = `/assets${params.toString() ? '?' + params.toString() : ''}`; - return api(path); + const r = await api(path); + // API returns { assets, total }. Callers expect r.data to be the array, + // so unwrap here — but preserve total as a sibling for any caller that wants it. + if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) { + r.total = r.data.total; + r.data = r.data.assets; + } + return r; } async function getAsset(assetId) { @@ -253,7 +260,12 @@ async function stopRecording() { * Get recent captures — uses assets API ordered by created_at desc. */ async function getRecentCaptures(limit = 10) { - return api(`/assets?limit=${limit}`); + const r = await api(`/assets?limit=${limit}`); + if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) { + r.total = r.data.total; + r.data = r.data.assets; + } + return r; } /** Not available in current capture service — returns empty */ diff --git a/services/web-ui/public/recorders.html b/services/web-ui/public/recorders.html index c70f66f..63991f3 100644 --- a/services/web-ui/public/recorders.html +++ b/services/web-ui/public/recorders.html @@ -3,11 +3,11 @@
-