openvibers/openvibe.mediahub Commits
Video: the AI edit, captions, chapters and a summary from media.analyze (#70)
Changes
@@@ -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 ofMedia 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 browserplays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loadednnew file mode 100644@@@ -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);@@@ -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);@@@ -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),@@@ -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 };}@@@ -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' }),nnew file mode 100644@@@ -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 };@@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarr.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) {@@@ -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>${stconst 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 thiif (!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);@@@ -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 }); }nnew file mode 100644@@@ -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();++})();
@@ -209,6 +209,16 @@ pages read Live's public JSON API server-side, signed out (Live already applies
@@ -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
(`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.
Media VOD.
- Stop completes it like an upload. Size and quota are checked as it grows, and a recording is private by default.
- 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
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
plays HLS, otherwise through hls.js, self-hosted at `public/vendor/hls.light.min.js` (hls.js 1.7.3, Apache-2.0, loaded
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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);
@@ -72,7 +72,8 @@ async function createApp(opts = {}) {
@@ -72,7 +72,8 @@ async function createApp(opts = {}) {
const pics = createPics({ s, media, config, log });
const pics = createPics({ s, media, config, log });
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
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.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -76,6 +76,12 @@ function load(env = process.env) {
@@ -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
// 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
// hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is
// MEDIAHUB_PICS_ANONYMOUS=1 then.
// 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.
// Uploads on openvibe.video (server/video/uploads.js): the bytes are OpenVibe.Media VODs in this tenant.
videoUploads: {
videoUploads: {
maxBytes: size(env.VIDEO_UPLOAD_MAX_BYTES, 2 * GB),
maxBytes: size(env.VIDEO_UPLOAD_MAX_BYTES, 2 * GB),
@@ -51,6 +51,12 @@ async function start() {
@@ -51,6 +51,12 @@ async function start() {
sweep.unref();
sweep.unref();
timers.push(sweep);
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(); }] });
createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
return { server, ctx };
}
}
@@ -112,6 +112,8 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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. */
/** 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 }),
vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),
vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),
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 }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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 };
@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -533,7 +533,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
// ── 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 ───────────────────────────────
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
async function playlistForm(req, kind, id) {
@@ -105,7 +105,7 @@ function createUploadApi({ ctx, config }) {
@@ -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). */
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
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 up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
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
@@ -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 manage = own || who.staff;
const ready = row.status === 'ready';
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
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);
if (ready && !own) await up.view(row);
const listed = ready && row.state === 'active' && row.visibility === 'public';
const listed = ready && row.state === 'active' && row.visibility === 'public';
const channel = row.owner_username;
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
@@ -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') : ''}
${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.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') : ''}
${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>`
: 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>`
: 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>`}
: 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
@@ -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>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${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>
</div>
<aside class="ovv-side">
<aside class="ovv-side">
${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this video</h2>
${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"><
@@ -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>
<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>
<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>` : ''}
</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>
${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>
<form method="post" action="/u/${row.id}/report">
<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>
<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
@@ -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) => {
r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
const row = await up.byId(req.params.id);
const row = await up.byId(req.params.id);
const who = whoOf(req);
const who = whoOf(req);
@@ -242,7 +266,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -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;
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
try {
try {
const b = req.body || {};
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) {
} catch (err) {
if (!(err instanceof UploadError)) throw 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);
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);
@@ -134,6 +134,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -134,6 +134,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
v.status = 'ready';
v.status = 'ready';
return json(res, 200, { vod: vodPublicOf(v) });
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 === '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 === '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 }); }
if (parts.length === 5 && req.method === 'DELETE') { v.deleted = true; deletedVods.push(v.id); return json(res, 200, { ok: true }); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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();
+})();