openvibers/openvibe.media Commits

An audio-only VOD has a picture: the streamer's avatar card, and audio_only on the public shape (#241)

OP OpenVibers committed 11 days ago · d14afff2f6 · 14 files, +305 −39

* An audio-only VOD says it has no picture, and its thumbnail is the streamer's avatar card

* Merge remote-tracking branch 'origin/main' into refresh-241

* Merge remote-tracking branch 'origin/main' into refresh-241

Changes

ddiff --git a/migrations/0016_audio_only.sql b/migrations/0016_audio_only.sql
nnew file mode 100644
iindex 0000000..e4bc286
---- /dev/null
++++ b/migrations/0016_audio_only.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- An audio-only recording (a browser broadcast with no camera, Media#240) has no frame to take. The row says so once,
++-- from the recorder's mode or from the one ffprobe that already runs at finalize/thumbnail time, so a page never asks
++-- again and the thumbnail is the streamer's avatar card (server/thumbnails/audio-card.js) instead of a 404.
++ALTER TABLE vods ADD COLUMN IF NOT EXISTS audio_only boolean NOT NULL DEFAULT false;
++ALTER TABLE clips ADD COLUMN IF NOT EXISTS audio_only boolean NOT NULL DEFAULT false;
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex bc5afdc..7d6325b 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -104,7 +104,7 @@ async function toAvatar(buf) {
* Store one avatar's bytes as a native object; returns its id. Throws (with nothing left behind) when the tenant,
* the namespace or the quota refuses it.
*/
--async function storeAvatar({ db, pic, subject = null, username = null, sourceHost = null }) {
++async function storeAvatar({ db, pic, subject = null, username = null, sourceHost = null, userId = null }) {
const model = require('../objects/model');
const namespaces = require('../objects/namespaces');
const invariant = require('../objects/invariant');
@@@ -123,6 +123,9 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHos
return {
id: await model.createObject({
app_id: AVATAR_APP, namespace, kind: 'avatar', owner_subject: SUBJECT_RE.test(String(subject || '')) ? subject : null,
++ // The app-local owner too: the avatar card (thumbnails/audio-card.js) finds an audio-only streamer's
++ // picture by the same Live user id its VOD carries, without a Network round trip on a thumbnail request.
++ owner_app: userId != null ? AVATAR_APP : null, owner_user_id: userId != null ? Number(userId) : null,
visibility: 'unlisted', lifecycle_status: 'uploading', mime_type: 'image/webp', size_bytes: bytes, content_hash: sha256,
metadata: { filename: 'avatar.webp', title: `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, source: 'avatar.ingest', source_host: sourceHost },
}),
@@@ -159,7 +162,7 @@ function createIngestHandler({ db, config, log = console }) {
const raw = await safeFetchImage(url);
const pic = await toAvatar(raw);
const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();
-- const id = await storeAvatar({ db, pic, subject, username, sourceHost });
++ const id = await storeAvatar({ db, pic, subject, username, sourceHost, userId: user_id });
res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
} catch (err) {
log.warn('[Avatar ingest]', err.message);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex c72b642..3255b26 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -222,14 +222,14 @@ async function ensureRootNamespace(app) {
// ── VOD helpers ──────────────────────────────────────────────
// The row and its media_object in one transaction (a clips-only recording has no object).
--async function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta, visibility, clips_only }) {
++async function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta, visibility, clips_only, audio_only }) {
const vis = visibility ? _normVisibility(visibility) : 'public';
return await withObject('vod', (r) => r.lastInsertRowid, async () => await run(
-- `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
++ `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only, audio_only)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
[app_id, stream_id || null, stream_key || null, managed_stream_id || null, user_id || null, title || 'Recording', description || '',
file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
-- JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0]
++ JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0, audio_only ? 1 : 0]
));
}
@@@ -451,15 +451,15 @@ function vodStatus(vod) {
// The row and its media_object in one transaction. `visibility` (public | unlisted | private), when
// given, sets both columns; otherwise is_public picks public or unlisted as before.
--async function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id }) {
++async function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only }) {
const pub = (is_public === 0 || is_public === false) ? 0 : 1;
const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');
return await withObject('clip', (r) => r.lastInsertRowid, async () => await run(
-- `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
++ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
[app_id, vod_id || null, stream_id || null, user_id || null, channel_user_id || null, title || 'Untitled Clip', description || '',
file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
-- vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null]
++ vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null, audio_only ? 1 : 0]
));
}
ddiff --git a/server/jobs/thumbnail.js b/server/jobs/thumbnail.js
iindex b6055c9..27e2c54 100644
---- a/server/jobs/thumbnail.js
++++ b/server/jobs/thumbnail.js
@@@ -74,8 +74,15 @@ 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 });
}
++ // An audio-only source (a browser broadcast with no camera, Media#240) has no frame to take. Its picture is the
++ // streamer's avatar card, made once here and stored like any thumbnail, and the row is marked audio_only. This used
++ // to be a permanent `no_video` failure, which every page then asked about again (Live backed off to once a day).
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, { audioOnly: true })
++ : await thumbService.generateClipThumbnail(id, source.value, { audioOnly: true, ...(virtual ? { seekSeconds: (Number(row.start_time) || 0) + 0.5 } : {}) });
++ if (!url) throw new JobError('generate_failed', 'Failed to make the audio-only picture');
++ return { url, kind, id, audio_only: true };
}
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex d611930..501b5f5 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -336,8 +336,11 @@ async function serveMediaRecord(kind, record, req, res) {
? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }
: {};
const asFile = downloadDisposition(req, kind, record, path.extname(filePath));
++ // A public/unlisted recording is readable from another origin (openvibe.video's player, and the Web Audio
++ // analyser that draws an audio-only item's level ring: createMediaElementSource needs an untainted CORS load).
++ const corsHeaders = visibility === 'private' ? {} : { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' };
-- streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex, ...(asFile ? { 'Content-Disposition': asFile } : {}) });
++ streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex, ...corsHeaders, ...(asFile ? { 'Content-Disposition': asFile } : {}) });
}
// ── VOD playback ─────────────────────────────────────────────
ddiff --git a/server/thumbnails/audio-card.js b/server/thumbnails/audio-card.js
nnew file mode 100644
iindex 0000000..3a0facb
---- /dev/null
++++ b/server/thumbnails/audio-card.js
@@@ -0,0 +1,135 @@
++'use strict';
++/**
++ * OpenVibe.Media — the picture of an audio-only recording or clip.
++ *
++ * A browser broadcast with no camera is an Opus WebM (Media#240): there is no frame for ffmpeg to take, and the
++ * thumbnail job used to answer `no_video` forever, so Live's cards and openvibe.video showed a hole. The picture is
++ * made here instead, once: the streamer's avatar over a dark, softly blurred copy of it, with a small "Audio" mark.
++ * It is stored like any thumbnail (/t/vod-<id>-<time>.jpg) and the item's row is marked audio_only, so nothing asks
++ * Media again and the player knows it has no picture to show.
++ *
++ * The avatar is Media's own copy (an avatar object ingested from Network), found by the owner's app-local user id
++ * (new avatars) or by the recording object's Network subject (old ones). Without one, a microphone on the same
++ * gradient says the same thing.
++ */
++const fs = require('fs');
++const path = require('path');
++const sharp = require('sharp');
++const db = require('../db/database');
++
++const WIDTH = 1280;
++const HEIGHT = 720;
++const AVATAR_PX = 320;
++const AUDIO_MARK = '<path fill="currentColor" d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>';
++
++const escapeXml = (s) => String(s == null ? '' : s)
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
++
++/** A local avatar file for an owner, preferring the avatar object's own bytes. */
++async function avatarFile({ userId = null, objectId = null } = {}) {
++ let subject = null;
++ if (objectId) {
++ try { subject = (await db.get('SELECT owner_subject FROM media_objects WHERE id = ?', [objectId]))?.owner_subject || null; } catch { /* older database */ }
++ }
++ const byUser = userId != null
++ ? await db.all(`SELECT id, app_id, canonical_provider, canonical_key FROM media_objects
++ WHERE kind = 'avatar' AND lifecycle_status = 'ready' AND owner_user_id = ?
++ ORDER BY created_at DESC LIMIT 3`, [userId]).catch(() => [])
++ : [];
++ const bySubject = subject
++ ? await db.all(`SELECT id, app_id, canonical_provider, canonical_key FROM media_objects
++ WHERE kind = 'avatar' AND lifecycle_status = 'ready' AND owner_subject = ?
++ ORDER BY created_at DESC LIMIT 3`, [subject]).catch(() => [])
++ : [];
++ const model = require('../objects/model');
++ for (const obj of [...byUser, ...bySubject]) {
++ const candidates = [obj.canonical_key, model.objectFilePath(obj)].filter(Boolean);
++ for (const p of candidates) {
++ try { if (fs.existsSync(p) && fs.statSync(p).size > 0) return p; } catch { /* unreadable: next */ }
++ }
++ }
++ return null;
++}
++
++/** Break a title into at most two lines that fit the card. */
++function titleLines(text, max = 44) {
++ const words = String(text || '').trim().split(/\s+/).filter(Boolean);
++ if (!words.length) return [];
++ const lines = [];
++ let line = '';
++ for (const w of words) {
++ const candidate = line ? `${line} ${w}` : w;
++ if (candidate.length > max && line) { lines.push(line); line = w; } else line = candidate;
++ if (lines.length === 2) break;
++ }
++ if (line && lines.length < 2) lines.push(line);
++ if (lines.join(' ').length < String(text).trim().length) lines[1] = `${lines[1].replace(/[.,;:!?]?$/, '')}…`;
++ return lines;
++}
++
++function backgroundSvg() {
++ return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}">
++ <defs>
++ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
++ <stop offset="0" stop-color="#0a0e1a"/><stop offset="0.55" stop-color="#111a31"/><stop offset="1" stop-color="#1e1636"/>
++ </linearGradient>
++ <radialGradient id="glow" cx="50%" cy="38%" r="62%">
++ <stop offset="0" stop-color="#7c6cff" stop-opacity="0.28"/><stop offset="1" stop-color="#7c6cff" stop-opacity="0"/>
++ </radialGradient>
++ </defs>
++ <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#bg)"/>
++ <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#glow)"/>
++ <circle cx="640" cy="300" r="${AVATAR_PX / 2}" fill="#0b1020" opacity="0.55"/>
++</svg>`);
++}
++
++function foregroundSvg({ title, hasAvatar }) {
++ const lines = titleLines(title);
++ const lineEls = lines.map((line, i) => `<text x="640" y="${548 + i * 54}" text-anchor="middle" font-family="Inter, 'Segoe UI', system-ui, sans-serif" font-size="42" font-weight="600" fill="#f4f6ff" opacity="0.94">${escapeXml(line)}</text>`).join('');
++ const placeholder = hasAvatar ? '' : `<g transform="translate(560 140) scale(6.667)" color="#ffffff" opacity="0.62">${AUDIO_MARK}</g>`;
++ return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}">
++ ${placeholder}
++ <g transform="translate(64 56)">
++ <rect width="156" height="58" rx="29" fill="#ffffff" opacity="0.10"/>
++ <rect x="0.5" y="0.5" width="155" height="57" rx="28.5" fill="none" stroke="#ffffff" stroke-opacity="0.22"/>
++ <g transform="translate(18 15) scale(1.15)" color="#ffffff" opacity="0.95">${AUDIO_MARK}</g>
++ <text x="62" y="38" font-family="Inter, 'Segoe UI', system-ui, sans-serif" font-size="25" font-weight="600" fill="#ffffff" opacity="0.92">Audio</text>
++ </g>
++ ${lineEls}
++ <text x="640" y="648" text-anchor="middle" font-family="Inter, 'Segoe UI', system-ui, sans-serif" font-size="23" fill="#c7cdea" opacity="0.72">Audio only · no picture was broadcast</text>
++</svg>`);
++}
++
++/** Render the 1280×720 JPEG. `avatar` is a local image path, or null for the microphone. */
++async function renderAudioCard({ title, avatar = null } = {}) {
++ let avatarBuf = null;
++ if (avatar) {
++ try { avatarBuf = await sharp(avatar).rotate().resize(AVATAR_PX, AVATAR_PX, { fit: 'cover', position: 'attention' }).png().toBuffer(); } catch { avatarBuf = null; }
++ }
++ const base = avatarBuf
++ ? await sharp(avatarBuf).resize(WIDTH, HEIGHT, { fit: 'cover' }).blur(46).modulate({ brightness: 0.42, saturation: 0.75 }).png().toBuffer()
++ : await sharp({ create: { width: WIDTH, height: HEIGHT, channels: 3, background: '#0a0e1a' } }).png().toBuffer();
++
++ const layers = [{ input: backgroundSvg() }];
++ if (avatarBuf) {
++ const mask = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${AVATAR_PX}" height="${AVATAR_PX}"><circle cx="${AVATAR_PX / 2}" cy="${AVATAR_PX / 2}" r="${AVATAR_PX / 2}" fill="#fff"/></svg>`);
++ const round = await sharp(avatarBuf).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer();
++ layers.push({ input: round, left: (WIDTH - AVATAR_PX) / 2, top: 140 });
++ }
++ layers.push({ input: foregroundSvg({ title, hasAvatar: !!avatarBuf }) });
++ return await sharp(base).composite(layers).jpeg({ quality: 84, mozjpeg: true }).toBuffer();
++}
++
++/** Write the card as a timestamped thumbnail and return its public path, or null when it could not be made. */
++async function writeAudioCard({ kind, id, title, userId = null, objectId = null }) {
++ const avatar = await avatarFile({ userId, objectId });
++ const buf = await renderAudioCard({ title, avatar });
++ const filename = `${kind}-${id}-${Date.now()}.jpg`;
++ const out = path.join(require('./thumbnail-service').THUMB_DIR, filename);
++ fs.mkdirSync(path.dirname(out), { recursive: true });
++ fs.writeFileSync(out, buf);
++ return `/t/${filename}`;
++}
++
++module.exports = { writeAudioCard, renderAudioCard, avatarFile, titleLines, WIDTH, HEIGHT };
ddiff --git a/server/thumbnails/routes.js b/server/thumbnails/routes.js
iindex 9c62db9..8f91f34 100644
---- a/server/thumbnails/routes.js
++++ b/server/thumbnails/routes.js
@@@ -83,12 +83,9 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
return res.json({ url });
}
-- // A recording already found to have no video (audio only) has no frame to take, and its file never changes after
-- // it is finished: answer that at once instead of running the same failing job again (every visitor's page asked,
-- // 56 failed jobs in two days, 2026-10-15). Uses idx_media_jobs_object.
-- if (row.object_id && !row.is_recording && await db.get("SELECT 1 AS x FROM media_jobs WHERE object_id = ? AND job_type = 'thumbnail.regenerate' AND status = 'failed' AND error_code = 'no_video' LIMIT 1", [row.object_id])) {
-- return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video' });
-- }
++ // An audio-only recording is not refused here any more: the job makes the streamer's avatar card and marks the
++ // row audio_only (Media#240 / R4). The old shortcut answered 404 media.no_video for every later ask, so a
++ // recording that got one before this release can still be given a picture now.
// A clip whose cut failed has no picture to take, and that does not change by asking: no job (thumbnail.js says
// the same if one runs anyway).
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex 5b16803..737e989 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -209,13 +209,13 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
// produced the identical 1-second frame and the card looked frozen for the whole
// broadcast. An in-progress recording therefore grabs the live edge instead.
async function generateVodThumbnail(vodId, filePath, opts = {}) {
-- const isRecording = opts.liveEdge != null
-- ? !!opts.liveEdge
-- : !!(await db.get('SELECT is_recording FROM vods WHERE id = ?', [vodId]))?.is_recording;
++ const row = await db.get('SELECT is_recording, audio_only, thumbnail_url, title, user_id, object_id FROM vods WHERE id = ?', [vodId]);
++ if (opts.audioOnly || (row && row.audio_only)) return await generateAudioThumbnail('vod', vodId, row);
++ const isRecording = opts.liveEdge != null ? !!opts.liveEdge : !!(row && row.is_recording);
const thumbUrl = await generateFromVideo(filePath, 'vod', vodId,
isRecording ? { liveEdge: true } : { seekPercent: 10 });
if (thumbUrl) {
-- const old = (await db.get('SELECT thumbnail_url FROM vods WHERE id = ?', [vodId]))?.thumbnail_url;
++ const old = row && row.thumbnail_url;
// The row and its objects (the VOD's, and the thumbnail object that follows the picture) together.
await db.withObject('vod', vodId, async () => await db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumbUrl, vodId]));
_removeOldThumb(old, thumbUrl); // the previous picture goes once nothing names it
@@@ -223,12 +223,33 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {
return thumbUrl;
}
++// An audio-only recording has no frame to take (Media#240): its picture is the streamer's avatar card, made once and
++// stored like any thumbnail. The row is marked audio_only, so no page asks again and the player knows there is no video.
++async function generateAudioThumbnail(kind, id, row = null) {
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ const r = row || await db.get(`SELECT thumbnail_url, title, user_id, channel_user_id, object_id, audio_only FROM ${table} WHERE id = ?`, [id]);
++ if (!r) return null;
++ const thumbUrl = await require('./audio-card').writeAudioCard({
++ kind, id, title: r.title,
++ userId: kind === 'clip' ? (r.channel_user_id != null ? r.channel_user_id : r.user_id) : r.user_id,
++ objectId: r.object_id || null,
++ });
++ if (!thumbUrl) return null;
++ const old = r.thumbnail_url;
++ await db.withObject(kind, id, async () => await db.run(`UPDATE ${table} SET thumbnail_url = ?, audio_only = true WHERE id = ?`, [thumbUrl, id]));
++ _removeOldThumb(old, thumbUrl);
++ return thumbUrl;
++}
++
// ── Generate Clip Thumbnail (first frames) ───────────────────
// opts.seekSeconds: where the clip starts in filePath (a virtual clip's picture comes from its VOD).
++// opts.audioOnly: the clip has no picture (an audio-only recording's clip); the avatar card is its picture.
async function generateClipThumbnail(clipId, filePath, opts = {}) {
++ const row = await db.get('SELECT audio_only, thumbnail_url, title, user_id, channel_user_id, object_id FROM clips WHERE id = ?', [clipId]);
++ if (opts.audioOnly || (row && row.audio_only)) return await generateAudioThumbnail('clip', clipId, row);
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;
++ const old = row && row.thumbnail_url;
await db.withObject('clip', clipId, async () => await db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]));
_removeOldThumb(old, thumbUrl);
}
@@@ -431,6 +452,7 @@ module.exports = {
hasVideoStream,
generateVodThumbnail,
generateClipThumbnail,
++ generateAudioThumbnail,
saveLiveThumbnail,
serveThumbnail,
cleanupOldThumbnails,
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex f099062..1e0b22e 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -87,6 +87,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),
++ // 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',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
@@@ -266,6 +268,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
is_public: visibility === 'public' ? 1 : 0,
visibility,
status: 'processing',
++ audio_only: !!vod.audio_only,
}); // the row and its object (uploading) in one transaction
const clipId = result.lastInsertRowid;
const appId = req.appId;
@@@ -329,7 +332,7 @@ async function _createRecordingClip(req, res, vod, startTime, endTime, body) {
description: typeof body.description === 'string' ? body.description.replace(/<[^>]*>/g, '').trim().slice(0, 1000) : '',
auto_generated: byApp && flag(body.auto_generated) === 1,
file_path: '', start_time: startTime, end_time: endTime, duration_seconds: endTime - startTime,
-- is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing',
++ is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing', audio_only: !!first.audio_only,
});
const clipId = result.lastInsertRowid;
await require('./clip-jobs').makeVirtual(clipId, { objectId: rec.id, endTime: startTime + Number(last.end_ms) / 1000 });
@@@ -347,8 +350,12 @@ async function _createUploadedClip(req, res) {
try {
await tools.remuxForSeeking(clipPath);
-- let duration = 0;
-- try { duration = (await tools.probeVodInfo(clipPath)).duration || 0; } catch { /* */ }
++ let duration = 0, audioOnly = false;
++ try {
++ const info = await tools.probeVodInfo(clipPath);
++ duration = info.duration || 0;
++ audioOnly = Array.isArray(info.streams) && info.streams.length > 0 && !info.streams.some((st) => st.codec_type === 'video');
++ } catch { /* */ }
if (!duration || tools.getFileSizeSafe(clipPath) === 0) {
tools.cleanupTempFile(clipPath);
return res.status(422).json({ error: 'Clip upload was corrupt or empty. Please try again.' });
@@@ -377,6 +384,7 @@ async function _createUploadedClip(req, res) {
is_public: visibility === 'public' ? 1 : 0,
visibility,
status: 'ready',
++ audio_only: audioOnly,
}); // the row and its object (ready, local copy present) in one transaction
const clipId = result.lastInsertRowid;
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 749d430..745de50 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -65,6 +65,8 @@ 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),
++ // 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',
visibility: vod.visibility || 'public',
// Kept alongside visibility — inherited SPA surfaces (manager badges,
@@@ -370,11 +372,14 @@ async function _doFinalize(vodId, opts) {
}
}
++ // The streams ffprobe saw: an audio-only recording has no video stream and gets an avatar card, not a frame.
++ const audioOnly = !!(probe.ok && Array.isArray(probe.streams) && probe.streams.length > 0
++ && !probe.streams.some((s) => s.codec_type === 'video'));
// Thumbnail BEFORE the ready commit, so the vod.ready payload has it (failure is non-fatal).
// The row is still is_recording = 1 here, so ask for the 10%-in frame, not the live edge.
try {
const thumbService = require('../thumbnails/thumbnail-service');
-- await thumbService.generateVodThumbnail(vodId, filePath, { liveEdge: false });
++ await thumbService.generateVodThumbnail(vodId, filePath, { liveEdge: false, audioOnly });
} catch (err) {
console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);
}
@@@ -386,8 +391,8 @@ async function _doFinalize(vodId, opts) {
// The ready transition, the VOD's object and the vod.ready event commit together.
// A failed commit throws, as the plain UPDATE did before.
await announce(vod.app_id, 'vod.ready', {
-- change: async () => await objects().withObject('vod', vodId, async () => await db.run(`UPDATE vods SET is_recording = 0, duration_seconds = ?, duration_source = ?, file_size = ?, probe_duration_seconds = ?, probe_format_json = ?, health_status = ?, health_issues_json = ?${lift ? ", quarantined_at = NULL, is_public = CASE WHEN COALESCE(visibility, 'public') = 'public' THEN 1 ELSE 0 END" : ''} WHERE id = ?`,
-- [stored, durationSource, stat.size, measured, probeFormatJson, 'ok', JSON.stringify(issues), vodId])),
++ change: async () => await objects().withObject('vod', vodId, async () => await db.run(`UPDATE vods SET is_recording = 0, duration_seconds = ?, duration_source = ?, file_size = ?, probe_duration_seconds = ?, probe_format_json = ?, health_status = ?, health_issues_json = ?, audio_only = ?${lift ? ", quarantined_at = NULL, is_public = CASE WHEN COALESCE(visibility, 'public') = 'public' THEN 1 ELSE 0 END" : ''} WHERE id = ?`,
++ [stored, durationSource, stat.size, measured, probeFormatJson, 'ok', JSON.stringify(issues), audioOnly ? 1 : 0, vodId])),
payload: async () => await vodPublic(await db.getVodById(vodId), { hls: false }),
});
const ready = await db.getVodById(vodId);
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex de3fb25..9488a37 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -257,7 +257,10 @@ class StreamRecorder {
// so even if the object cannot be written with it: withObjectOrRow then writes the row alone
// and re-projects after it (the finalize that follows re-projects every outcome as well).
await require('../objects/model').withObjectOrRow('vod', vod.id, async () => {
-- await db.run('UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?', [filePath, vod.id]);
++ // The recorder knows an RTP broadcast has no video before any probe: store it once, so the public shape can
++ // say so while the recording is still growing (the finalize probe confirms it).
++ await db.run('UPDATE vods SET is_recording = 1, file_path = ?, audio_only = CASE WHEN ? THEN true ELSE audio_only END WHERE id = ?',
++ [filePath, extra.audioOnly ? 1 : 0, vod.id]);
if (masterPath) await db.run('UPDATE vods SET master_file_path = ? WHERE id = ?', [masterPath, vod.id]);
});
return recording;
@@@ -548,6 +551,7 @@ class StreamRecorder {
const persisted = this._registerCommon(vod, proc, filePath, masterPath, {
protocol: 'rtp',
++ audioOnly,
sdpPath,
rtpVideoPort: videoPort,
rtpAudioPort: audioPort,
ddiff --git a/test/audio-only-vod.test.js b/test/audio-only-vod.test.js
nnew file mode 100644
iindex 0000000..eee32d7
---- /dev/null
++++ b/test/audio-only-vod.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++// R4: an audio-only recording has no frame to take, so its picture is the streamer's avatar card (or a microphone
++// when there is none) over the site's dark gradient, with an "Audio" mark. The row says audio_only once, from the one
++// ffprobe in the thumbnail job, and the card is served from /t/ like any other thumbnail. A clip of such a recording
++// carries the same flag and gets the same card.
++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');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-audio-only-'));
++ 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 ffmpeg = spawnSync('ffmpeg', ['-version']);
++ if (ffmpeg.status !== 0) {
++ console.log('audio-only card: skipped (ffmpeg not found)');
++ process.exit(0);
++ }
++
++ const db = require('../server/db/database');
++ const thumbs = require('../server/thumbnails/thumbnail-service');
++ await db.upsertApp({ app_id: 'live', api_key: 'live-key-audio-card' });
++
++ const audio = path.join(process.env.VOD_PATH, 'audio.webm');
++ const made = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:a', 'libopus', audio]);
++ assert.strictEqual(made.status, 0, String(made.stderr));
++ assert.strictEqual(await thumbs.hasVideoStream(audio), false, 'the probe says the source has no video');
++
++ const vodId = Number((await db.run(`INSERT INTO vods (app_id, user_id, title, file_path, file_size, duration_seconds, is_public, visibility, health_status)
++ VALUES ('live', 7, 'A microphone on a dark night', ?, ?, 3, 1, 'public', 'ok') RETURNING id`, [audio, fs.statSync(audio).size])).lastInsertRowid);
++ const url = await thumbs.generateVodThumbnail(vodId, audio, { audioOnly: true });
++ assert.match(url, new RegExp(`^/t/vod-${vodId}-\\d+\\.jpg$`));
++ const row = await db.get('SELECT audio_only, thumbnail_url FROM vods WHERE id = ?', [vodId]);
++ assert.deepStrictEqual([row.audio_only, row.thumbnail_url], [true, url], 'the row says audio_only and names the card');
++ const file = path.join(process.env.THUMBNAILS_PATH, path.basename(url));
++ const bytes = fs.readFileSync(file);
++ assert.deepStrictEqual([...bytes.subarray(0, 2)], [0xff, 0xd8], 'a real JPEG, not a 1×1 fallback');
++ const meta = await require('sharp')(file).metadata();
++ assert.deepStrictEqual([meta.width, meta.height], [1280, 720], 'a card-shaped 16:9 thumbnail');
++
++ // The public /t/ route serves it, so Live's and Video's cards never show a hole.
++ const server = http.createServer((req, res) => {
++ req.params = { id: decodeURIComponent(new URL(req.url, 'http://x').pathname.replace(/^\/t\//, '')) };
++ req.query = {};
++ res.status = (code) => { res.statusCode = code; return res; };
++ res.json = (b) => res.end(JSON.stringify(b));
++ thumbs.serveThumbnail(req, res);
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const got = await fetch(`http://127.0.0.1:${server.address().port}${url}`);
++ assert.strictEqual(got.status, 200, 'served, not 404');
++ assert.strictEqual(got.headers.get('content-type'), 'image/jpeg');
++ assert.deepStrictEqual([...Buffer.from(await got.arrayBuffer()).subarray(0, 2)], [0xff, 0xd8]);
++
++ // A clip of it is audio-only too: the same card, under /t/clip-.
++ const clipId = Number((await db.run(`INSERT INTO clips (app_id, vod_id, user_id, channel_user_id, title, file_path, start_time, end_time, duration_seconds, is_public, visibility, status, audio_only)
++ VALUES ('live', ?, 7, 7, 'A clip of the microphone', ?, 0, 3, 3, 1, 'public', 'ready', true) RETURNING id`, [vodId, audio])).lastInsertRowid);
++ const clipUrl = await thumbs.generateClipThumbnail(clipId, audio);
++ assert.match(clipUrl, new RegExp(`^/t/clip-${clipId}-\\d+\\.jpg$`));
++ assert.strictEqual((await db.get('SELECT audio_only FROM clips WHERE id = ?', [clipId])).audio_only, true);
++ assert.ok(fs.existsSync(path.join(process.env.THUMBNAILS_PATH, path.basename(clipUrl))));
++
++ server.close();
++ await db.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('✅ audio-only VOD/clip: probe without video → stored audio_only, avatar card served from /t/ (not 404)');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex 3519829..ee6a032 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -263,8 +263,7 @@ const http = require('http');
r = await call('POST', '/api/v1/live/thumbnails/vod/2');
assert.deepStrictEqual([r.status, r.body.error], [404, 'Media file unavailable'], 'no media is still a 404');
assert.strictEqual((await queue.get(r.body.job_id)).error_code, 'media_unavailable');
-- // An audio-only recording failed once with no_video: later asks answer at once, with no new job (every visitor's
-- // page asked again before, 2026-10-15).
++ // A legacy no_video failure does not block the new avatar card (R4): the route asks again and makes one.
await conn.prepare("INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, duration_seconds) OVERRIDING SYSTEM VALUE VALUES (3, 'live', 5, 'C', ?, 1, 60) RETURNING id").run(path.join(process.env.VOD_PATH, 'rec-1.webm'));
await require('../server/objects/model').sync('vod', 3);
const audioObj = (await db.get('SELECT object_id FROM vods WHERE id = 3')).object_id;
@@@ -272,8 +271,8 @@ const http = require('http');
await db.run("UPDATE media_jobs SET status = 'failed', error_code = 'no_video', error = 'The recording has no video (audio only)' WHERE id = ?", [failed.job.id]);
const jobsBefore = (await db.get("SELECT COUNT(*) AS n FROM media_jobs WHERE object_id = ?", [audioObj])).n;
r = await call('POST', '/api/v1/live/thumbnails/vod/3');
-- assert.deepStrictEqual([r.status, r.body.code], [404, 'media.no_video'], 'a known audio-only recording answers no_video');
-- assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM media_jobs WHERE object_id = ?", [audioObj])).n, jobsBefore, 'and runs no new job');
++ assert.deepStrictEqual([r.status, r.body.url], [200, '/t/vod-3-1.jpg'], 'the card is made despite the old no_video job');
++ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM media_jobs WHERE object_id = ?", [audioObj])).n, jobsBefore + 1, 'one new job');
r = await call('POST', '/api/v1/live/thumbnails/vod/1?async=1');
assert.strictEqual(r.status, 202);
assert.ok(await waitFor(() => false, 50) || true);
ddiff --git a/test/thumbnail-unavailable.test.js b/test/thumbnail-unavailable.test.js
iindex a19836f..e113c7b 100644
---- a/test/thumbnail-unavailable.test.js
++++ b/test/thumbnail-unavailable.test.js
@@@ -54,7 +54,8 @@ 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.
++ // An audio-only recording (a browser broadcast with no camera; VODs 5718 and 5721 on 2026-10-12) has no frame,
++ // but it is not a failure any more: the job makes the streamer's avatar card and marks the row audio_only (R4).
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]);
@@@ -62,10 +63,11 @@ const express = require('express');
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');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.match(r.body.url, new RegExp(`^/t/vod-${id}-\\d+\\.jpg$`), 'the avatar card, not a 404');
const job = await queue.get(r.body.job_id);
-- assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);
++ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['succeeded', null, 1]);
++ assert.strictEqual((await db.get('SELECT audio_only FROM vods WHERE id = ?', [id])).audio_only, true);
} else console.log('audio-only case skipped (ffmpeg not found)');
// A clip whose cut failed (clips 495-498 of VOD 6010, 2026-10-27): 404 at once, and no job is made for it.
const jobsBefore = (await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n;
@@@ -101,7 +103,7 @@ const express = require('express');
server.close();
await db.close();
fs.rmSync(tmp, { recursive: true, force: true });
-- console.log('✅ thumbnail.regenerate on an empty or missing recording, a clip never cut or a damaged recording under review: media_unavailable; on an audio-only one: no_video; 404 from the v1 route');
++ console.log('✅ thumbnail.regenerate on an empty or missing recording, a clip never cut or a damaged recording under review: media_unavailable; on an audio-only one: an avatar card and 200');
process.exit(0);
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });