openvibers/openvibe.media Commits
Media: an audio-only recording has no thumbnail (no_video, 404), not a 500 (#93)
Changes
@@@ -8,8 +8,9 @@* params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)* result { url, kind, id }* 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), not_found-- * (permanent), generate_failed (ffmpeg produced no frame from real media; retried).++ * 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++ * (ffmpeg produced no frame from real media; retried).*/'use strict';@@@ -60,6 +61,9 @@ async function run(job) {try { size = require('fs').statSync(source.value).size; } catch { size = 0; }if (!size) throw new JobError('media_unavailable', 'The media file is empty (0 bytes)', { permanent: true, status: 404 });}++ if (await thumbService.hasVideoStream(source.value) === false) {++ throw new JobError('no_video', 'The recording has no video (audio only), so there is no frame to take', { permanent: true, status: 404 });++ }const url = kind === 'vod'? await thumbService.generateVodThumbnail(id, source.value): await thumbService.generateClipThumbnail(id, source.value);@@@ -100,6 +100,7 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), uploadif (!done || done.status === 'queued' || done.status === 'running') return res.status(202).json({ job: queue.jobPublic(done || r.job) });if (done.status === 'succeeded') return res.json({ url: queue.parseJson(done.result, {}).url, job_id: done.id });if (done.error_code === 'media_unavailable') return res.status(404).json({ error: 'Media file unavailable', job_id: done.id });++ if (done.error_code === 'no_video') return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video', job_id: done.id });res.status(500).json({ error: 'Failed to generate thumbnail', job_id: done.id });} catch (err) {if (err && err.code === 'media.job.too_many') {@@@ -84,6 +84,25 @@ function _probeLastPts(videoPath) {});}++/**++ * Whether a file (or presigned URL) has a video stream: true, false, or null when ffprobe could not tell.++ * An audio-only recording (a browser broadcast with no camera) has no frame to take; asking ffmpeg for one++ * fails every time, so the job refuses it as permanent instead (two such VODs answered 500 on 2026-10-12).++ */++function hasVideoStream(videoPath) {++ return new Promise((resolve) => {++ let out = '';++ let p;++ try {++ p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v', '-show_entries', 'stream=index', '-of', 'csv=p=0', videoPath]);++ } catch { return resolve(null); }++ const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);++ p.stdout.on('data', (d) => { out += d; });++ p.on('close', (code) => { clearTimeout(to); resolve(code === 0 ? String(out).trim() !== '' : null); });++ p.on('error', () => { clearTimeout(to); resolve(null); });++ });++}++/*** Extract a thumbnail frame from a video file using ffmpeg.* @param {string} videoPath – Absolute path (or presigned URL) of the video@@@ -371,6 +390,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {module.exports = {generateFromVideo,++ hasVideoStream,generateVodThumbnail,generateClipThumbnail,saveLiveThumbnail,@@@ -54,12 +54,25 @@ const express = require('express');assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1], `vod ${id}`);assert.ok(Date.now() - t0 < 5000, 'refused before any ffmpeg run');}++ // An audio-only recording (a browser broadcast with no camera; VODs 5718 and 5721 on 2026-10-12): no_video, 404.++ if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {++ const audio = path.join(V, 'audio-only.webm');++ const mk = require('child_process').spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:a', 'libopus', audio]);++ assert.strictEqual(mk.status, 0, String(mk.stderr));++ const id = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)++ VALUES ('live', 't', ?, ?, 1, 'public', 'ok') RETURNING id`, [audio, fs.statSync(audio).size])).lastInsertRowid);++ const r = await post(`/api/v1/live/thumbnails/vod/${id}`);++ assert.strictEqual(r.status, 404, JSON.stringify(r.body));++ assert.strictEqual(r.body.code, 'media.no_video');++ const job = await queue.get(r.body.job_id);++ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);++ } else console.log('audio-only case skipped (ffmpeg not found)');assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');server.close();await db.close();fs.rmSync(tmp, { recursive: true, force: true });-- console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once, 404 from the v1 route');++ console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once; on an audio-only one: no_video; 404 from the v1 route');process.exit(0);})().catch((err) => { console.error(err); server.close(); process.exit(1); });})().catch((err) => { console.error(err); process.exit(1); });
@@ -8,8 +8,9 @@
@@ -8,8 +8,9 @@
* 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 }
* 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), not_found
- * (permanent), generate_failed (ffmpeg produced no frame from real media; retried).
+ * 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
+ * (ffmpeg produced no frame from real media; retried).
*/
*/
'use strict';
'use strict';
@@ -60,6 +61,9 @@ async function run(job) {
@@ -60,6 +61,9 @@ async function run(job) {
try { size = require('fs').statSync(source.value).size; } catch { size = 0; }
try { size = require('fs').statSync(source.value).size; } catch { size = 0; }
if (!size) throw new JobError('media_unavailable', 'The media file is empty (0 bytes)', { permanent: true, status: 404 });
if (!size) throw new JobError('media_unavailable', 'The media file is empty (0 bytes)', { permanent: true, status: 404 });
}
}
+ if (await thumbService.hasVideoStream(source.value) === false) {
+ throw new JobError('no_video', 'The recording has no video (audio only), so there is no frame to take', { permanent: true, status: 404 });
+ }
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);
@@ -100,6 +100,7 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
@@ -100,6 +100,7 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
if (!done || done.status === 'queued' || done.status === 'running') return res.status(202).json({ job: queue.jobPublic(done || r.job) });
if (!done || done.status === 'queued' || done.status === 'running') return res.status(202).json({ job: queue.jobPublic(done || r.job) });
if (done.status === 'succeeded') return res.json({ url: queue.parseJson(done.result, {}).url, job_id: done.id });
if (done.status === 'succeeded') return res.json({ url: queue.parseJson(done.result, {}).url, job_id: done.id });
if (done.error_code === 'media_unavailable') return res.status(404).json({ error: 'Media file unavailable', job_id: done.id });
if (done.error_code === 'media_unavailable') return res.status(404).json({ error: 'Media file unavailable', job_id: done.id });
+ if (done.error_code === 'no_video') return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video', job_id: done.id });
res.status(500).json({ error: 'Failed to generate thumbnail', job_id: done.id });
res.status(500).json({ error: 'Failed to generate thumbnail', job_id: done.id });
} catch (err) {
} catch (err) {
if (err && err.code === 'media.job.too_many') {
if (err && err.code === 'media.job.too_many') {
@@ -84,6 +84,25 @@ function _probeLastPts(videoPath) {
@@ -84,6 +84,25 @@ function _probeLastPts(videoPath) {
});
});
}
}
+/**
+ * Whether a file (or presigned URL) has a video stream: true, false, or null when ffprobe could not tell.
+ * An audio-only recording (a browser broadcast with no camera) has no frame to take; asking ffmpeg for one
+ * fails every time, so the job refuses it as permanent instead (two such VODs answered 500 on 2026-10-12).
+ */
+function hasVideoStream(videoPath) {
+ return new Promise((resolve) => {
+ let out = '';
+ let p;
+ try {
+ p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v', '-show_entries', 'stream=index', '-of', 'csv=p=0', videoPath]);
+ } catch { return resolve(null); }
+ const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);
+ p.stdout.on('data', (d) => { out += d; });
+ p.on('close', (code) => { clearTimeout(to); resolve(code === 0 ? String(out).trim() !== '' : null); });
+ p.on('error', () => { clearTimeout(to); resolve(null); });
+ });
+}
+
/**
/**
* 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
@@ -371,6 +390,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
@@ -371,6 +390,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
module.exports = {
module.exports = {
generateFromVideo,
generateFromVideo,
+ hasVideoStream,
generateVodThumbnail,
generateVodThumbnail,
generateClipThumbnail,
generateClipThumbnail,
saveLiveThumbnail,
saveLiveThumbnail,
@@ -54,12 +54,25 @@ const express = require('express');
@@ -54,12 +54,25 @@ const express = require('express');
assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1], `vod ${id}`);
assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1], `vod ${id}`);
assert.ok(Date.now() - t0 < 5000, 'refused before any ffmpeg run');
assert.ok(Date.now() - t0 < 5000, 'refused before any ffmpeg run');
}
}
+ // An audio-only recording (a browser broadcast with no camera; VODs 5718 and 5721 on 2026-10-12): no_video, 404.
+ if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {
+ const audio = path.join(V, 'audio-only.webm');
+ const mk = require('child_process').spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:a', 'libopus', audio]);
+ assert.strictEqual(mk.status, 0, String(mk.stderr));
+ const id = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)
+ VALUES ('live', 't', ?, ?, 1, 'public', 'ok') RETURNING id`, [audio, fs.statSync(audio).size])).lastInsertRowid);
+ const r = await post(`/api/v1/live/thumbnails/vod/${id}`);
+ assert.strictEqual(r.status, 404, JSON.stringify(r.body));
+ assert.strictEqual(r.body.code, 'media.no_video');
+ const job = await queue.get(r.body.job_id);
+ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);
+ } else console.log('audio-only case skipped (ffmpeg not found)');
assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));
assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));
assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');
assert.strictEqual((await db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'"))[0].n, 0, 'none of them is a generate failure any more');
server.close();
server.close();
await db.close();
await db.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
- console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once, 404 from the v1 route');
+ console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once; on an audio-only one: no_video; 404 from the v1 route');
process.exit(0);
process.exit(0);
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });