diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 5a31214..8dbae25 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -1089,6 +1089,7 @@ exit "$BMXRC" device, sourceType, sourceUrl, + assetId, hiresKey, proxyKey, growingPath, @@ -1105,6 +1106,12 @@ exit "$BMXRC" }, }; + // Fire-and-forget: grab the first frame for the live poster thumbnail. + // Only for sources that produce an HLS dir (sdi/deltacast); never blocks start(). + if (sdiHlsDir && assetId) { + this._publishLiveThumbnail({ assetId, hlsDir: sdiHlsDir }).catch(() => {}); + } + return this._formatSessionResponse(); } @@ -1267,6 +1274,7 @@ exit "$BMXRC" return { sessionId, + assetId: currentSession.assetId, projectId: currentSession.projectId, binId: currentSession.binId, clipName: currentSession.clipName, @@ -1282,6 +1290,74 @@ exit "$BMXRC" }; } + // Grab the first video frame from the live HLS output and publish it as the + // asset's poster thumbnail, so the library shows a real frame instead of the + // "connecting…" placeholder while recording is still in progress. + // + // Runs entirely on the sidecar (where the HLS segments physically exist): + // 1. poll /live/ for the first seg-*.ts (bridge/ffmpeg warm-up) + // 2. ffmpeg -i -frames:v 1 -> scaled JPEG + // 3. upload JPEG to S3 at thumbnails/.jpg (matches mam-api convention) + // 4. POST /assets//live-thumbnail so the row gets thumbnail_s3_key + // + // Best-effort and non-blocking: any failure is logged and swallowed — the + // post-stop thumbnail job still produces the final thumbnail regardless. + async _publishLiveThumbnail({ assetId, hlsDir }) { + if (!assetId || !hlsDir) return; + const mamUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; + const tmpJpg = `/tmp/livethumb-${assetId}.jpg`; + const thumbKey = `thumbnails/${assetId}.jpg`; + + try { + // 1. Wait up to 30s for the first HLS segment to appear. + const deadline = Date.now() + 30_000; + let segment = null; + while (Date.now() < deadline && this.state.recording && this.state.currentSession.assetId === assetId) { + try { + const entries = await fs.promises.readdir(hlsDir); + const segs = entries.filter(f => /^seg-\d+\.ts$/.test(f)).sort(); + if (segs.length > 0) { segment = `${hlsDir}/${segs[0]}`; break; } + } catch (_) { /* dir not created yet */ } + await new Promise(r => setTimeout(r, 500)); + } + if (!segment) { console.warn(`[livethumb] no segment for ${assetId} within 30s`); return; } + + // 2. Extract the first frame, scaled to 640px wide (yuvj420p for broad JPEG + // decoder compatibility), as a single still. + await new Promise((resolve, reject) => { + const ff = spawn('ffmpeg', [ + '-y', '-i', segment, + '-frames:v', '1', + '-vf', 'scale=640:-2', + '-pix_fmt', 'yuvj420p', + tmpJpg, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + ff.stderr.on('data', d => { err += d.toString(); }); + ff.on('error', reject); + ff.on('exit', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err.slice(-200)}`))); + }); + + // 3. Upload to S3. + const size = statSync(tmpJpg).size; + if (size <= 0) throw new Error('extracted thumbnail is 0 bytes'); + await createUploadStream(S3_BUCKET, thumbKey, createReadStream(tmpJpg)); + + // 4. Tell mam-api the key (only sticks while the asset is still 'live'). + const resp = await fetch(`${mamUrl}/api/v1/assets/${assetId}/live-thumbnail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ thumbnailKey: thumbKey }), + }); + if (!resp.ok) throw new Error(`mam-api ${resp.status}: ${(await resp.text()).slice(0, 200)}`); + console.log(`[livethumb] published poster for ${assetId} (${thumbKey})`); + } catch (err) { + console.warn(`[livethumb] failed for ${assetId}:`, err.message); + } finally { + try { unlinkSync(tmpJpg); } catch (_) {} + } + } + getStatus() { if (!this.state.recording) return { recording: false }; diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 0a6cd45..7fc3c06 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -335,6 +335,33 @@ router.post('/start', async (req, res) => { }); } + // 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' }, + 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 session = await captureManager.start({ projectId: project_id, binId: bin_id || null, @@ -345,6 +372,7 @@ router.post('/start', async (req, res) => { listen, listenPort: listen_port, streamKey: stream_key, + assetId, }); res.json(session); @@ -369,33 +397,28 @@ router.post('/stop', async (req, res) => { const completedSession = await captureManager.stop(session_id); - // Register asset with mam-api. - // If proxyKey is null (SRT/RTMP source), set needsProxy=true so the - // worker generates a proxy from the hires file asynchronously. - try { - const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectId: completedSession.projectId, - binId: completedSession.binId, - clipName: completedSession.clipName, - sourceType: completedSession.sourceType, - hiresKey: completedSession.hiresKey, - proxyKey: completedSession.proxyKey, - needsProxy: completedSession.proxyKey === null, - duration: completedSession.duration, - capturedAt: completedSession.startedAt, - }), - }); - - if (!mamResponse.ok) { - console.warn( - `MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`, - ); + // Finalize the pre-created live asset (live -> processing) so the proxy / + // thumbnail job chain kicks off. assetId is set when /start created the live + // asset; guard in case it wasn't (older callers / failed pre-create). + if (completedSession.assetId) { + try { + const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets/${completedSession.assetId}/finalize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hiresKey: completedSession.hiresKey, + proxyKey: completedSession.proxyKey, + needsProxy: completedSession.proxyKey === null, + duration: completedSession.duration, + capturedAt: completedSession.startedAt, + }), + }); + if (!mamResponse.ok) { + console.warn(`MAM API finalize returned ${mamResponse.status}: ${await mamResponse.text()}`); + } + } catch (mamError) { + console.warn('Failed to finalize asset with MAM API:', mamError.message); } - } catch (mamError) { - console.warn('Failed to register asset with MAM API:', mamError.message); } res.json(completedSession); diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 1cd7c93..73e892a 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -162,6 +162,7 @@ router.post('/', async (req, res, next) => { capturedAt, sourceType, // Bug #64: was ignored — now used to set media_type needsProxy, // Bug #64: was ignored — now controls proxy queue logic + status, // 'live' when recording starts, 'processing' (default) when stopped } = req.body; if (!projectId || !clipName) { @@ -196,8 +197,8 @@ router.post('/', async (req, res, next) => { let asset; { id = uuidv4(); - // Bug #64: use sourceType to set media_type (default 'video') const mediaType = (sourceType === 'audio') ? 'audio' : 'video'; + const assetStatus = status || 'processing'; const ins = await pool.query( `INSERT INTO assets ( id, project_id, bin_id, @@ -210,7 +211,7 @@ router.post('/', async (req, res, next) => { VALUES ( $1, $2, $3, $4, $4, - 'processing', $9, + $10, $9, $5, $6, $7, COALESCE($8::timestamptz, NOW()), NOW() @@ -223,6 +224,7 @@ router.post('/', async (req, res, next) => { durationMs, capturedAt || null, mediaType, + assetStatus, ] ); asset = ins.rows[0]; @@ -230,9 +232,10 @@ router.post('/', async (req, res, next) => { const thumbnailKey = `thumbnails/${id}.jpg`; - // Bug #64: when needsProxy is explicitly false and proxyKey is already set, - // skip re-queuing a proxy job and mark the asset ready immediately. - if (needsProxy === false && proxyKey) { + // Skip proxy/thumbnail queue for live assets - they'll be processed after recording stops + if (assetStatus === 'live') { + // Live assets stay in 'live' status until recording finishes + } else if (needsProxy === false && proxyKey) { await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]); asset.status = 'ready'; } else if (proxyKey) { @@ -505,6 +508,31 @@ router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => { } catch (err) { next(err); } }); +// POST /:id/live-thumbnail — set the poster thumbnail for a still-live asset. +// The capture sidecar extracts the first video frame from the first HLS segment +// (where the segment physically exists) and uploads it to S3, then calls this to +// record the key. This replaces the "connecting…" placeholder in the library with +// a real frame while recording is still in progress. Only touches thumbnail_s3_key +// — does NOT change status (the asset stays 'live' until the recording stops). +router.post('/:id/live-thumbnail', requireAssetEdit, async (req, res, next) => { + try { + const { id } = req.params; + const { thumbnailKey } = req.body; + if (!thumbnailKey) return res.status(400).json({ error: 'thumbnailKey is required' }); + const upd = await pool.query( + `UPDATE assets SET thumbnail_s3_key = $2, updated_at = NOW() + WHERE id = $1 AND status = 'live' + RETURNING id, thumbnail_s3_key`, + [id, thumbnailKey] + ); + if (upd.rows.length === 0) { + // Asset already finalized or gone — harmless, the post-stop thumbnail job covers it. + return res.status(200).json({ skipped: true }); + } + res.json(upd.rows[0]); + } catch (err) { next(err); } +}); + // POST /:id/generate-proxy router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => { try { diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx index 374b69b..5382aa6 100644 --- a/services/web-ui/public/visuals.jsx +++ b/services/web-ui/public/visuals.jsx @@ -25,10 +25,23 @@ function AssetThumb({ asset, size = 'md' }) { ); } - // Live/recording assets: show a muted HLS live preview instead of a black - // box. The capture container writes HLS segments to /live//index.m3u8 - // while recording is in progress; no thumbnail_s3_key exists yet. + // Live/recording assets: once the capture sidecar has published a poster + // thumbnail (first frame of the recording), show that static frame instead + // of the HLS "connecting…" player. Until the poster exists (the brief window + // before the first segment is grabbed), fall back to the live HLS preview. if (asset.status === 'live' && asset.id) { + if (asset.thumbnail_s3_key || thumbUrl) { + const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail'; + return ( +
+ {thumbUrl + ? {altText} + : } + {/* Keep the pulsing LIVE border so it still reads as recording */} +
+
+ ); + } return ; }