openvibers/openvibe.media Commits
An audio-only VOD has a picture: the streamer's avatar card, and audio_only on the public shape (#241)
Changes
nnew file mode 100644@@@ -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;@@@ -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, sourceHosreturn {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);@@@ -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]));}@@@ -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)@@@ -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 ─────────────────────────────────────────────nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')++ .replace(/"/g, '"').replace(/'/g, ''');++++/** 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 };@@@ -83,12 +83,9 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), uploadreturn 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).@@@ -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,@@@ -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.singis_public: visibility === 'public' ? 1 : 0,visibility,status: 'processing',++ audio_only: !!vod.audio_only,}); // the row and its object (uploading) in one transactionconst 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 transactionconst clipId = result.lastInsertRowid;@@@ -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);@@@ -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,nnew file mode 100644@@@ -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); });@@@ -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);@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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;
@@ -104,7 +104,7 @@ async function toAvatar(buf) {
@@ -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,
* 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.
* 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 model = require('../objects/model');
const namespaces = require('../objects/namespaces');
const namespaces = require('../objects/namespaces');
const invariant = require('../objects/invariant');
const invariant = require('../objects/invariant');
@@ -123,6 +123,9 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHos
@@ -123,6 +123,9 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHos
return {
return {
id: await model.createObject({
id: await model.createObject({
app_id: AVATAR_APP, namespace, kind: 'avatar', owner_subject: SUBJECT_RE.test(String(subject || '')) ? subject : null,
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,
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 },
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 }) {
@@ -159,7 +162,7 @@ function createIngestHandler({ db, config, log = console }) {
const raw = await safeFetchImage(url);
const raw = await safeFetchImage(url);
const pic = await toAvatar(raw);
const pic = await toAvatar(raw);
const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();
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 });
res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
} catch (err) {
} catch (err) {
log.warn('[Avatar ingest]', err.message);
log.warn('[Avatar ingest]', err.message);
@@ -222,14 +222,14 @@ async function ensureRootNamespace(app) {
@@ -222,14 +222,14 @@ async function ensureRootNamespace(app) {
// ── VOD helpers ──────────────────────────────────────────────
// ── VOD helpers ──────────────────────────────────────────────
// The row and its media_object in one transaction (a clips-only recording has no object).
// 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';
const vis = visibility ? _normVisibility(visibility) : 'public';
return await withObject('vod', (r) => r.lastInsertRowid, async () => await run(
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 || '',
[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,
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) {
@@ -451,15 +451,15 @@ function vodStatus(vod) {
// The row and its media_object in one transaction. `visibility` (public | unlisted | private), when
// 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.
// 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 pub = (is_public === 0 || is_public === false) ? 0 : 1;
const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');
const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');
return await withObject('clip', (r) => r.lastInsertRowid, async () => await run(
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 || '',
[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,
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]
));
));
}
}
@@ -74,8 +74,15 @@ async function run(job) {
@@ -74,8 +74,15 @@ 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 });
}
}
+ // 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) {
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'
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
? await thumbService.generateVodThumbnail(id, source.value)
@@ -336,8 +336,11 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -336,8 +336,11 @@ async function serveMediaRecord(kind, record, req, res) {
? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }
? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }
: {};
: {};
const asFile = downloadDisposition(req, kind, record, path.extname(filePath));
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 ─────────────────────────────────────────────
// ── VOD playback ─────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+
+/** 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 };
@@ -83,12 +83,9 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
@@ -83,12 +83,9 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
return res.json({ url });
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
// 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).
// the same if one runs anyway).
@@ -209,13 +209,13 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
@@ -209,13 +209,13 @@ function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
// produced the identical 1-second frame and the card looked frozen for the whole
// 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.
// broadcast. An in-progress recording therefore grabs the live edge instead.
async function generateVodThumbnail(vodId, filePath, opts = {}) {
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,
const thumbUrl = await generateFromVideo(filePath, 'vod', vodId,
isRecording ? { liveEdge: true } : { seekPercent: 10 });
isRecording ? { liveEdge: true } : { seekPercent: 10 });
if (thumbUrl) {
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.
// 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]));
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
_removeOldThumb(old, thumbUrl); // the previous picture goes once nothing names it
@@ -223,12 +223,33 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {
@@ -223,12 +223,33 @@ async function generateVodThumbnail(vodId, filePath, opts = {}) {
return thumbUrl;
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) ───────────────────
// ── Generate Clip Thumbnail (first frames) ───────────────────
// opts.seekSeconds: where the clip starts in filePath (a virtual clip's picture comes from its VOD).
// 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 = {}) {
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 });
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 = row && row.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]));
_removeOldThumb(old, thumbUrl);
_removeOldThumb(old, thumbUrl);
}
}
@@ -431,6 +452,7 @@ module.exports = {
@@ -431,6 +452,7 @@ module.exports = {
hasVideoStream,
hasVideoStream,
generateVodThumbnail,
generateVodThumbnail,
generateClipThumbnail,
generateClipThumbnail,
+ generateAudioThumbnail,
saveLiveThumbnail,
saveLiveThumbnail,
serveThumbnail,
serveThumbnail,
cleanupOldThumbnails,
cleanupOldThumbnails,
@@ -87,6 +87,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -87,6 +87,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),
+ // 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',
visibility: clip.visibility || 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
storage_provider: clip.storage_provider || 'local',
@@ -266,6 +268,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -266,6 +268,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
is_public: visibility === 'public' ? 1 : 0,
is_public: visibility === 'public' ? 1 : 0,
visibility,
visibility,
status: 'processing',
status: 'processing',
+ audio_only: !!vod.audio_only,
}); // the row and its object (uploading) in one transaction
}); // the row and its object (uploading) in one transaction
const clipId = result.lastInsertRowid;
const clipId = result.lastInsertRowid;
const appId = req.appId;
const appId = req.appId;
@@ -329,7 +332,7 @@ async function _createRecordingClip(req, res, vod, startTime, endTime, body) {
@@ -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) : '',
description: typeof body.description === 'string' ? body.description.replace(/<[^>]*>/g, '').trim().slice(0, 1000) : '',
auto_generated: byApp && flag(body.auto_generated) === 1,
auto_generated: byApp && flag(body.auto_generated) === 1,
file_path: '', start_time: startTime, end_time: endTime, duration_seconds: endTime - startTime,
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;
const clipId = result.lastInsertRowid;
await require('./clip-jobs').makeVirtual(clipId, { objectId: rec.id, endTime: startTime + Number(last.end_ms) / 1000 });
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) {
@@ -347,8 +350,12 @@ async function _createUploadedClip(req, res) {
try {
try {
await tools.remuxForSeeking(clipPath);
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) {
if (!duration || tools.getFileSizeSafe(clipPath) === 0) {
tools.cleanupTempFile(clipPath);
tools.cleanupTempFile(clipPath);
return res.status(422).json({ error: 'Clip upload was corrupt or empty. Please try again.' });
return res.status(422).json({ error: 'Clip upload was corrupt or empty. Please try again.' });
@@ -377,6 +384,7 @@ async function _createUploadedClip(req, res) {
@@ -377,6 +384,7 @@ async function _createUploadedClip(req, res) {
is_public: visibility === 'public' ? 1 : 0,
is_public: visibility === 'public' ? 1 : 0,
visibility,
visibility,
status: 'ready',
status: 'ready',
+ audio_only: audioOnly,
}); // the row and its object (ready, local copy present) in one transaction
}); // the row and its object (ready, local copy present) in one transaction
const clipId = result.lastInsertRowid;
const clipId = result.lastInsertRowid;
@@ -65,6 +65,8 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
@@ -65,6 +65,8 @@ 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),
+ // 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',
storage_provider: vod.storage_provider || 'local',
visibility: vod.visibility || 'public',
visibility: vod.visibility || 'public',
// Kept alongside visibility — inherited SPA surfaces (manager badges,
// Kept alongside visibility — inherited SPA surfaces (manager badges,
@@ -370,11 +372,14 @@ async function _doFinalize(vodId, opts) {
@@ -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).
// 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.
// The row is still is_recording = 1 here, so ask for the 10%-in frame, not the live edge.
try {
try {
const thumbService = require('../thumbnails/thumbnail-service');
const thumbService = require('../thumbnails/thumbnail-service');
- await thumbService.generateVodThumbnail(vodId, filePath, { liveEdge: false });
+ await thumbService.generateVodThumbnail(vodId, filePath, { liveEdge: false, audioOnly });
} catch (err) {
} catch (err) {
console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);
console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);
}
}
@@ -386,8 +391,8 @@ async function _doFinalize(vodId, opts) {
@@ -386,8 +391,8 @@ async function _doFinalize(vodId, opts) {
// The ready transition, the VOD's object and the vod.ready event commit together.
// The ready transition, the VOD's object and the vod.ready event commit together.
// A failed commit throws, as the plain UPDATE did before.
// A failed commit throws, as the plain UPDATE did before.
await announce(vod.app_id, 'vod.ready', {
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 }),
payload: async () => await vodPublic(await db.getVodById(vodId), { hls: false }),
});
});
const ready = await db.getVodById(vodId);
const ready = await db.getVodById(vodId);
@@ -257,7 +257,10 @@ class StreamRecorder {
@@ -257,7 +257,10 @@ class StreamRecorder {
// so even if the object cannot be written with it: withObjectOrRow then writes the row alone
// 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).
// 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 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]);
if (masterPath) await db.run('UPDATE vods SET master_file_path = ? WHERE id = ?', [masterPath, vod.id]);
});
});
return recording;
return recording;
@@ -548,6 +551,7 @@ class StreamRecorder {
@@ -548,6 +551,7 @@ class StreamRecorder {
const persisted = this._registerCommon(vod, proc, filePath, masterPath, {
const persisted = this._registerCommon(vod, proc, filePath, masterPath, {
protocol: 'rtp',
protocol: 'rtp',
+ audioOnly,
sdpPath,
sdpPath,
rtpVideoPort: videoPort,
rtpVideoPort: videoPort,
rtpAudioPort: audioPort,
rtpAudioPort: audioPort,
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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); });
@@ -263,8 +263,7 @@ const http = require('http');
@@ -263,8 +263,7 @@ const http = require('http');
r = await call('POST', '/api/v1/live/thumbnails/vod/2');
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.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');
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 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);
await require('../server/objects/model').sync('vod', 3);
const audioObj = (await db.get('SELECT object_id FROM vods WHERE id = 3')).object_id;
const audioObj = (await db.get('SELECT object_id FROM vods WHERE id = 3')).object_id;
@@ -272,8 +271,8 @@ const http = require('http');
@@ -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]);
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;
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');
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');
r = await call('POST', '/api/v1/live/thumbnails/vod/1?async=1');
assert.strictEqual(r.status, 202);
assert.strictEqual(r.status, 202);
assert.ok(await waitFor(() => false, 50) || true);
assert.ok(await waitFor(() => false, 50) || true);
@@ -54,7 +54,8 @@ const express = require('express');
@@ -54,7 +54,8 @@ 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.
+ // 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) {
if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {
const audio = path.join(V, 'audio-only.webm');
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]);
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');
@@ -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)
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);
VALUES ('live', 't', ?, ?, 1, 'public', 'ok') RETURNING id`, [audio, fs.statSync(audio).size])).lastInsertRowid);
const r = await post(`/api/v1/live/thumbnails/vod/${id}`);
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);
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)');
} 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.
// 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;
const jobsBefore = (await db.all('SELECT COUNT(*) AS n FROM media_jobs'))[0].n;
@@ -101,7 +103,7 @@ const express = require('express');
@@ -101,7 +103,7 @@ const express = require('express');
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, 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);
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); });