openvibers/openvibe.media Commits

Thumbnails that survive a bad frame, and 6 s hover previews (355) (#410)

OP OpenVibers committed 2 days ago · dc84fbdd83 · 13 files, +711 −91

* Thumbnails that survive a bad frame, and 6 s hover previews (355)

* Backfill quarantined public recordings too: their visibility is public

Changes

ddiff --git a/migrations/0028_media_previews.sql b/migrations/0028_media_previews.sql
nnew file mode 100644
iindex 0000000..ed0b4b7
---- /dev/null
++++ b/migrations/0028_media_previews.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- Hover previews (6 s, muted, 320 px wide, MP4 H.264 and WebM) for a finished recording or clip, stored as files
++-- beside the thumbnails and named for their item (vod-<id>-<time>.mp4/.webm, served at /p/<name>). preview_url is
++-- the MP4 (every browser plays it); preview_webm_url the WebM of the same moment. Both null until made, and the
++-- preview.make job fills them. A live stream's preview_url is Live's own field (its DVR playlist), not a column here.
++ALTER TABLE vods ADD COLUMN IF NOT EXISTS preview_url text COLLATE "C";
++ALTER TABLE vods ADD COLUMN IF NOT EXISTS preview_webm_url text COLLATE "C";
++ALTER TABLE clips ADD COLUMN IF NOT EXISTS preview_url text COLLATE "C";
++ALTER TABLE clips ADD COLUMN IF NOT EXISTS preview_webm_url text COLLATE "C";
ddiff --git a/server/config.js b/server/config.js
iindex 81aee56..9c4519c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -149,6 +149,10 @@ const config = {
// picture is missing or gone, so the home's replay rows stop showing a grey 1×1.
thumbnailBackfillMin: intEnv('MEDIA_THUMBNAIL_BACKFILL_MIN', 2),
thumbnailBackfillBatch: Math.max(1, Math.min(20, intEnv('MEDIA_THUMBNAIL_BACKFILL_BATCH', 10))),
++ // The hover-preview backfill (server/jobs/preview.js sweepMissing): a small batch this often (minutes; 0 =
++ // never), newest items first. Each is a 6 s 320 px encode in the light lane.
++ previewBackfillMin: intEnv('MEDIA_PREVIEW_BACKFILL_MIN', 5),
++ previewBackfillBatch: Math.max(1, Math.min(10, intEnv('MEDIA_PREVIEW_BACKFILL_BATCH', 2))),
retentionDays: intEnv('MEDIA_JOBS_RETENTION_DAYS', 30), // finished thumbnail jobs are pruned after this
},
ddiff --git a/server/jobs/preview.js b/server/jobs/preview.js
nnew file mode 100644
iindex 0000000..8b01608
---- /dev/null
++++ b/server/jobs/preview.js
@@@ -0,0 +1,277 @@
++/**
++ * OpenVibe.Media — job type preview.make
++ *
++ * A short, muted hover preview for one finished recording or clip: about 6 s from a moment with motion, 320 px wide,
++ * MP4 H.264 and WebM, aimed under 400 KB (server/thumbnails/preview-service.js owns the files and the /p/<name> route).
++ * It runs in the light lane (a 6 s 320 px encode costs about as much as a thumbnail) and only touches the source's
++ * preview_url / preview_webm_url columns: the source file and its thumbnail are never changed.
++ *
++ * params { kind: 'vod' | 'clip', id: <row id> }
++ * result { kind, id, preview_url, preview_webm_url, start_seconds, seconds, bytes, webm_bytes }
++ *
++ * A moment with motion is the first scene change in a 20 s window around 10% in (ffmpeg's `scene` score); when the
++ * scene is static the 10% mark itself is used. A virtual clip (a window over its VOD's timeline, no file of its own)
++ * previews its window. An audio-only item, a private item, a source under 1.5 s and one that is gone are answered as
++ * done with no preview (a preview is an extra, never a reason to retry a recording forever).
++ *
++ * The backfill (sweepMissing) queues a small batch of them, newest first and due based, so the recordings made before
++ * previews existed reach their turn without a retry storm for the ones that can never have one.
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const config = require('../config');
++const { JobError } = require('./queue');
++const previews = require('../thumbnails/preview-service');
++
++const TYPE = 'preview.make';
++const MIN_SOURCE_SECONDS = 1.5;
++const WIDTH = previews.PREVIEW_WIDTH;
++const SECONDS = previews.PREVIEW_SECONDS;
++const MAX_BYTES = previews.MAX_BYTES;
++const CANDIDATE_WINDOW = 400;
++
++function targetOf(params = {}) {
++ const kind = String(params.kind || '');
++ const id = Number(params.id);
++ if (!['vod', 'clip'].includes(kind)) throw new JobError('media.job.invalid', "preview.make needs params.kind 'vod' | 'clip'", { permanent: true });
++ if (!Number.isInteger(id) || id < 1) throw new JobError('media.job.invalid', 'preview.make needs params.id (the vod or clip id)', { permanent: true });
++ return { kind, id };
++}
++
++async function rowOf(appId, kind, id) {
++ return kind === 'vod' ? await db.getVodById(id, appId) : await db.getClipById(id, appId);
++}
++
++async function validate({ appId, params }) {
++ const t = targetOf(params || {});
++ const row = await rowOf(appId, t.kind, t.id);
++ if (!row) throw new JobError('media.job.not_found', `${t.kind} ${t.id} not found`, { status: 404, permanent: true });
++ return t;
++}
++
++/** The file ffmpeg previews, the clip window's start in it, and the window's own duration. */
++async function sourceOf(appId, kind, row) {
++ const vodStorage = require('../vod/vod-storage');
++ const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;
++ const recording = virtual ? await db.getVodById(row.vod_id, appId) : (kind === 'vod' ? row : null);
++ if (virtual && !recording) return null;
++ const source = await vodStorage.resolveMediaSource(virtual ? recording : row);
++ if (!source) return null;
++ return {
++ input: source.value,
++ remote: source.kind === 'url',
++ offset: virtual ? Math.max(0, Number(row.start_time) || 0) : 0,
++ duration: kind === 'clip' ? Number(row.duration_seconds) || 0 : 0,
++ recording,
++ };
++}
++
++/** First scene change (seconds from the start of the scanned window), or null. */
++async function firstSceneChange(input, remote, from, scanSeconds, ctx) {
++ const r = await require('./derive').ffmpeg([
++ '-hide_banner', '-nostdin', '-v', 'info', ...require('./derive').inputArgs({ remote }),
++ '-ss', from.toFixed(2), '-t', scanSeconds.toFixed(2), '-i', input,
++ '-an', '-vf', "select='gt(scene,0.08)',showinfo", '-f', 'null', '-',
++ ], { signal: ctx.signal, timeoutMs: 5 * 60 * 1000 });
++ if (!r.ok) return null;
++ const m = /pts_time:([0-9.]+)/.exec(String(r.stderr || ''));
++ if (!m) return null;
++ const at = Number(m[1]);
++ return Number.isFinite(at) && at >= 0 ? at : null;
++}
++
++/** Where the preview starts (seconds inside the item's own window) and how long it is. */
++function pickWindow(duration) {
++ const len = Math.min(SECONDS, Math.max(1, duration));
++ const anchor = duration > 20
++ ? Math.min(duration * 0.1, duration - len)
++ : Math.max(0, (duration - len) / 2);
++ return { len: Math.round(len * 1000) / 1000, anchor: Math.round(anchor * 1000) / 1000 };
++}
++
++/** Encode one format, retrying a too-large result at a higher CRF. Returns the file size, or null. */
++async function encode(input, remote, startAbs, seconds, out, codec, ctx, attempt = 0) {
++ const d = require('./derive');
++ const mp4 = codec === 'mp4';
++ const args = ['-y', '-nostdin', '-v', 'error', ...d.inputArgs({ remote }),
++ '-ss', startAbs.toFixed(3), '-t', seconds.toFixed(3), '-i', input,
++ '-an', '-vf', `scale=${WIDTH}:-2`];
++ if (mp4) {
++ args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(30 + attempt * 4),
++ '-maxrate', `${Math.max(160, 420 - attempt * 80)}k`, '-bufsize', '840k',
++ '-pix_fmt', 'yuv420p', '-profile:v', 'main', '-movflags', '+faststart', '-f', 'mp4', out);
++ } else {
++ // VP9 where this ffmpeg has it, VP8 otherwise (both are WebM; a hover preview needs only the codec a browser plays).
++ args.push('-c:v', 'libvpx-vp9', '-crf', String(40 + attempt * 6), '-b:v', '0', '-deadline', 'good', '-cpu-used', '4', '-row-mt', '1', '-f', 'webm', out);
++ }
++ let r = await d.ffmpeg(args, { signal: ctx.signal, timeoutMs: 5 * 60 * 1000, niceness: 5 });
++ if (!r.ok && !mp4 && /Unknown encoder|libvpx-vp9/i.test(r.stderr)) {
++ args.splice(args.indexOf('libvpx-vp9'), 1, 'libvpx');
++ args.splice(args.indexOf('-row-mt'), 2);
++ r = await d.ffmpeg(args, { signal: ctx.signal, timeoutMs: 5 * 60 * 1000, niceness: 5 });
++ }
++ if (!r.ok || !d.existing(out) || !fs.statSync(out).size) return null;
++ const size = fs.statSync(out).size;
++ if (size > MAX_BYTES && attempt < 2) {
++ try { fs.unlinkSync(out); } catch { /* gone */ }
++ return await encode(input, remote, startAbs, seconds, out, codec, ctx, attempt + 1);
++ }
++ return size;
++}
++
++async function run(job, ctx) {
++ const { kind, id } = targetOf(job.params);
++ const row = await rowOf(job.app_id, kind, id);
++ if (!row) throw new JobError('media.job.not_found', `${kind} ${id} not found`, { status: 404, permanent: true });
++ const visibility = row.visibility || (Number(row.is_public) ? 'public' : 'private');
++ // An extra for cards, never content in its own right: a private or audio-only item gets none.
++ if (visibility === 'private' || Number(row.audio_only)) return { kind, id, preview_url: null, preview_webm_url: null, skipped: 'not_public_or_no_video' };
++ const src = await sourceOf(job.app_id, kind, row);
++ if (!src) return { kind, id, preview_url: null, preview_webm_url: null, skipped: 'no_source' };
++ const derive = require('./derive');
++ const probe = await derive.probeStreams({ input: src.input, remote: src.remote }, { signal: ctx && ctx.signal, timeoutMs: 60000 }).catch(() => null);
++ if (!probe || !probe.video) return { kind, id, preview_url: null, preview_webm_url: null, skipped: 'no_video' };
++ const duration = src.duration > 0 ? src.duration : Number(probe.duration) || 0;
++ if (!(duration >= MIN_SOURCE_SECONDS)) return { kind, id, preview_url: null, preview_webm_url: null, skipped: 'too_short' };
++ const { len, anchor } = pickWindow(duration);
++ // A moment with motion: the first scene change in a 20 s window just before the 10% mark.
++ let start = anchor;
++ const scanStart = Math.max(0, anchor - 4);
++ const scanLen = Math.min(20, Math.max(0, duration - scanStart));
++ if (scanLen > 1) {
++ const at = await firstSceneChange(src.input, src.remote, src.offset + scanStart, scanLen, { signal: ctx && ctx.signal }).catch(() => null);
++ if (at != null && at + len <= scanLen + 0.25) start = scanStart + at;
++ }
++ start = Math.max(0, Math.min(start, Math.max(0, duration - len)));
++ const names = previews.previewNames(kind, id);
++ const dir = previews.PREVIEW_DIR;
++ const mp4Path = path.join(dir, names.mp4);
++ const webmPath = path.join(dir, names.webm);
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++ const startAbs = src.offset + start;
++ const [mp4, webm] = await Promise.all([
++ encode(src.input, src.remote, startAbs, len, mp4Path, 'mp4', { signal: ctx && ctx.signal }),
++ encode(src.input, src.remote, startAbs, len, webmPath, 'webm', { signal: ctx && ctx.signal }),
++ ]);
++ if (!mp4 && !webm) {
++ try { for (const f of [mp4Path, webmPath]) fs.unlinkSync(f); } catch { /* gone */ }
++ throw new JobError('generate_failed', 'Failed to encode a preview');
++ }
++ if (!mp4) { try { fs.unlinkSync(mp4Path); } catch { /* gone */ } }
++ if (!webm) { try { fs.unlinkSync(webmPath); } catch { /* gone */ } }
++ const previewUrl = mp4 ? previews.previewUrl(names.mp4) : previews.previewUrl(names.webm);
++ const webmUrl = webm ? previews.previewUrl(names.webm) : null;
++ const old = { preview_url: row.preview_url, preview_webm_url: row.preview_webm_url };
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ await db.run(`UPDATE ${table} SET preview_url = ?, preview_webm_url = ? WHERE id = ? AND app_id = ?`, [previewUrl, webmUrl, id, job.app_id]);
++ previews.removePreviewFiles(old.preview_url, old.preview_webm_url);
++ return { kind, id, preview_url: previewUrl, preview_webm_url: webmUrl, start_seconds: Math.round(startAbs * 1000) / 1000, seconds: len, bytes: mp4 || webm, webm_bytes: webm || 0 };
++}
++
++/** Queue this item's preview (idempotent: joins one already queued or running). Never throws. */
++async function queuePreview(appId, kind, id, { createdBy = 'system:preview' } = {}) {
++ try {
++ const row = await rowOf(appId, kind, id);
++ if (!row || Number(row.audio_only)) return null;
++ if ((row.visibility || (Number(row.is_public) ? 'public' : 'private')) === 'private') return null;
++ if (row.preview_url) return null;
++ const { enqueue } = require('./queue');
++ return await enqueue({ appId, type: TYPE, params: { kind, id }, dedupeActive: true, createdBy });
++ } catch (err) {
++ console.warn(`[Previews] could not queue ${kind} ${id}: ${err.message}`);
++ return null;
++ }
++}
++
++// ── Backfill (newest first, small batches, due based) ────────
++const MIN_AGE_MS = 10 * 60 * 1000;
++const RETRY_FAILED_MS = 6 * 3600 * 1000;
++const RETRY_DONE_MS = 24 * 3600 * 1000;
++const cursorKey = (appId, kind) => `preview_backfill.cursor.${kind}.${appId}`;
++
++function dbTimeMs(value) {
++ if (!value) return NaN;
++ const t = String(value);
++ const ms = Date.parse(/Z$|[+-]\d\d:?\d\d$/.test(t) ? t : t.replace(' ', 'T') + 'Z');
++ return Number.isFinite(ms) ? ms : NaN;
++}
++
++async function lastAttempt(appId, kind, id) {
++ return await db.get(`SELECT status, error_code, created_at FROM media_jobs
++ WHERE app_id = ? AND job_type = ? AND params::jsonb ->> 'kind' = ? AND params::jsonb ->> 'id' = ?
++ ORDER BY created_at DESC, id DESC LIMIT 1`, [appId, TYPE, kind, String(id)]);
++}
++
++function dueAt(last) {
++ if (!last) return 0;
++ if (last.status === 'queued' || last.status === 'running') return Infinity;
++ const at = dbTimeMs(last.created_at);
++ if (!Number.isFinite(at)) return 0;
++ if (last.status === 'failed') return at + RETRY_FAILED_MS;
++ // Succeeded with no preview_url: the item was skipped (no video, too short), so once a day is enough.
++ return at + RETRY_DONE_MS;
++}
++
++/** One batch for one kind: public, finished, non-audio items with a picture and no preview, newest first. */
++async function sweepKind(appId, kind, { now, batch, minAgeMs }) {
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ const where = kind === 'vod'
++ ? `app_id = ? AND is_public = 1 AND COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0
++ AND COALESCE(health_status, '') NOT IN ('missing_file', 'zero_byte')`
++ : `app_id = ? AND is_public = 1 AND status = 'ready'`;
++ const cursor = Number(await db.getSetting(cursorKey(appId, kind))) || 0;
++ const rows = await db.all(`SELECT id, created_at, duration_seconds, thumbnail_url, preview_url, file_path${kind === 'clip' ? ', vod_id' : ''} FROM ${table}
++ WHERE ${where}
++ AND audio_only IS NOT TRUE
++ AND preview_url IS NULL AND thumbnail_url IS NOT NULL
++ AND COALESCE(duration_seconds, 0) >= 2
++ AND id < ?
++ ORDER BY id DESC LIMIT ?`, [appId, cursor || '9223372036854775807', CANDIDATE_WINDOW]);
++ let missing = 0, queued = 0, examined = 0, lastId = 0;
++ for (const row of rows) {
++ examined++;
++ lastId = Number(row.id);
++ if (row.preview_url) continue;
++ // A clip with neither a file nor a VOD has nothing to preview (a failed cut).
++ if (kind === 'clip' && !row.file_path && row.vod_id == null) continue;
++ const created = dbTimeMs(row.created_at);
++ if (Number.isFinite(created) && now - created < minAgeMs) continue; // finalize/cut may still queue it
++ missing++;
++ if (now < dueAt(await lastAttempt(appId, kind, row.id))) continue;
++ try {
++ const out = await require('./queue').enqueue({ appId, type: TYPE, params: { kind, id: row.id }, dedupeActive: true, createdBy: 'system:preview-backfill' });
++ if (out && out.created && queued < batch) queued++;
++ if (queued >= batch) break;
++ } catch (err) {
++ console.warn(`[Previews] backfill could not queue ${kind} ${row.id}: ${err.message}`);
++ }
++ }
++ const done = examined === rows.length && rows.length < CANDIDATE_WINDOW;
++ await db.setSetting(cursorKey(appId, kind), done ? 0 : lastId, 'number');
++ return { missing, queued };
++}
++
++/** → { missing, queued } across every tenant (or one app). */
++async function sweepMissing({ now = Date.now(), limit = null, appId = null, minAgeMs = MIN_AGE_MS } = {}) {
++ const batch = Math.max(1, Math.min(10, Number(limit) || config.jobs.previewBackfillBatch || 2));
++ const apps = appId ? [{ app_id: appId }]
++ : await db.all(`SELECT DISTINCT app_id FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0 AND preview_url IS NULL
++ UNION SELECT DISTINCT app_id FROM clips WHERE is_public = 1 AND preview_url IS NULL`);
++ let missing = 0, queued = 0;
++ for (const { app_id: app } of apps) {
++ for (const kind of ['vod', 'clip']) {
++ const r = await sweepKind(app, kind, { now, batch, minAgeMs });
++ missing += r.missing; queued += r.queued;
++ }
++ }
++ if (queued) console.log(`[Previews] backfill: queued ${queued} preview job(s); ${missing} item(s) have no preview`);
++ return { missing, queued };
++}
++
++module.exports = {
++ spec: { lane: 'light', maxAttempts: 2, timeoutMs: 5 * 60 * 1000, needsObject: false, backoffS: () => 120, validate, run },
++ targetOf, pickWindow, sourceOf, queuePreview, sweepMissing, dueAt,
++};
ddiff --git a/server/jobs/thumbnail.js b/server/jobs/thumbnail.js
iindex 65e555d..6ffdd05 100644
---- a/server/jobs/thumbnail.js
++++ b/server/jobs/thumbnail.js
@@@ -158,11 +158,11 @@ function hasPicture(row) {
try { return fs.existsSync(path.join(require('../thumbnails/thumbnail-service').THUMB_DIR, name)); } catch { return false; }
}
--/** This VOD's latest thumbnail job (any status), for the due time. */
--async function lastAttempt(appId, id) {
++/** This item's latest thumbnail job (any status), for the due time. */
++async function lastAttempt(appId, kind, id) {
return await db.get(`SELECT status, error_code, created_at FROM media_jobs
-- WHERE app_id = ? AND job_type = ? AND params::jsonb ->> 'kind' = 'vod' AND params::jsonb ->> 'id' = ?
-- ORDER BY created_at DESC, id DESC LIMIT 1`, [appId, TYPE, String(id)]);
++ WHERE app_id = ? AND job_type = ? AND params::jsonb ->> 'kind' = ? AND params::jsonb ->> 'id' = ?
++ ORDER BY created_at DESC, id DESC LIMIT 1`, [appId, TYPE, kind, String(id)]);
}
/** The earliest moment the next attempt may run; Infinity while one is queued or running. */
@@@ -185,46 +185,63 @@ function dueAt(last) {
async function sweepMissing({ now = Date.now(), limit = null, appId = null, minAgeMs = MIN_AGE_MS } = {}) {
const batch = Math.max(1, Math.min(20, Number(limit) || config.jobs.thumbnailBackfillBatch || 3));
const apps = appId ? [{ app_id: appId }]
-- : await db.all(`SELECT DISTINCT app_id FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0`);
++ : await db.all(`SELECT DISTINCT app_id FROM vods WHERE (is_public = 1 OR visibility IN ('public', 'unlisted'))
++ AND COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0
++ UNION SELECT DISTINCT app_id FROM clips WHERE is_public = 1 OR visibility IN ('public', 'unlisted')`);
let missing = 0, queued = 0;
for (const { app_id: app } of apps) {
-- // Walk the whole library a window at a time (newest first, then older), not just the newest rows:
-- // an old recording with a lost picture is a stored VOD too. The cursor wraps when the walk ends.
-- const cursor = Number(await db.getSetting(cursorKey(app))) || 0;
-- const rows = await db.all(`SELECT id, created_at, thumbnail_url FROM vods
-- WHERE app_id = ? AND is_public = 1
++ const v = await sweepKind(app, 'vod', { now, batch, minAgeMs });
++ missing += v.missing; queued += v.queued;
++ const c = await sweepKind(app, 'clip', { now, batch, minAgeMs });
++ missing += c.missing; queued += c.queued;
++ }
++ return { missing, queued };
++}
++
++/**
++ * One kind's walk: the whole library a window at a time (newest first, then older), not just the newest rows: an old
++ * item with a lost or never-made picture is a stored VOD/clip too. The cursor wraps when the walk ends.
++ */
++async function sweepKind(app, kind, { now, batch, minAgeMs }) {
++ const cursorKeyFor = kind === 'vod' ? cursorKey(app) : `thumbnail_backfill.clips.${app}`;
++ const cursor = Number(await db.getSetting(cursorKeyFor)) || 0;
++ const rows = kind === 'vod'
++ ? await db.all(`SELECT id, created_at, thumbnail_url FROM vods
++ WHERE app_id = ? AND (is_public = 1 OR visibility IN ('public', 'unlisted'))
AND COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0
AND (file_path IS NOT NULL OR object_id IS NOT NULL)
AND COALESCE(health_status, '') NOT IN ('missing_file', 'zero_byte')
AND id < ?
++ ORDER BY id DESC LIMIT ?`, [app, cursor || '9223372036854775807', CANDIDATE_WINDOW])
++ : await db.all(`SELECT id, created_at, thumbnail_url FROM clips
++ WHERE app_id = ? AND (is_public = 1 OR visibility IN ('public', 'unlisted')) AND status = 'ready'
++ AND (file_path IS NOT NULL OR vod_id IS NOT NULL)
++ AND id < ?
ORDER BY id DESC LIMIT ?`, [app, cursor || '9223372036854775807', CANDIDATE_WINDOW]);
-- let appMissing = 0, appQueued = 0, examined = 0, lastId = 0;
-- for (const row of rows) {
-- examined++;
-- lastId = Number(row.id);
-- if (hasPicture(row)) continue;
-- const created = dbTimeMs(row.created_at);
-- if (Number.isFinite(created) && now - created < minAgeMs) continue; // finalize may still make it
-- appMissing++;
-- if (now < dueAt(await lastAttempt(app, row.id))) continue;
-- try {
-- const out = await require('./queue').enqueue({
-- appId: app, type: TYPE, params: { kind: 'vod', id: row.id },
-- dedupeActive: true, createdBy: 'system:thumbnail-backfill',
-- });
-- if (out && out.created && appQueued < batch) appQueued++;
-- if (appQueued >= batch) break;
-- } catch (err) {
-- console.warn(`[Thumbnails] backfill could not queue vod ${row.id}: ${err.message}`);
-- }
++ let missing = 0, queued = 0, examined = 0, lastId = 0;
++ for (const row of rows) {
++ examined++;
++ lastId = Number(row.id);
++ if (hasPicture(row)) continue;
++ const created = dbTimeMs(row.created_at);
++ if (Number.isFinite(created) && now - created < minAgeMs) continue; // finalize/cut may still make it
++ missing++;
++ if (now < dueAt(await lastAttempt(app, kind, row.id))) continue;
++ try {
++ const out = await require('./queue').enqueue({
++ appId: app, type: TYPE, params: { kind, id: row.id },
++ dedupeActive: true, createdBy: 'system:thumbnail-backfill',
++ });
++ if (out && out.created && queued < batch) queued++;
++ if (queued >= batch) break;
++ } catch (err) {
++ console.warn(`[Thumbnails] backfill could not queue ${kind} ${row.id}: ${err.message}`);
}
-- // A stopped batch resumes at the row after the one it stopped on; a finished walk wraps to the top.
-- const done = examined === rows.length && rows.length < CANDIDATE_WINDOW;
-- await db.setSetting(cursorKey(app), done ? 0 : lastId, 'number');
-- if (appQueued) console.log(`[Thumbnails] backfill: queued ${appQueued} thumbnail job(s); ${appMissing} recording(s) on ${app} have no usable picture`);
-- missing += appMissing;
-- queued += appQueued;
}
++ // A stopped batch resumes at the row after the one it stopped on; a finished walk wraps to the top.
++ const done = examined === rows.length && rows.length < CANDIDATE_WINDOW;
++ await db.setSetting(cursorKeyFor, done ? 0 : lastId, 'number');
++ if (queued) console.log(`[Thumbnails] backfill: queued ${queued} ${kind} thumbnail job(s); ${missing} on ${app} have no usable picture`);
return { missing, queued };
}
ddiff --git a/server/jobs/types.js b/server/jobs/types.js
iindex 4080173..da72099 100644
---- a/server/jobs/types.js
++++ b/server/jobs/types.js
@@@ -7,6 +7,7 @@
* object.remux heavy a stream-copy remux of a vod/clip as a new private object
* object.waveform heavy the audio waveform (PNG) of a vod/clip as the source's `waveform` variant
* object.sprite heavy a seek-preview sprite sheet (JPEG, layout in its metadata) as the `sprite` variant
++ * preview.make light the 6 s muted hover preview (MP4 H.264 + WebM, 320 px) of a vod/clip
* object.cmaf heavy the source as CMAF/HLS segments on its own keyframes, indexed in media_timeline (MEDIA_HLS_ENABLED)
* object.pack heavy a timeline's durable segments packed into ~60 s chunk objects (byte ranges; MEDIA_HLS_ENABLED)
* rendition.create heavy an on-demand 720p H.264/AAC rendition of a media object, indexed in media_timeline (MEDIA_HLS_ENABLED + MEDIA_RENDITIONS)
@@@ -27,6 +28,7 @@ queue.register('object.split', require('./derive').split);
queue.register('object.remux', require('./derive').remux);
queue.register('object.waveform', require('./previews').waveform);
queue.register('object.sprite', require('./previews').sprite);
++queue.register('preview.make', require('./preview').spec);
queue.register('object.cmaf', require('./cmaf').spec);
queue.register('object.pack', require('./pack').spec);
queue.register('object.startup-tier', require('./startup-tier').spec);
ddiff --git a/server/jobs/worker.js b/server/jobs/worker.js
iindex b43feef..b13f506 100644
---- a/server/jobs/worker.js
++++ b/server/jobs/worker.js
@@@ -49,6 +49,7 @@ async function sweepWorkDirs() {
}
let lastPruneAt = 0;
let lastThumbBackfillAt = 0;
++let lastPreviewBackfillAt = 0;
let kicked = false;
function lanes() {
@@@ -183,6 +184,13 @@ async function tick() {
try { await require('./thumbnail').sweepMissing({ now, limit: cfg().thumbnailBackfillBatch }); }
catch (err) { console.warn('[Jobs] thumbnail backfill:', err.message); }
}
++ // Hover previews for recent recordings and clips first: a small batch each time (server/jobs/preview.js).
++ const previewMs = cfg().previewBackfillMin * 60 * 1000;
++ if (previewMs > 0 && now - lastPreviewBackfillAt > previewMs) {
++ lastPreviewBackfillAt = now;
++ try { await require('./preview').sweepMissing({ now, limit: cfg().previewBackfillBatch }); }
++ catch (err) { console.warn('[Jobs] preview backfill:', err.message); }
++ }
if (now - lastSweepAt > 3600 * 1000) {
lastSweepAt = now;
await sweepWorkDirs();
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 7164274..27b8e77 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -496,6 +496,14 @@ router.get('/og-image.png', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'static', 'og-image.png'));
});
++// ── Hover previews (6 s muted MP4/WebM; server/thumbnails/preview-service.js) ──
++router.get('/p/:id', async (req, res, next) => {
++ // Not a preview name: a paste slug, which the legacy /p/:slug route below redirects to Community.
++ if (!require('../thumbnails/preview-service').NAME_RE.test(path.basename(String(req.params.id || '')))) return next();
++ try { await require('../thumbnails/preview-service').servePreview(req, res); }
++ catch (err) { console.error('[Public] preview:', err.message); if (!res.headersSent) res.status(500).end(); }
++});
++
// ── Thumbnails ───────────────────────────────────────────────
router.get('/t/:id', sharingLib.attachViewer, async (req, res) => {
if (drill.refuseBytes(res)) return;
ddiff --git a/server/thumbnails/preview-service.js b/server/thumbnails/preview-service.js
nnew file mode 100644
iindex 0000000..6540c0b
---- /dev/null
++++ b/server/thumbnails/preview-service.js
@@@ -0,0 +1,117 @@
++/**
++ * OpenVibe.Media — hover previews (6 s, muted, 320 px wide)
++ *
++ * A finished recording or clip gets a short preview beside its thumbnail, for the hover/centred player the design
++ * puts on cards (plan 355): `vod-<id>-<time>.mp4` (H.264) and `.webm` (VP9, VP8 when this ffmpeg has no VP9), both
++ * in THUMB_DIR, both served at GET /p/<name>. The row's preview_url is the MP4 (every browser plays it) and
++ * preview_webm_url the WebM of the same moment. Files are made by the preview.make job (server/jobs/preview.js)
++ * and only for public/unlisted, non-audio-only items.
++ *
++ * The name follows the thumbnail convention, so the file can be found from the item and the item from the name.
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const config = require('../config');
++const db = require('../db/database');
++
++const PREVIEW_DIR = path.resolve(config.thumbnails.path);
++const PREVIEW_WIDTH = 320;
++const PREVIEW_SECONDS = 6;
++const MAX_BYTES = 400 * 1024;
++const NAME_RE = /^(vod|clip)-(\d{1,15})-\d{10,}\.(mp4|webm)$/;
++const CONTENT_TYPE = { mp4: 'video/mp4', webm: 'video/webm' };
++
++// A deleted item's preview must stop answering (as a deleted item's thumbnail does). Asked per item, remembered a minute.
++const ITEM_TTL_MS = 60 * 1000;
++const _items = new Map(); // 'vod:12' → { at, ok }
++async function itemServable(kind, id) {
++ const key = `${kind}:${id}`;
++ const hit = _items.get(key);
++ if (hit && Date.now() - hit.at < ITEM_TTL_MS) return hit.ok;
++ let ok = false;
++ try {
++ const row = await db.get(`SELECT visibility, is_public FROM ${kind === 'vod' ? 'vods' : 'clips'} WHERE id = ?`, [id]);
++ ok = !!row && (row.visibility || (Number(row.is_public) ? 'public' : 'private')) !== 'private';
++ } catch { ok = true; } // no database to ask (a test, a drill): serve as before
++ if (_items.size > 5000) _items.clear();
++ _items.set(key, { at: Date.now(), ok });
++ return ok;
++}
++
++/** The public URL of a written preview file. */
++const previewUrl = (name) => `/p/${name}`;
++
++/** The file name of a new preview for `kind`/`id` at `stamp` (the same stamp for both formats). */
++function previewNames(kind, id, stamp = Date.now()) {
++ return { mp4: `${kind}-${id}-${stamp}.mp4`, webm: `${kind}-${id}-${stamp}.webm` };
++}
++
++/** Remove the preview files a row used before (the old pair follows a regeneration). */
++function removePreviewFiles(...urls) {
++ for (const url of urls) {
++ if (!url) continue;
++ const name = path.basename(String(url));
++ if (!NAME_RE.test(name)) continue;
++ try { fs.unlinkSync(path.join(PREVIEW_DIR, name)); } catch { /* gone */ }
++ }
++}
++
++/** Parse a Range header against a size: { start, end } or null (invalid / not a range). */
++function parseRange(header, size) {
++ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim());
++ if (!m || (!m[1] && !m[2])) return null;
++ let start, end;
++ if (!m[1]) { // bytes=-500: the last 500 bytes
++ start = Math.max(0, size - Number(m[2]));
++ end = size - 1;
++ } else {
++ start = Number(m[1]);
++ end = m[2] ? Number(m[2]) : size - 1;
++ }
++ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= size) return null;
++ return { start, end: Math.min(end, size - 1) };
++}
++
++/** GET /p/:id — one stored preview. Range requests are answered (a <video> seeking asks for one). */
++async function servePreview(req, res) {
++ if (require('../drill').refuseBytes(res)) return;
++ const name = path.basename(String(req.params.id || ''));
++ const m = NAME_RE.exec(name);
++ if (!m) return res.status(404).end();
++ if (!await itemServable(m[1], Number(m[2]))) return res.status(404).end();
++ const file = path.join(PREVIEW_DIR, name);
++ let stat;
++ try { stat = fs.statSync(file); if (!stat.isFile()) throw new Error('not a file'); } catch { return res.status(404).end(); }
++ const type = CONTENT_TYPE[m[3]];
++ res.set({
++ 'Content-Type': type,
++ 'Accept-Ranges': 'bytes',
++ 'Cache-Control': 'public, max-age=604800, immutable',
++ 'Access-Control-Allow-Origin': '*',
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Robots-Tag': 'noindex',
++ });
++ const range = parseRange(req.headers.range, stat.size);
++ if (req.headers.range && !range) {
++ res.status(416).set('Content-Range', `bytes */${stat.size}`);
++ return res.end();
++ }
++ const start = range ? range.start : 0;
++ const end = range ? range.end : stat.size - 1;
++ res.status(range ? 206 : 200);
++ res.set('Content-Length', String(end - start + 1));
++ if (range) res.set('Content-Range', `bytes ${start}-${end}/${stat.size}`);
++ const stream = fs.createReadStream(file, { start, end });
++ const done = () => { try { stream.destroy(); } catch { /* */ } };
++ res.on('close', done); res.on('finish', done); res.on('error', done);
++ stream.on('error', () => { try { res.destroy(); } catch { /* */ } });
++ stream.pipe(res);
++}
++
++module.exports = {
++ PREVIEW_DIR, PREVIEW_WIDTH, PREVIEW_SECONDS, MAX_BYTES, NAME_RE,
++ previewUrl, previewNames, removePreviewFiles, parseRange, servePreview, itemServable,
++};
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex ec61900..214b0f1 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -140,6 +140,67 @@ async function nameWithColour(outPath, outFilename) {
try { fs.renameSync(outPath, path.join(THUMB_DIR, named)); return named; } catch { return outFilename; }
}
++/**
++ * The seek points to try for a picture, best first. A frame at second 0 (or 10% of a two-second recording) can be
++ * black, and the tail of a still-writing recording can be a partial frame: a failed or all-black grab moves on to the
++ * next point instead of leaving the item without a picture. (VOD 6292, 4.7 GB and healthy, failed three times at the
++ * 10% mark on 2026-11-10.) Exported for the tests.
++ */
++function frameSeekCandidates(duration, { liveEdge = false, lastPts = 0, seekSeconds = 0 } = {}) {
++ if (seekSeconds) return [Math.max(0, Number(seekSeconds) || 0.5)];
++ const dur = Number.isFinite(duration) && duration > 0 ? duration : (Number(lastPts) || 0);
++ let points;
++ if (liveEdge) {
++ if (dur > 0) points = [Math.max(0, dur - LIVE_EDGE_BACKOFF_SEC), Math.max(0, dur - 1), 0.5];
++ else points = [0.5, 0.2, 0];
++ } else if (dur > 20) {
++ // A few seconds in, not the very first frame: the start is often a fade-in or a black leader.
++ points = [Math.max(3, Math.min(dur * 0.1, dur - 1)), Math.max(3, Math.min(dur * 0.25, dur - 1)), 5, 1, 0.5];
++ } else if (dur >= 6) {
++ points = [Math.max(2, dur * 0.15), Math.max(1, dur * 0.5), 0.5];
++ } else if (dur >= 2) {
++ points = [Math.max(0.6, dur * 0.25), 0.5, Math.max(0.5, dur - 0.4)];
++ } else {
++ points = [0.4, 0.1, 0];
++ }
++ const seen = new Set();
++ return points
++ .map((p) => Math.round(Math.max(0, Math.min(dur > 0 ? dur - 0.05 : p, p)) * 1000) / 1000)
++ .filter((p) => { if (seen.has(p)) return false; seen.add(p); return true; });
++}
++
++/** The average colour's luminance, 0–255 (a black frame is under ~14; a fade-in or dark scene is not). */
++async function frameIsBlack(file) {
++ const hex = await colourOf(file);
++ if (!hex) return false;
++ const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16);
++ return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 14;
++}
++
++/** One ffmpeg grab of one frame; true when a usable file was written. */
++function grabFrame(videoPath, isUrl, seekTime, outPath) {
++ return new Promise((resolve) => {
++ const args = [
++ '-y',
++ '-ss', String(Math.max(0, seekTime)),
++ '-i', videoPath,
++ '-vframes', '1',
++ '-vf', `scale=${THUMB_WIDTH}:-1`,
++ '-q:v', String(THUMB_QUALITY),
++ outPath,
++ ];
++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
++ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, isUrl ? 35000 : 20000);
++ ff.on('close', (code) => {
++ clearTimeout(killTimer);
++ let ok = false;
++ try { ok = code === 0 && fs.statSync(outPath).size > 256; } catch { ok = false; }
++ resolve(ok);
++ });
++ ff.on('error', () => { clearTimeout(killTimer); resolve(false); });
++ });
++}
++
/**
* Extract a thumbnail frame from a video file using ffmpeg.
* @param {string} videoPath – Absolute path (or presigned URL) of the video
@@@ -164,8 +225,8 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
return resolve(null);
}
-- const outFilename = `${prefix}-${entityId}-${Date.now()}.jpg`;
-- const outPath = path.join(THUMB_DIR, outFilename);
++ const stamp = Date.now();
++ const outFilename = `${prefix}-${entityId}-${stamp}.jpg`;
// First, probe the duration
const probe = spawn('ffprobe', [
@@@ -175,62 +236,49 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
probe.stdout.on('data', (d) => (probeData += d));
const onProbeClose = async (probeCode) => {
-- let seekTime = opts.seekSeconds || 1;
--
-- if (!opts.seekSeconds) {
-- let duration = 0;
-- if (probeCode === 0) {
-- try {
-- const info = JSON.parse(probeData);
-- // parseFloat('N/A') is NaN — normalize so the checks below
-- // treat "no usable duration" as 0 rather than silently
-- // falling through to the default 1-second seek.
-- const d = parseFloat(info.format?.duration);
-- if (Number.isFinite(d)) duration = d;
-- } catch { /* */ }
-- }
--
-- // A still-recording file has no header duration; find the real
-- // tail position from the last video packet instead.
-- if (opts.liveEdge || duration <= 0) {
-- const lastPts = await _probeLastPts(videoPath);
-- if (lastPts > 0) duration = lastPts;
-- }
--
-- if (opts.liveEdge) {
-- // Newest frame that is safely on disk. Falls back to the
-- // start only when the tail could not be located at all.
-- seekTime = duration > 0 ? Math.max(0, duration - LIVE_EDGE_BACKOFF_SEC) : 0;
-- } else if (duration > 2) {
-- seekTime = Math.min(
-- duration * ((opts.seekPercent || 10) / 100),
-- duration - 0.5
-- );
-- }
++ let duration = 0;
++ let lastPts = 0;
++ if (!opts.seekSeconds && probeCode === 0) {
++ try {
++ const info = JSON.parse(probeData);
++ // parseFloat('N/A') is NaN — normalize so the checks below
++ // treat "no usable duration" as 0 rather than silently
++ // falling through to the default 1-second seek.
++ const d = parseFloat(info.format?.duration);
++ if (Number.isFinite(d) && d > 0) duration = d;
++ } catch { /* */ }
}
--
-- // Extract frame
-- const args = [
-- '-y',
-- '-ss', String(Math.max(0, seekTime)),
-- '-i', videoPath,
-- '-vframes', '1',
-- '-vf', `scale=${THUMB_WIDTH}:-1`,
-- '-q:v', String(THUMB_QUALITY),
-- outPath,
-- ];
--
-- const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
-- const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch {} }, isUrl ? 35000 : 20000);
-- ff.on('close', (code) => {
-- clearTimeout(killTimer);
-- if (code === 0 && fs.existsSync(outPath)) {
-- nameWithColour(outPath, outFilename).then((name) => resolve(`/t/${name}`), () => resolve(`/t/${outFilename}`));
-- } else {
-- resolve(null);
++ // A still-recording file has no header duration; find the real tail position from the last video packet.
++ if (!opts.seekSeconds && (opts.liveEdge || duration <= 0)) {
++ lastPts = await _probeLastPts(videoPath);
++ }
++ const candidates = frameSeekCandidates(duration, { liveEdge: !!opts.liveEdge, lastPts, seekSeconds: opts.seekSeconds });
++ let fallback = null; // the first frame that decoded at all, kept when every candidate is black
++ for (let i = 0; i < candidates.length; i++) {
++ const tryPath = candidates.length > 1 ? path.join(THUMB_DIR, `${prefix}-${entityId}-${stamp}-try${i}.jpg`) : path.join(THUMB_DIR, outFilename);
++ const made = await grabFrame(videoPath, isUrl, candidates[i], tryPath);
++ if (!made) continue;
++ if (!fallback) fallback = tryPath;
++ const black = await frameIsBlack(tryPath);
++ if (!black) {
++ if (fallback && fallback !== tryPath) { try { fs.unlinkSync(fallback); } catch { /* */ } }
++ if (tryPath !== path.join(THUMB_DIR, outFilename)) {
++ try { fs.renameSync(tryPath, path.join(THUMB_DIR, outFilename)); } catch { /* */ }
++ }
++ const name = await nameWithColour(path.join(THUMB_DIR, outFilename), outFilename);
++ return resolve(`/t/${name}`);
}
-- });
-- ff.on('error', () => { clearTimeout(killTimer); resolve(null); });
++ // A black frame is kept only as the last resort; a later point may still show the scene.
++ if (tryPath !== fallback) { try { fs.unlinkSync(tryPath); } catch { /* */ } }
++ }
++ // Every point was black (a night stream, a black screen) or failed: a dark picture beats no picture, but
++ // a black one that decoded is the last resort, and an item that never decoded has none.
++ if (fallback) {
++ try { fs.renameSync(fallback, path.join(THUMB_DIR, outFilename)); } catch { /* */ }
++ const name = await nameWithColour(path.join(THUMB_DIR, outFilename), outFilename);
++ return resolve(`/t/${name}`);
++ }
++ return resolve(null);
};
// A throw inside the handler (e.g. spawn failing) must resolve the request, not hang it.
probe.on('close', (probeCode) => { onProbeClose(probeCode).catch(() => resolve(null)); });
@@@ -499,5 +547,7 @@ module.exports = {
serveThumbnail,
cleanupOldThumbnails,
extractFrameToFile,
++ frameSeekCandidates,
++ frameIsBlack,
THUMB_DIR,
};
ddiff --git a/server/vod/clip-jobs.js b/server/vod/clip-jobs.js
iindex 85b33c0..1ce37c0 100644
---- a/server/vod/clip-jobs.js
++++ b/server/vod/clip-jobs.js
@@@ -115,6 +115,7 @@ async function _recutClip(clipId, { reason = 'recut', viaJob = false, job = null
// Thumbnail first (the clip.ready payload carries it), then the ready transition, the clip's
// object and its event in one transaction (announce).
try { await require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath); } catch { /* */ }
++ try { await require('../jobs/preview').queuePreview(clip.app_id, 'clip', clipId); } catch { /* */ }
await announce(clip.app_id, 'clip.ready', {
change: async () => {
await objects().withObject('clip', clipId, async () => await db.run(`UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = 'ready', cut_error = NULL, cut_next_at = NULL,
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 3a1ac84..4a764a6 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -98,6 +98,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
// Absolute URLs — cross-origin consumers render these directly.
playback_url: `${config.publicUrl}/c/${clip.id}`,
thumbnail_url: require('./finalize')._absUrl(clip.thumbnail_url),
++ preview_url: require('./finalize')._absUrl(clip.preview_url),
++ preview_webm_url: require('./finalize')._absUrl(clip.preview_webm_url),
// No picture in the source (an audio-only recording's clip): the player shows the avatar card, not a black box.
audio_only: !!clip.audio_only,
visibility: clip.visibility || 'public',
@@@ -422,6 +424,7 @@ async function _createUploadedClip(req, res) {
await registerClip(req, clipId, visibility);
require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
++ .then(() => require('../jobs/preview').queuePreview(req.appId, 'clip', clipId))
.catch(err => console.warn(`[Clips] Thumbnail failed for clip ${clipId}:`, err.message));
console.log(`[Clips] Direct upload: clip ${clipId} (${req.appId}, ${duration.toFixed ? duration.toFixed(1) : duration}s)`);
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex fc36aeb..c6dcff6 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -69,6 +69,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
// directly; relative paths resolved against the app's origin and broke.
playback_url: `${config.publicUrl}/v/${vod.id}`,
thumbnail_url: _absUrl(vod.thumbnail_url),
++ // The hover preview (preview.make): 6 s muted MP4, with the WebM of the same moment beside it. Null until made.
++ preview_url: _absUrl(vod.preview_url),
++ preview_webm_url: _absUrl(vod.preview_webm_url),
// No picture in the source (an audio-only browser broadcast, Media#240): one probe at finalize says so once.
audio_only: !!vod.audio_only,
storage_provider: vod.storage_provider || 'local',
@@@ -422,6 +425,8 @@ async function _doFinalize(vodId, opts) {
payload: async () => await vodPublic(await db.getVodById(vodId), { hls: false }),
});
const ready = await db.getVodById(vodId);
++ // The hover preview waits in the queue behind the row's own finalize; a preview is an extra, never a failure.
++ try { require('../jobs/preview').queuePreview(vod.app_id, 'vod', vodId).catch(() => {}); } catch { /* jobs off */ }
// Segment-native video (MEDIA_HLS_ENABLED): the finished recording's timeline is cut by object.cmaf. A queue error
// never fails the finalize; GET …/download?format=json queues it again on the first request.
try {
ddiff --git a/test/preview-hover.test.js b/test/preview-hover.test.js
nnew file mode 100644
iindex 0000000..5b3baac
---- /dev/null
++++ b/test/preview-hover.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++// Hover previews (plan 355): preview.make writes a 6 s, 320 px, muted MP4 H.264 and WebM beside the thumbnail,
++// under 400 KB, starting at a moment with motion; the backfill queues public items only, newest first; the seek
++// ladder moves past a black frame instead of leaving the item without a picture.
++// node --import ./test/helpers/pg-preload.mjs test/preview-hover.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-preview-hover-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ Object.assign(process.env, {
++ VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),
++ MEDIA_INVARIANT_SCAN_HOURS: '0', MEDIA_JOBS_ENABLED: '0', MEDIA_UPLOAD_MIN_FREE_MB: '0',
++ });
++
++ const db = require('../server/db/database');
++ const previewJob = require('../server/jobs/preview');
++ const thumbService = require('../server/thumbnails/thumbnail-service');
++ const previewService = require('../server/thumbnails/preview-service');
++ const hasFfmpeg = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;
++
++ // ── The seek ladder and the range parser, no ffmpeg needed ──
++ assert.deepStrictEqual(thumbService.frameSeekCandidates(0.8), [0.4, 0.1, 0]);
++ assert.strictEqual(thumbService.frameSeekCandidates(60)[0], 6, 'a minute-long item is grabbed at 10%, not second 0');
++ assert.ok(thumbService.frameSeekCandidates(2)[0] >= 0.5, 'a two-second item is not grabbed at 0');
++ assert.deepStrictEqual(thumbService.frameSeekCandidates(0, { liveEdge: true, lastPts: 42 }), [39, 41, 0.5]);
++ assert.strictEqual(thumbService.frameSeekCandidates(60).length, new Set(thumbService.frameSeekCandidates(60)).size, 'no duplicate seeks');
++ assert.deepStrictEqual(previewService.parseRange('bytes=0-99', 1000), { start: 0, end: 99 });
++ assert.deepStrictEqual(previewService.parseRange('bytes=-100', 1000), { start: 900, end: 999 });
++ assert.strictEqual(previewService.parseRange('bytes=1000-', 1000), null);
++ assert.deepStrictEqual(previewJob.pickWindow(60), { len: 6, anchor: 6 });
++ assert.deepStrictEqual(previewJob.pickWindow(3), { len: 3, anchor: 0 });
++
++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++ const VIDEO = path.join(process.env.VOD_PATH, 'bright.mp4');
++ const LEADER = path.join(process.env.VOD_PATH, 'black-leader.mp4');
++ if (hasFfmpeg) {
++ assert.strictEqual(spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=duration=8:size=320x180:rate=15',
++ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', VIDEO], { stdio: 'ignore' }).status, 0);
++ // Two seconds of black, then six seconds of moving test pattern: the first seek candidate is black.
++ assert.strictEqual(spawnSync('ffmpeg', ['-v', 'error', '-y',
++ '-f', 'lavfi', '-i', 'color=c=black:s=320x180:d=2:r=15',
++ '-f', 'lavfi', '-i', 'testsrc=duration=6:size=320x180:rate=15',
++ '-filter_complex', '[0:v][1:v]concat=n=2:v=1:a=0[v]', '-map', '[v]',
++ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', LEADER], { stdio: 'ignore' }).status, 0);
++ }
++
++ async function insertVod({ title, file = VIDEO, thumb = '/t/vod-present-1.jpg', audio = false, isPublic = true, created_at = new Date(Date.now() - 3600 * 1000).toISOString(), duration = 8 }) {
++ const id = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, duration_seconds, thumbnail_url, health_status,
++ is_recording, is_public, visibility, audio_only, created_at)
++ VALUES ('live', ?, ?, ?, ?, ?, 'ok', 0, ?, ?, ?, ?) RETURNING id`,
++ [title, file, file && fs.existsSync(file) ? fs.statSync(file).size : 0, duration, thumb,
++ isPublic ? 1 : 0, isPublic ? 'public' : 'private', !!audio, created_at])).lastInsertRowid);
++ return id;
++ }
++ async function insertClip({ title, isPublic = true, created_at = new Date(Date.now() - 3600 * 1000).toISOString(), vodId = null }) {
++ return Number((await db.run(`INSERT INTO clips (app_id, title, vod_id, file_path, duration_seconds, thumbnail_url, status, is_public, visibility, created_at)
++ VALUES ('live', ?, ?, NULL, 8, '/t/clip-present-1.jpg', 'ready', ?, ?, ?) RETURNING id`,
++ [title, vodId, isPublic ? 1 : 0, isPublic ? 'public' : 'private', created_at])).lastInsertRowid);
++ }
++ fs.writeFileSync(path.join(thumbService.THUMB_DIR, 'vod-present-1.jpg'), Buffer.from('a file, not a real jpeg'));
++ fs.writeFileSync(path.join(thumbService.THUMB_DIR, 'clip-present-1.jpg'), Buffer.from('a file, not a real jpeg'));
++
++ const old = await insertVod({ title: 'old', created_at: new Date(Date.now() - 30 * 86400000).toISOString() });
++ const recent = await insertVod({ title: 'recent' });
++ const audioOnly = await insertVod({ title: 'audio', audio: true });
++ const privateVod = await insertVod({ title: 'private', isPublic: false });
++ const clip = await insertClip({ title: 'recent clip', vodId: recent });
++ await insertClip({ title: 'private clip', isPublic: false });
++
++ // ── The backfill: public, non-audio, picture first, preview missing, newest first ──
++ const swept = await previewJob.sweepMissing({ appId: 'live', now: Date.now(), limit: 10 });
++ const queued = (await db.all("SELECT params FROM media_jobs WHERE app_id = 'live' AND job_type = 'preview.make' ORDER BY id"))
++ .map((r) => JSON.parse(r.params).id).sort((a, b) => a - b);
++ assert.deepStrictEqual(swept, { missing: 3, queued: 3 }, `recent vod, old vod and the clip (saw ${JSON.stringify(swept)})`);
++ assert.deepStrictEqual(queued, [old, recent, clip].sort((a, b) => a - b), 'audio-only and private items were skipped');
++
++ if (!hasFfmpeg) { console.log('preview-hover: encode checks skipped (ffmpeg not found)'); return; }
++
++ // ── The job makes both formats, 320 px wide, muted, under 400 KB, and points the row at them ──
++ const out = await previewJob.spec.run({ app_id: 'live', params: { kind: 'vod', id: recent } }, {});
++ assert.match(out.preview_url, /^\/p\/vod-\d+-\d+\.mp4$/);
++ assert.match(out.preview_webm_url, /^\/p\/vod-\d+-\d+\.webm$/);
++ assert.ok(out.bytes > 0 && out.bytes <= previewService.MAX_BYTES, `mp4 ${out.bytes} bytes`);
++ assert.ok(out.webm_bytes > 0 && out.webm_bytes <= previewService.MAX_BYTES, `webm ${out.webm_bytes} bytes`);
++ assert.ok(out.start_seconds >= 0 && out.start_seconds + out.seconds <= 8.1, 'the window is inside the source');
++ for (const url of [out.preview_url, out.preview_webm_url]) {
++ const file = path.join(previewService.PREVIEW_DIR, path.basename(url));
++ assert.ok(fs.existsSync(file), `${url} is on disk`);
++ const probe = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'stream=width,height,codec_type', '-of', 'json', file], { encoding: 'utf8' });
++ const streams = JSON.parse(probe.stdout).streams;
++ assert.ok(streams.every((s) => s.codec_type === 'video'), 'no audio track (muted)');
++ assert.strictEqual(streams[0].width, 320, '320 px wide');
++ const dur = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' });
++ assert.ok(Number(dur.stdout) <= 6.2, `about 6 s (${dur.stdout.trim()})`);
++ }
++ const row = await db.get('SELECT preview_url, preview_webm_url FROM vods WHERE id = ?', [recent]);
++ assert.strictEqual(row.preview_url, out.preview_url, 'the row names the MP4');
++ assert.strictEqual(row.preview_webm_url, out.preview_webm_url, 'and the WebM');
++
++ // A second run replaces the pair and removes the old files.
++ const again = await previewJob.spec.run({ app_id: 'live', params: { kind: 'vod', id: recent } }, {});
++ assert.notStrictEqual(again.preview_url, out.preview_url, 'a new picture gets new names');
++ assert.ok(!fs.existsSync(path.join(previewService.PREVIEW_DIR, path.basename(out.preview_url))), 'the old pair is removed');
++
++ // ── A black first frame is skipped: the picture comes from later in the file ──
++ const leader = await insertVod({ title: 'black leader', file: LEADER, duration: 8 });
++ const made = await thumbService.generateVodThumbnail(leader, LEADER);
++ assert.match(made, /^\/t\/vod-\d+-\d+-[0-9a-f]{6}\.jpg$/);
++ assert.strictEqual(await thumbService.frameIsBlack(path.join(thumbService.THUMB_DIR, path.basename(made))), false,
++ 'the thumbnail is not the black leader');
++
++ console.log('preview-hover: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });