openvibers/openvibe.media Commits
Thumbnails that survive a bad frame, and 6 s hover previews (355) (#410)
Changes
nnew file mode 100644@@@ -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";@@@ -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},nnew file mode 100644@@@ -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,++};@@@ -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) = 0AND (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 };}@@@ -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);@@@ -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();@@@ -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;nnew file mode 100644@@@ -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,++};@@@ -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 durationconst 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,};@@@ -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,@@@ -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)`);@@@ -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 {nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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";
@@ -149,6 +149,10 @@ const config = {
@@ -149,6 +149,10 @@ const config = {
// picture is missing or gone, so the home's replay rows stop showing a grey 1×1.
// picture is missing or gone, so the home's replay rows stop showing a grey 1×1.
thumbnailBackfillMin: intEnv('MEDIA_THUMBNAIL_BACKFILL_MIN', 2),
thumbnailBackfillMin: intEnv('MEDIA_THUMBNAIL_BACKFILL_MIN', 2),
thumbnailBackfillBatch: Math.max(1, Math.min(20, intEnv('MEDIA_THUMBNAIL_BACKFILL_BATCH', 10))),
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
retentionDays: intEnv('MEDIA_JOBS_RETENTION_DAYS', 30), // finished thumbnail jobs are pruned after this
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,277 @@
@@ -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,
+};
@@ -158,11 +158,11 @@ function hasPicture(row) {
@@ -158,11 +158,11 @@ function hasPicture(row) {
try { return fs.existsSync(path.join(require('../thumbnails/thumbnail-service').THUMB_DIR, name)); } catch { return false; }
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
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. */
/** The earliest moment the next attempt may run; Infinity while one is queued or running. */
@@ -185,46 +185,63 @@ function dueAt(last) {
@@ -185,46 +185,63 @@ function dueAt(last) {
async function sweepMissing({ now = Date.now(), limit = null, appId = null, minAgeMs = MIN_AGE_MS } = {}) {
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 batch = Math.max(1, Math.min(20, Number(limit) || config.jobs.thumbnailBackfillBatch || 3));
const apps = appId ? [{ app_id: appId }]
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;
let missing = 0, queued = 0;
for (const { app_id: app } of apps) {
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 COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0
AND (file_path IS NOT NULL OR object_id IS NOT NULL)
AND (file_path IS NOT NULL OR object_id IS NOT NULL)
AND COALESCE(health_status, '') NOT IN ('missing_file', 'zero_byte')
AND COALESCE(health_status, '') NOT IN ('missing_file', 'zero_byte')
AND id < ?
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]);
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 };
return { missing, queued };
}
}
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* object.remux heavy a stream-copy remux of a vod/clip as a new private object
* 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.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
* 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.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)
* 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)
* 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);
@@ -27,6 +28,7 @@ queue.register('object.split', require('./derive').split);
queue.register('object.remux', require('./derive').remux);
queue.register('object.remux', require('./derive').remux);
queue.register('object.waveform', require('./previews').waveform);
queue.register('object.waveform', require('./previews').waveform);
queue.register('object.sprite', require('./previews').sprite);
queue.register('object.sprite', require('./previews').sprite);
+queue.register('preview.make', require('./preview').spec);
queue.register('object.cmaf', require('./cmaf').spec);
queue.register('object.cmaf', require('./cmaf').spec);
queue.register('object.pack', require('./pack').spec);
queue.register('object.pack', require('./pack').spec);
queue.register('object.startup-tier', require('./startup-tier').spec);
queue.register('object.startup-tier', require('./startup-tier').spec);
@@ -49,6 +49,7 @@ async function sweepWorkDirs() {
@@ -49,6 +49,7 @@ async function sweepWorkDirs() {
}
}
let lastPruneAt = 0;
let lastPruneAt = 0;
let lastThumbBackfillAt = 0;
let lastThumbBackfillAt = 0;
+let lastPreviewBackfillAt = 0;
let kicked = false;
let kicked = false;
function lanes() {
function lanes() {
@@ -183,6 +184,13 @@ async function tick() {
@@ -183,6 +184,13 @@ async function tick() {
try { await require('./thumbnail').sweepMissing({ now, limit: cfg().thumbnailBackfillBatch }); }
try { await require('./thumbnail').sweepMissing({ now, limit: cfg().thumbnailBackfillBatch }); }
catch (err) { console.warn('[Jobs] thumbnail backfill:', err.message); }
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) {
if (now - lastSweepAt > 3600 * 1000) {
lastSweepAt = now;
lastSweepAt = now;
await sweepWorkDirs();
await sweepWorkDirs();
@@ -496,6 +496,14 @@ router.get('/og-image.png', (req, res) => {
@@ -496,6 +496,14 @@ router.get('/og-image.png', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'static', 'og-image.png'));
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 ───────────────────────────────────────────────
// ── Thumbnails ───────────────────────────────────────────────
router.get('/t/:id', sharingLib.attachViewer, async (req, res) => {
router.get('/t/:id', sharingLib.attachViewer, async (req, res) => {
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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,
+};
@@ -140,6 +140,67 @@ async function nameWithColour(outPath, outFilename) {
@@ -140,6 +140,67 @@ async function nameWithColour(outPath, outFilename) {
try { fs.renameSync(outPath, path.join(THUMB_DIR, named)); return named; } catch { return 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.
* Extract a thumbnail frame from a video file using ffmpeg.
* @param {string} videoPath – Absolute path (or presigned URL) of the video
* @param {string} videoPath – Absolute path (or presigned URL) of the video
@@ -164,8 +225,8 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
@@ -164,8 +225,8 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
return resolve(null);
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
// First, probe the duration
const probe = spawn('ffprobe', [
const probe = spawn('ffprobe', [
@@ -175,62 +236,49 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
@@ -175,62 +236,49 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
probe.stdout.on('data', (d) => (probeData += d));
probe.stdout.on('data', (d) => (probeData += d));
const onProbeClose = async (probeCode) => {
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.
// 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)); });
probe.on('close', (probeCode) => { onProbeClose(probeCode).catch(() => resolve(null)); });
@@ -499,5 +547,7 @@ module.exports = {
@@ -499,5 +547,7 @@ module.exports = {
serveThumbnail,
serveThumbnail,
cleanupOldThumbnails,
cleanupOldThumbnails,
extractFrameToFile,
extractFrameToFile,
+ frameSeekCandidates,
+ frameIsBlack,
THUMB_DIR,
THUMB_DIR,
};
};
@@ -115,6 +115,7 @@ async function _recutClip(clipId, { reason = 'recut', viaJob = false, job = null
@@ -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
// Thumbnail first (the clip.ready payload carries it), then the ready transition, the clip's
// object and its event in one transaction (announce).
// object and its event in one transaction (announce).
try { await require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath); } catch { /* */ }
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', {
await announce(clip.app_id, 'clip.ready', {
change: async () => {
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,
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,
@@ -98,6 +98,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -98,6 +98,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
// Absolute URLs — cross-origin consumers render these directly.
// Absolute URLs — cross-origin consumers render these directly.
playback_url: `${config.publicUrl}/c/${clip.id}`,
playback_url: `${config.publicUrl}/c/${clip.id}`,
thumbnail_url: require('./finalize')._absUrl(clip.thumbnail_url),
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.
// 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,
audio_only: !!clip.audio_only,
visibility: clip.visibility || 'public',
visibility: clip.visibility || 'public',
@@ -422,6 +424,7 @@ async function _createUploadedClip(req, res) {
@@ -422,6 +424,7 @@ async function _createUploadedClip(req, res) {
await registerClip(req, clipId, visibility);
await registerClip(req, clipId, visibility);
require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
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));
.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)`);
console.log(`[Clips] Direct upload: clip ${clipId} (${req.appId}, ${duration.toFixed ? duration.toFixed(1) : duration}s)`);
@@ -69,6 +69,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
@@ -69,6 +69,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
// directly; relative paths resolved against the app's origin and broke.
// directly; relative paths resolved against the app's origin and broke.
playback_url: `${config.publicUrl}/v/${vod.id}`,
playback_url: `${config.publicUrl}/v/${vod.id}`,
thumbnail_url: _absUrl(vod.thumbnail_url),
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.
// No picture in the source (an audio-only browser broadcast, Media#240): one probe at finalize says so once.
audio_only: !!vod.audio_only,
audio_only: !!vod.audio_only,
storage_provider: vod.storage_provider || 'local',
storage_provider: vod.storage_provider || 'local',
@@ -422,6 +425,8 @@ async function _doFinalize(vodId, opts) {
@@ -422,6 +425,8 @@ async function _doFinalize(vodId, opts) {
payload: async () => await vodPublic(await db.getVodById(vodId), { hls: false }),
payload: async () => await vodPublic(await db.getVodById(vodId), { hls: false }),
});
});
const ready = await db.getVodById(vodId);
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
// 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.
// never fails the finalize; GET …/download?format=json queues it again on the first request.
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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); });