openvibers/openvibe.mediahub Commits

Video: the AI edit, captions, chapters and a summary from media.analyze (#70)

OP OpenVibers committed 29 days ago · f1a5ad4ddf · 11 files, +330 −5

Changes

ddiff --git a/README.md b/README.md
iindex 24b95e7..1de4274 100644
---- a/README.md
++++ b/README.md
@@@ -209,6 +209,16 @@ pages read Live's public JSON API server-side, signed out (Live already applies
(`POST /api/v1/videos/recordings`, then `PUT /api/v1/videos/:id/chunks/:n`, up to 9 MB each) to the same kind of
Media VOD.
- Stop completes it like an upload. Size and quota are checked as it grows, and a recording is private by default.
++- **AI edit** (`server/video/ai-edit.js`, migration `0009_video_ai.sql`):
++ - Every few minutes, OpenVibe.AI's `media.analyze` runs on each finished upload or recording. It reads the video
++ through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
++ model (`allow_paid: false`).
++ - The transcript becomes captions (`/u/:id/captions.vtt`, a track) and a transcript panel; the highlights become
++ chapters (`/u/:id/chapters.vtt` and a list); the overview becomes an "About this video" summary, labelled as AI,
++ which the owner can make the description in one click.
++ - A run still going is joined again by its key; a failure retries with backoff, 4 attempts at most.
++ - Needs `OV_AI_INTERNAL_URL` and the Network grant `[media-hub, ai.run.create, openvibe.ai, ['media.analyze']]`;
++ `VIDEO_AI_ANALYSIS=off` turns it off.
The player plays Media's segment timeline (`hls_url`, HLS) when the recording has one: natively where the browser
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
ddiff --git a/migrations/0009_video_ai.sql b/migrations/0009_video_ai.sql
nnew file mode 100644
iindex 0000000..3a788f0
---- /dev/null
++++ b/migrations/0009_video_ai.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- The AI edit of an OpenVibe.Video upload (server/video/ai-edit.js): OpenVibe.AI's media.analyze (local speech-to-text,
++-- highlights and an overview) on the finished video. The transcript becomes captions, the highlights chapters, and the
++-- overview a suggested description the owner may use. One row per upload, kept while the upload exists.
++CREATE TABLE video_upload_ai (
++ upload_id text COLLATE "C" PRIMARY KEY REFERENCES video_uploads(id) ON DELETE CASCADE,
++ status text COLLATE "C" NOT NULL DEFAULT 'queued',
++ attempts integer NOT NULL DEFAULT 0,
++ run_id text COLLATE "C",
++ overview text,
++ chapters jsonb NOT NULL DEFAULT '[]', -- [{ start, title }] from the highlights, in time order
++ segments jsonb NOT NULL DEFAULT '[]', -- [{ start, end, text }] the transcript
++ language text COLLATE "C",
++ error text,
++ next_at text COLLATE "C", -- when to ask OpenVibe.AI again (a run still going, or a failure)
++ updated_at text COLLATE "C" NOT NULL,
++ CHECK (status IN ('queued', 'running', 'done', 'failed'))
++);
++CREATE INDEX video_upload_ai_due ON video_upload_ai (status, next_at);
ddiff --git a/server/app.js b/server/app.js
iindex 72d516f..b4ba416 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -72,7 +72,8 @@ async function createApp(opts = {}) {
const pics = createPics({ s, media, config, log });
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, fetchImpl, log };
++ const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, fetchImpl, log };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
ddiff --git a/server/config.js b/server/config.js
iindex fb57d8f..02dd960 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -76,6 +76,12 @@ function load(env = process.env) {
// this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
// hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is
// MEDIAHUB_PICS_ANONYMOUS=1 then.
++ // OpenVibe.AI (server/video/ai-edit.js): media.analyze on finished uploads, for captions, chapters and a summary.
++ // Off without OV_AI_INTERNAL_URL, or with VIDEO_AI_ANALYSIS=off.
++ ai: {
++ internalUrl: trim(env.OV_AI_INTERNAL_URL || ''),
++ videoAnalysis: env.VIDEO_AI_ANALYSIS !== 'off',
++ },
// Uploads on openvibe.video (server/video/uploads.js): the bytes are OpenVibe.Media VODs in this tenant.
videoUploads: {
maxBytes: size(env.VIDEO_UPLOAD_MAX_BYTES, 2 * GB),
ddiff --git a/server/index.js b/server/index.js
iindex 98bc9b2..f638c62 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -51,6 +51,12 @@ async function start() {
sweep.unref();
timers.push(sweep);
}
++ // OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.
++ if (ctx.videoAi.enabled) {
++ const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);
++ aiTimer.unref();
++ timers.push(aiTimer);
++ }
createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
}
ddiff --git a/server/media/client.js b/server/media/client.js
iindex bbc436b..de7f983 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -112,6 +112,8 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
/** Close the file: Media remuxes, probes, makes the thumbnail and announces it. A big file takes minutes. */
vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),
vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),
++ /** A signed URL to read the VOD's file for `ttlS` seconds (private ones too): what OpenVibe.AI reads it through. */
++ vodSignedUrl: (vodId, ttlS = 3600) => call(`/${Number(vodId)}/signed-url?ttl=${Number(ttlS)}`, { root: vods() }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
ddiff --git a/server/video/ai-edit.js b/server/video/ai-edit.js
nnew file mode 100644
iindex 0000000..49c4462
---- /dev/null
++++ b/server/video/ai-edit.js
@@@ -0,0 +1,116 @@
++'use strict';
++
++/**
++ * The AI edit of an OpenVibe.Video upload: OpenVibe.AI's media.analyze on the finished video (local speech-to-text with
++ * whisper.cpp, scene and loudness signals, highlights and an overview from a local model; never a paid model here,
++ * allow_paid false). Its results become:
++ * captions the transcript as WebVTT (/u/:id/captions.vtt), a track on the player
++ * chapters the highlights in time order, each named by what was said (/u/:id/chapters.vtt and a list)
++ * overview a short description, labelled as written by AI, which the owner may take as the video's description
++ *
++ * Media gives AI a signed URL to read (private videos too); AI keeps nothing of the video but the run. One run per
++ * attempt, keyed (media-hub.video.analyze:<id>:<attempt>): a run still going after a minute is joined again by its key
++ * on the next sweep, never started twice. A failure waits and retries (up to 4 attempts).
++ *
++ * sweep() every few minutes: queue ready uploads that have no AI row, then work the due rows (2 at a time)
++ * forUpload(row) the AI row of an upload, parsed (or null)
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const MAX_ATTEMPTS = 4;
++const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
++const clean = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
++
++/** Highlights → chapters: in time order, at least 20 s apart, each named by the words said there. */
++function chaptersOf(highlights) {
++ const list = (Array.isArray(highlights) ? highlights : [])
++ .filter((h) => h && Number.isFinite(Number(h.start)))
++ .map((h) => ({ start: Math.max(0, Math.round(Number(h.start))), title: clean(h.excerpt, 70) || 'Highlight' }))
++ .sort((a, b) => a.start - b.start);
++ const out = [];
++ for (const c of list) if (!out.length || c.start - out[out.length - 1].start >= 20) out.push(c);
++ if (out.length && out[0].start > 0) out.unshift({ start: 0, title: 'Start' });
++ return out.slice(0, 30);
++}
++
++function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = String(config.ai.internalUrl || '').replace(/\/+$/, '');
++ const enabled = Boolean(url && config.oauth.clientSecret && config.ai.videoAnalysis);
++ const tokens = enabled ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', fetch: fetchImpl,
++ }) : null;
++
++ async function run(input, idempotencyKey, target) {
++ const token = await tokens.getToken();
++ const res = await fetchImpl(`${url}/api/v1/runs?wait=60000`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify({ workflow: 'media.analyze', input, target, idempotency_key: idempotencyKey }),
++ signal: AbortSignal.timeout(75_000),
++ });
++ const body = await res.json().catch(() => null);
++ if (!res.ok && res.status !== 202) throw new Error(`OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}`);
++ return body && body.run;
++ }
++
++ const later = (minutes) => new Date(s.now() + minutes * 60_000).toISOString();
++ const save = (id, patch) => {
++ const cols = Object.keys(patch);
++ return s.db.query(`UPDATE video_upload_ai SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE upload_id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
++ };
++
++ async function work(ai) {
++ const upload = await s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [ai.upload_id]);
++ if (!upload || upload.state === 'removed' || upload.status !== 'ready') return save(ai.upload_id, { status: 'failed', error: 'The video is gone.', next_at: null });
++ // A run still going is joined by the same key; a new attempt gets a new key.
++ const attempt = ai.status === 'running' ? ai.attempts : ai.attempts + 1;
++ if (ai.status !== 'running') await save(ai.upload_id, { status: 'running', attempts: attempt, next_at: later(15) });
++ try {
++ const signed = await media.vodSignedUrl(upload.media_vod_id, 6 * 3600);
++ if (!signed.ok || !signed.data || !signed.data.url) throw new Error(`Media gave no readable URL (${signed.code || signed.status})`);
++ const r = await run({ media_url: signed.data.url, language: 'auto', allow_paid: false }, `media-hub.video.analyze:${upload.id}:${attempt}`, { service: 'media-hub', type: 'video', id: upload.id });
++ if (r && (r.status === 'queued' || r.status === 'running')) return save(ai.upload_id, { run_id: r.id || null, next_at: later(10) });
++ const out = r && r.status === 'succeeded' && r.output;
++ if (!out || !out.transcript || !Array.isArray(out.transcript.segments)) throw new Error((r && r.error && (r.error.code || r.error.detail)) || (r && r.status) || 'no result');
++ const segments = out.transcript.segments.filter((x) => x && Number.isFinite(Number(x.start)) && Number.isFinite(Number(x.end)) && clean(x.text, 500))
++ .map((x) => ({ start: Number(x.start), end: Number(x.end), text: clean(x.text, 500) })).slice(0, 5000);
++ return save(ai.upload_id, {
++ status: 'done', run_id: r.id || null, error: null, next_at: null,
++ overview: out.overview && out.overview.text ? clean(out.overview.text, 1500) : null,
++ chapters: JSON.stringify(chaptersOf(out.highlights)), segments: JSON.stringify(segments),
++ language: out.transcript.language ? clean(out.transcript.language, 8) : null,
++ });
++ } catch (err) {
++ const done = attempt >= MAX_ATTEMPTS;
++ log.warn(`[OpenVibe.Video] AI edit of ${ai.upload_id} (attempt ${attempt}):`, err.message);
++ return save(ai.upload_id, { status: done ? 'failed' : 'queued', error: clean(err.message, 300), next_at: done ? null : later(2 ** attempt * 5) });
++ }
++ }
++
++ let sweeping = false;
++ async function sweep() {
++ if (!enabled || sweeping) return 0;
++ sweeping = true;
++ try {
++ await s.db.query(`INSERT INTO video_upload_ai (upload_id, status, updated_at)
++ SELECT u.id, 'queued', $1 FROM video_uploads u LEFT JOIN video_upload_ai a ON a.upload_id = u.id
++ WHERE u.status = 'ready' AND u.state <> 'removed' AND a.upload_id IS NULL
++ ON CONFLICT DO NOTHING`, [s.iso()]);
++ const due = await s.db.many(`SELECT * FROM video_upload_ai WHERE status IN ('queued', 'running') AND (next_at IS NULL OR next_at <= $1)
++ ORDER BY updated_at LIMIT 2`, [s.iso()]);
++ for (const ai of due) await work(ai);
++ return due.length;
++ } finally { sweeping = false; }
++ }
++
++ return {
++ enabled, sweep,
++ async forUpload(row) {
++ const ai = await s.db.maybe('SELECT * FROM video_upload_ai WHERE upload_id = $1', [row.id]);
++ return ai ? { ...ai, chapters: parse(ai.chapters, []), segments: parse(ai.segments, []) } : null;
++ },
++ };
++}
++
++module.exports = { createAiEdit, chaptersOf };
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex b3f88c0..6a83d91 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
-- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card });
++ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt });
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 5387323..988d89e 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -105,7 +105,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
-- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card } = h;
++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt } = h;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@@ -184,6 +184,9 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
const manage = own || who.staff;
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
++ const ai = ready ? await ctx.videoAi.forUpload(row) : null;
++ const aiDone = Boolean(ai && ai.status === 'done');
++ const tracks = aiDone ? html`${ai.segments.length ? html`<track kind="captions" label="Captions (AI)" src="/u/${row.id}/captions.vtt" default>` : ''}${ai.chapters.length ? html`<track kind="chapters" src="/u/${row.id}/chapters.vtt">` : ''}` : '';
if (ready && !own) await up.view(row);
const listed = ready && row.state === 'active' && row.visibility === 'public';
const channel = row.owner_username;
@@@ -202,7 +205,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
${req.query.saved === '1' ? notice('Saved.', 'ok') : ''}${req.query.reported === '1' ? notice('Thanks: the report reached OpenVibe staff.', 'ok') : ''}
${row.state === 'hidden' ? notice('Hidden after reports: only you and OpenVibe staff can see it until staff decide.', 'warn') : ''}
${row.visibility === 'private' && ready ? notice('Private: only you (and OpenVibe staff) can see this video.', 'info') : ''}
--${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${hlsAttrs(vod)}></video></div>`
++${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}"` : ''} src="${fileOf(vod) || ''}" data-ovv-video${hlsAttrs(vod)}>${tracks}</video></div>`
: html`<div class="ovv-player-wrap ovv-processing" role="status">${row.status === 'processing' ? html`<p><i class="fa-solid fa-gear fa-spin" aria-hidden="true"></i> Processing: OpenVibe.Media is making it playable. This page reloads by itself.</p>`
: row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
: html`<p>The upload stopped before the end (${row.parts_received} of ${row.parts_expected} parts). Upload the file again from <a href="/upload">Upload</a>.</p>`}</div>`}
@@@ -213,6 +216,10 @@ ${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" ta
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
++${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
++${manage && ai.overview !== row.description ? html`<form method="post" action="/u/${row.id}/manage"><input type="hidden" name="description" value="${ai.overview}"><button class="ovv-btn" type="submit">Use this as the description</button></form>` : ''}</section>` : ''}
++${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${ai.segments.length} lines)</span></summary><div class="ovv-transcript-body">${ai.segments.map((x) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${x.start}">${vttTime(x.start).slice(0, 8)}</button> <span>${x.text}</span></p>`)}</div></details>` : ''}
++${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}
</div>
<aside class="ovv-side">
${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this video</h2>
@@@ -224,6 +231,7 @@ ${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><
<details class="ovv-danger"><summary>Delete this video</summary>
<form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
</details></section>` : ''}
++${aiDone && ai.chapters.length > 1 ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${ai.chapters.map((c) => html`<li><a href="/u/${row.id}?t=${c.start}" data-ovv-chapter="${c.start}"><span class="ovv-time">${vttTime(c.start).slice(0, 8)}</span><span>${c.title}</span></a></li>`)}</ol><p class="ovv-fine">From the video's highlights (AI).</p></section>` : ''}
${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>
<form method="post" action="/u/${row.id}/report">
<fieldset><legend>What is wrong?</legend>${Object.entries(REASON_TEXT).map(([k, t]) => html`<label class="ovv-radio"><input type="radio" name="reason" value="${k}" required> ${t}</label>`)}</fieldset>
@@@ -235,6 +243,22 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
});
});
++ // The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
++ async function aiOf(req) {
++ const row = await up.byId(req.params.id);
++ if (!up.canSee(row, whoOf(req)) || row.status !== 'ready') return null;
++ const ai = await ctx.videoAi.forUpload(row);
++ return ai && ai.status === 'done' ? { row, ai } : null;
++ }
++ r.get('/u/:id/captions.vtt', async (req, res) => {
++ const x = await aiOf(req);
++ return x && x.ai.segments.length ? sendVtt(res, renderCaptionsVtt(x.ai.segments)) : notFoundVtt(req, res);
++ });
++ r.get('/u/:id/chapters.vtt', async (req, res) => {
++ const x = await aiOf(req);
++ return x && x.ai.chapters.length ? sendVtt(res, renderChaptersVtt(x.ai.chapters.map((c) => ({ offset_seconds: c.start, description: c.title })), x.row.duration_seconds)) : notFoundVtt(req, res);
++ });
++
r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
const row = await up.byId(req.params.id);
const who = whoOf(req);
@@@ -242,7 +266,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
try {
const b = req.body || {};
-- await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });
++ await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility });
} catch (err) {
if (!(err instanceof UploadError)) throw err;
return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex 21538df..be8f1b6 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -134,6 +134,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
v.status = 'ready';
return json(res, 200, { vod: vodPublicOf(v) });
}
++ if (parts[5] === 'signed-url' && req.method === 'GET') return json(res, 200, { url: `https://openvibe.media/v/vod-${v.id}.mp4?exp=9999999999&sig=stand-in`, expires_in: Number(url.searchParams.get('ttl')) || 3600 });
if (parts.length === 5 && req.method === 'GET') return json(res, 200, vodPublicOf(v));
if (parts.length === 5 && req.method === 'PUT') { Object.assign(v, JSON.parse(raw.toString('utf8') || '{}')); return json(res, 200, vodPublicOf(v)); }
if (parts.length === 5 && req.method === 'DELETE') { v.deleted = true; deletedVods.push(v.id); return json(res, 200, { ok: true }); }
ddiff --git a/test/video-ai.test.js b/test/video-ai.test.js
nnew file mode 100644
iindex 0000000..6ce794c
---- /dev/null
++++ b/test/video-ai.test.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * The AI edit of an upload (server/video/ai-edit.js): OpenVibe.AI's media.analyze on the finished video, read through
++ * Media's signed URL (private ones too), never a paid model; the transcript becomes captions, the highlights chapters,
++ * the overview a summary the owner may take as the description. A run still going is joined by the same key; a
++ * failure waits and retries with a new one.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const { chaptersOf } = require('../server/video/ai-edit');
++
++function startStub(handler) {
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const out = handler(req, raw ? JSON.parse(raw) : null);
++ res.writeHead(out.status || 200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(out.body || {}));
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
++}
++
++function vreq(base, path, { method = 'GET', cookie, form, json, body } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const h = { Host: 'openvibe.video' };
++ if (cookie) h.cookie = cookie;
++ if (method !== 'GET') h['sec-fetch-site'] = 'same-origin';
++ let data;
++ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }
++ if (body) { data = body; h['content-type'] = 'application/octet-stream'; }
++ if (data) h['content-length'] = data.length;
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, json: () => JSON.parse(text) }); });
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++}
++
++const OUTPUT = {
++ duration_seconds: 300, analyzed_seconds: 300,
++ overview: { text: 'A walk along the coast at sunset, talking about the tide and the birds.', source: 'local_model', provider: 'local', model: 'qwen' },
++ transcript: { available: true, language: 'en', text: 'hello', segments: [{ start: 0.5, end: 4, text: 'Hello from the coast.' }, { start: 4, end: 9.2, text: 'The tide is coming in.' }] },
++ highlights: [{ start: 130, end: 140, score: 0.8, reasons: ['speech'], excerpt: 'Look at those birds over the water' }, { start: 40, end: 50, score: 0.9, reasons: ['loud'], excerpt: 'Waves' }, { start: 45, end: 55, score: 0.5, reasons: ['speech'], excerpt: 'too close to the last one' }],
++};
++
++(async () => {
++ await check('chapters: in time order, 20 s apart, a Start chapter, named by what was said', () => {
++ assert.deepStrictEqual(chaptersOf(OUTPUT.highlights), [{ start: 0, title: 'Start' }, { start: 40, title: 'Waves' }, { start: 130, title: 'Look at those birds over the water' }]);
++ assert.deepStrictEqual(chaptersOf(null), []);
++ });
++
++ const runs = [];
++ let mode = 'running';
++ const ai = await startStub((req, body) => {
++ runs.push({ path: req.url, body, auth: req.headers.authorization });
++ if (mode === 'fail') return { status: 503, body: { code: 'ai.unavailable' } };
++ if (mode === 'running') return { status: 202, body: { run: { id: 'run_1', status: 'running' } } };
++ return { status: 201, body: { run: { id: 'run_1', status: 'succeeded', output: OUTPUT } } };
++ });
++ const live = await startStub(() => ({ body: { vods: [], clips: [], items: [], hasMore: false } }));
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, OV_AI_INTERNAL_URL: ai.url } });
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const kimCookie = t.signIn(kim);
++ const samCookie = t.signIn(sam);
++ let id;
++
++ await check('a finished private upload is analysed through a signed URL, never a paid model; a run still going is joined by its key', async () => {
++ const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Coast', size: 10, content_type: 'video/mp4', name: 'c.mp4', visibility: 'private' }, cookie: kimCookie });
++ id = start.json().upload.id;
++ await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: Buffer.alloc(10, 1), cookie: kimCookie });
++ await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
++ for (let i = 0; i < 20 && !(await vreq(t.base, `/u/${id}`, { cookie: kimCookie })).text.includes('data-ovv-video'); i++) await new Promise((r) => setTimeout(r, 50));
++ assert.ok(t.ctx.videoAi.enabled);
++ await t.ctx.videoAi.sweep();
++ assert.strictEqual(runs.length, 1);
++ const r = runs[0];
++ assert.strictEqual(r.body.workflow, 'media.analyze');
++ assert.deepStrictEqual(r.body.input, { media_url: 'https://openvibe.media/v/vod-1.mp4?exp=9999999999&sig=stand-in', language: 'auto', allow_paid: false });
++ assert.strictEqual(r.body.idempotency_key, `media-hub.video.analyze:${id}:1`);
++ assert.match(r.path, /^\/api\/v1\/runs\?wait=60000$/);
++ assert.ok(String(r.auth).startsWith('Bearer '));
++ const row = await t.ctx.videoAi.forUpload({ id });
++ assert.strictEqual(row.status, 'running');
++ // Due again later: join the same run by the same key.
++ await t.ctx.s.db.query('UPDATE video_upload_ai SET next_at = $1 WHERE upload_id = $2', ['2000-01-01T00:00:00.000Z', id]);
++ mode = 'done';
++ await t.ctx.videoAi.sweep();
++ assert.strictEqual(runs[1].body.idempotency_key, `media-hub.video.analyze:${id}:1`, 'the same key: joined, not a second analysis');
++ assert.strictEqual((await t.ctx.videoAi.forUpload({ id })).status, 'done');
++ });
++
++ await check('the page: captions and chapters tracks, the transcript, the chapters, the summary and the owner\'s button', async () => {
++ const page = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });
++ assert.ok(page.text.includes(`<track kind="captions" label="Captions (AI)" src="/u/${id}/captions.vtt" default>`));
++ assert.ok(page.text.includes(`<track kind="chapters" src="/u/${id}/chapters.vtt">`));
++ assert.ok(page.text.includes('A walk along the coast at sunset') && page.text.includes('Written by AI'));
++ assert.ok(page.text.includes('Use this as the description'));
++ assert.ok(page.text.includes('The tide is coming in.'), 'the transcript');
++ assert.ok(page.text.includes('Look at those birds over the water'), 'the chapters');
++ const vtt = await vreq(t.base, `/u/${id}/captions.vtt`, { cookie: kimCookie });
++ assert.ok(vtt.text.startsWith('WEBVTT') && vtt.text.includes('00:00:04.000 --> 00:00:09.200'));
++ const ch = await vreq(t.base, `/u/${id}/chapters.vtt`, { cookie: kimCookie });
++ assert.ok(ch.text.includes('Waves'));
++ assert.strictEqual((await vreq(t.base, `/u/${id}/captions.vtt`, { cookie: samCookie })).status, 404, 'private: not for someone else');
++ const use = await vreq(t.base, `/u/${id}/manage`, { method: 'POST', cookie: kimCookie, form: { description: OUTPUT.overview.text } });
++ assert.strictEqual(use.status, 303);
++ const row = await t.ctx.videoUploads.byId(id);
++ assert.strictEqual(row.description, OUTPUT.overview.text);
++ assert.deepStrictEqual([row.title, row.visibility], ['Coast', 'private'], 'a description-only change keeps the title and visibility');
++ });
++
++ await check('a failure waits and retries with a new key; four attempts at most', async () => {
++ await t.ctx.s.db.query("UPDATE video_upload_ai SET status = 'queued', attempts = 0, next_at = NULL WHERE upload_id = $1", [id]);
++ mode = 'fail';
++ await t.ctx.videoAi.sweep();
++ let row = await t.ctx.videoAi.forUpload({ id });
++ assert.deepStrictEqual([row.status, row.attempts], ['queued', 1]);
++ assert.ok(row.next_at > new Date().toISOString(), 'waits before the next try');
++ await t.ctx.s.db.query("UPDATE video_upload_ai SET attempts = 3, next_at = NULL WHERE upload_id = $1", [id]);
++ await t.ctx.videoAi.sweep();
++ row = await t.ctx.videoAi.forUpload({ id });
++ assert.strictEqual(row.status, 'failed', 'the fourth failure is final');
++ assert.strictEqual(runs[runs.length - 1].body.idempotency_key, `media-hub.video.analyze:${id}:4`);
++ });
++
++ await t.close();
++ await ai.close();
++ await live.close();
++ done();
++})();