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:
parent
f8eda7fc37
commit
a2790601c9
4 changed files with 174 additions and 34 deletions
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue