openvibers/openvibe.media Commits
Thumbnails: a virtual clip takes its picture from its VOD at the clip's start (clips 450, 453-456 failed generate_failed on every page view; a clip whose VOD is gone is media_unavailable, 404) (#136)
Changes
@@@ -7,6 +7,8 @@** params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)* result { url, kind, id }++ * A virtual clip (clip-jobs makeVirtual: a window over its VOD's timeline, no file of its own) takes its++ * picture from its VOD, half a second after the clip's start.* Failures: media_unavailable (no local file and no cloud copy, an empty file, or a recording the* health scan found zero-byte / missing; permanent, answered 404 by the v1 route), no_video (an* audio-only recording: no frame exists; permanent, 404), not_found (permanent), generate_failed@@@ -49,12 +51,17 @@ async function run(job) {const thumbService = require('../thumbnails/thumbnail-service');// A recording the health scan found empty or gone has no frame to take (five such VODs failed as// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).-- if (kind === 'vod' && ['zero_byte', 'missing_file'].includes(row.health_status)) {-- throw new JobError('media_unavailable', `The recording is ${row.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });++ // A virtual clip has no file: its frame comes from its VOD (five such clips failed every page view as++ // generate_failed on 2026-10-17: ffmpeg was pointed at the clip row's own path, which never existed).++ const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;++ const recording = virtual ? await db.getVodById(row.vod_id, job.app_id) : (kind === 'vod' ? row : null);++ if (virtual && !recording) throw new JobError('media_unavailable', `The clip's recording (vod ${row.vod_id}) is gone`, { permanent: true, status: 404 });++ if (recording && ['zero_byte', 'missing_file'].includes(recording.health_status)) {++ throw new JobError('media_unavailable', `The recording is ${recording.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });}let source = null;if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { kind: 'file', value: row.file_path };-- else source = await require('../vod/vod-storage').resolveMediaSource(row);++ else source = await require('../vod/vod-storage').resolveMediaSource(virtual ? recording : row);if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });if (source.kind === 'file') {let size = 0;@@@ -66,7 +73,7 @@ async function run(job) {}const url = kind === 'vod'? await thumbService.generateVodThumbnail(id, source.value)-- : await thumbService.generateClipThumbnail(id, source.value);++ : await thumbService.generateClipThumbnail(id, source.value, virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {});if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');return { url, kind, id };}@@@ -224,8 +224,9 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {}// ── Generate Clip Thumbnail (first frames) ───────────────────--async function generateClipThumbnail(clipId, filePath) {-- const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: 0.5 });++// opts.seekSeconds: where the clip starts in filePath (a virtual clip's picture comes from its VOD).++async function generateClipThumbnail(clipId, filePath, opts = {}) {++ const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: opts.seekSeconds || 0.5 });if (thumbUrl) {const old = (await db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId]))?.thumbnail_url;await db.withObject('clip', clipId, async () => await db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]));nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// thumbnail.regenerate on a virtual clip (clip-jobs makeVirtual: a window over its VOD's timeline, no file of++// its own). Five such clips (450, 453-456) failed as generate_failed on every page view on 2026-10-17 and Live++// passed the 500 to each visitor's console. The clip's picture now comes from its VOD, half a second after the++// clip starts; a virtual clip whose VOD is gone is media_unavailable (404), not a generate failure.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { spawnSync } = require('child_process');++const express = require('express');++++(async () => {++ if (spawnSync('ffmpeg', ['-version']).status !== 0) { console.log('skipped (ffmpeg not found)'); process.exit(0); }++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-thumbvirtual-'));++ 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',++ });++++ const db = require('../server/db/database');++ const queue = require('../server/jobs/queue');++ await db.upsertApp({ app_id: 'live', api_key: 'live-key-thumbvirtual' });++++ const vodFile = path.join(process.env.VOD_PATH, 'vod-live-1.mp4');++ const mk = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10:duration=6', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', vodFile]);++ assert.strictEqual(mk.status, 0, String(mk.stderr));++ const vod = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)++ VALUES ('live', 'the stream', ?, ?, 1, 'public', 'ok') RETURNING id`, [vodFile, fs.statSync(vodFile).size])).lastInsertRowid);++ const insClip = async (vodId) => Number((await db.run(`INSERT INTO clips (app_id, vod_id, title, file_path, storage_provider, start_time, end_time,++ duration_seconds, is_public, visibility, status) VALUES ('live', ?, 'a moment', NULL, 'timeline', 3, 5, 2, 1, 'public', 'ready') RETURNING id`, [vodId])).lastInsertRowid);++ const virtual = await insClip(vod);++ const orphan = await insClip(vod + 1000); // its VOD is gone++++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));++ const server = app.listen(0, '127.0.0.1');++ const post = (p) => new Promise((resolve, reject) => {++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method: 'POST', headers: { Authorization: 'Bearer live-key-thumbvirtual', 'Content-Length': 0 } },++ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b || '{}') })); });++ req.on('error', reject);++ req.end();++ });++++ (async () => {++ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));++ const ok = await post(`/api/v1/live/thumbnails/clip/${virtual}`);++ assert.strictEqual(ok.status, 200, JSON.stringify(ok.body));++ const row = await db.getClipById(virtual);++ assert.ok(row.thumbnail_url && /^\/t\/clip-/.test(row.thumbnail_url), `the clip has its picture: ${row.thumbnail_url}`);++ assert.ok(fs.existsSync(path.join(process.env.THUMBNAILS_PATH, path.basename(row.thumbnail_url))), 'the frame is on disk');++ assert.strictEqual(row.file_path, null, 'the clip stays virtual');++++ const gone = await post(`/api/v1/live/thumbnails/clip/${orphan}`);++ assert.strictEqual(gone.status, 404, JSON.stringify(gone.body));++ const job = await queue.get(gone.body.job_id);++ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1]);++ assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'no generate failure');++ server.close();++ await db.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('✅ thumbnail.regenerate on a virtual clip: a frame from its VOD at the clip start; VOD gone: media_unavailable (404)');++ process.exit(0);++ })().catch((err) => { console.error(err); server.close(); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
*
*
* params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)
* params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)
* result { url, kind, id }
* result { url, kind, id }
+ * A virtual clip (clip-jobs makeVirtual: a window over its VOD's timeline, no file of its own) takes its
+ * picture from its VOD, half a second after the clip's start.
* Failures: media_unavailable (no local file and no cloud copy, an empty file, or a recording the
* Failures: media_unavailable (no local file and no cloud copy, an empty file, or a recording the
* health scan found zero-byte / missing; permanent, answered 404 by the v1 route), no_video (an
* health scan found zero-byte / missing; permanent, answered 404 by the v1 route), no_video (an
* audio-only recording: no frame exists; permanent, 404), not_found (permanent), generate_failed
* audio-only recording: no frame exists; permanent, 404), not_found (permanent), generate_failed
@@ -49,12 +51,17 @@ async function run(job) {
@@ -49,12 +51,17 @@ async function run(job) {
const thumbService = require('../thumbnails/thumbnail-service');
const thumbService = require('../thumbnails/thumbnail-service');
// A recording the health scan found empty or gone has no frame to take (five such VODs failed as
// A recording the health scan found empty or gone has no frame to take (five such VODs failed as
// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).
// generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).
- if (kind === 'vod' && ['zero_byte', 'missing_file'].includes(row.health_status)) {
- throw new JobError('media_unavailable', `The recording is ${row.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });
+ // A virtual clip has no file: its frame comes from its VOD (five such clips failed every page view as
+ // generate_failed on 2026-10-17: ffmpeg was pointed at the clip row's own path, which never existed).
+ const virtual = kind === 'clip' && !row.file_path && row.vod_id != null;
+ const recording = virtual ? await db.getVodById(row.vod_id, job.app_id) : (kind === 'vod' ? row : null);
+ if (virtual && !recording) throw new JobError('media_unavailable', `The clip's recording (vod ${row.vod_id}) is gone`, { permanent: true, status: 404 });
+ if (recording && ['zero_byte', 'missing_file'].includes(recording.health_status)) {
+ throw new JobError('media_unavailable', `The recording is ${recording.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });
}
}
let source = null;
let source = null;
if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { kind: 'file', value: row.file_path };
if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { kind: 'file', value: row.file_path };
- else source = await require('../vod/vod-storage').resolveMediaSource(row);
+ else source = await require('../vod/vod-storage').resolveMediaSource(virtual ? recording : row);
if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });
if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });
if (source.kind === 'file') {
if (source.kind === 'file') {
let size = 0;
let size = 0;
@@ -66,7 +73,7 @@ async function run(job) {
@@ -66,7 +73,7 @@ async function run(job) {
}
}
const url = kind === 'vod'
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
? await thumbService.generateVodThumbnail(id, source.value)
- : await thumbService.generateClipThumbnail(id, source.value);
+ : await thumbService.generateClipThumbnail(id, source.value, virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {});
if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');
if (!url) throw new JobError('generate_failed', 'Failed to generate thumbnail');
return { url, kind, id };
return { url, kind, id };
}
}
@@ -224,8 +224,9 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {
@@ -224,8 +224,9 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {
}
}
// ── Generate Clip Thumbnail (first frames) ───────────────────
// ── Generate Clip Thumbnail (first frames) ───────────────────
-async function generateClipThumbnail(clipId, filePath) {
- const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: 0.5 });
+// opts.seekSeconds: where the clip starts in filePath (a virtual clip's picture comes from its VOD).
+async function generateClipThumbnail(clipId, filePath, opts = {}) {
+ const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: opts.seekSeconds || 0.5 });
if (thumbUrl) {
if (thumbUrl) {
const old = (await db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId]))?.thumbnail_url;
const old = (await db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId]))?.thumbnail_url;
await db.withObject('clip', clipId, async () => await db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]));
await db.withObject('clip', clipId, async () => await db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]));
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// thumbnail.regenerate on a virtual clip (clip-jobs makeVirtual: a window over its VOD's timeline, no file of
+// its own). Five such clips (450, 453-456) failed as generate_failed on every page view on 2026-10-17 and Live
+// passed the 500 to each visitor's console. The clip's picture now comes from its VOD, half a second after the
+// clip starts; a virtual clip whose VOD is gone is media_unavailable (404), not a generate failure.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { spawnSync } = require('child_process');
+const express = require('express');
+
+(async () => {
+ if (spawnSync('ffmpeg', ['-version']).status !== 0) { console.log('skipped (ffmpeg not found)'); process.exit(0); }
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-thumbvirtual-'));
+ 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',
+ });
+
+ const db = require('../server/db/database');
+ const queue = require('../server/jobs/queue');
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key-thumbvirtual' });
+
+ const vodFile = path.join(process.env.VOD_PATH, 'vod-live-1.mp4');
+ const mk = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10:duration=6', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', vodFile]);
+ assert.strictEqual(mk.status, 0, String(mk.stderr));
+ const vod = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)
+ VALUES ('live', 'the stream', ?, ?, 1, 'public', 'ok') RETURNING id`, [vodFile, fs.statSync(vodFile).size])).lastInsertRowid);
+ const insClip = async (vodId) => Number((await db.run(`INSERT INTO clips (app_id, vod_id, title, file_path, storage_provider, start_time, end_time,
+ duration_seconds, is_public, visibility, status) VALUES ('live', ?, 'a moment', NULL, 'timeline', 3, 5, 2, 1, 'public', 'ready') RETURNING id`, [vodId])).lastInsertRowid);
+ const virtual = await insClip(vod);
+ const orphan = await insClip(vod + 1000); // its VOD is gone
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));
+ const server = app.listen(0, '127.0.0.1');
+ const post = (p) => new Promise((resolve, reject) => {
+ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method: 'POST', headers: { Authorization: 'Bearer live-key-thumbvirtual', 'Content-Length': 0 } },
+ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b || '{}') })); });
+ req.on('error', reject);
+ req.end();
+ });
+
+ (async () => {
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
+ const ok = await post(`/api/v1/live/thumbnails/clip/${virtual}`);
+ assert.strictEqual(ok.status, 200, JSON.stringify(ok.body));
+ const row = await db.getClipById(virtual);
+ assert.ok(row.thumbnail_url && /^\/t\/clip-/.test(row.thumbnail_url), `the clip has its picture: ${row.thumbnail_url}`);
+ assert.ok(fs.existsSync(path.join(process.env.THUMBNAILS_PATH, path.basename(row.thumbnail_url))), 'the frame is on disk');
+ assert.strictEqual(row.file_path, null, 'the clip stays virtual');
+
+ const gone = await post(`/api/v1/live/thumbnails/clip/${orphan}`);
+ assert.strictEqual(gone.status, 404, JSON.stringify(gone.body));
+ const job = await queue.get(gone.body.job_id);
+ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1]);
+ assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'no generate failure');
+ server.close();
+ await db.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('✅ thumbnail.regenerate on a virtual clip: a frame from its VOD at the clip start; VOD gone: media_unavailable (404)');
+ process.exit(0);
+ })().catch((err) => { console.error(err); server.close(); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });