feat(library): first-frame poster thumbnail for live recordings

Replace the HLS 'connecting…' player in the library with a real frame grabbed
from the start of the recording, while the recording is still live.

Flow:
- recorders.js already pre-creates the asset as status='live' + ASSET_ID env
- capture-manager.start() fires _publishLiveThumbnail() (non-blocking): polls
  /live/<id> for the first seg-*.ts, extracts frame 0 via ffmpeg (scaled JPEG,
  yuvj420p), uploads to S3 thumbnails/<id>.jpg, then POSTs the key to mam-api
- new mam-api POST /assets/:id/live-thumbnail sets thumbnail_s3_key on the still
  -live row (status untouched); idempotent no-op once finalized
- visuals.jsx AssetThumb: for live assets, show the static poster once the key /
  signed URL is available, else fall back to the live HLS preview. Pulsing LIVE
  border kept either way
- POST /assets gains an optional status param (default 'processing'); 'live'
  skips the proxy/thumbnail queue
- capture /stop route now finalizes the pre-created asset by id (guarded) instead
  of POSTing a duplicate

🤖 Generated with Claude Code
This commit is contained in:
Claude 2026-06-02 15:21:05 +00:00
parent f8eda7fc37
commit a2790601c9
4 changed files with 174 additions and 34 deletions

View file

@ -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/<assetId> for the first seg-*.ts (bridge/ffmpeg warm-up)
// 2. ffmpeg -i <segment> -frames:v 1 -> scaled JPEG
// 3. upload JPEG to S3 at thumbnails/<assetId>.jpg (matches mam-api convention)
// 4. POST /assets/<assetId>/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 };

View file

@ -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);

View file

@ -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 {

View file

@ -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/<id>/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 (
<div className="asset-thumb" style={{ aspectRatio: aspect, position: 'relative', background: '#000', overflow: 'hidden' }}>
{thumbUrl
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <FauxFrame />}
{/* Keep the pulsing LIVE border so it still reads as recording */}
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
</div>
);
}
return <LiveThumb assetId={asset.id} aspect={aspect} />;
}