openvibers/openvibe.mediahub Commits

Video: the AI cut, an edited copy of an upload with the pauses cut out or a highlights reel (#80)

OP OpenVibers committed 28 days ago · 3a17aa1e21 · 14 files, +724 −5

The owner's AI edit panel makes a copy and never touches the original: pauses of 1.5 s or more become half a second
(tight: 0.7 s), or the best six moments the AI edit found make a reel of at most 3 minutes. One cut at a time under
nice 19: ffmpeg reads the original through Media's signed URL on MEDIAHUB_MEDIA_URL, finds the pauses with
silencedetect, re-encodes the kept ranges (trim/concat, H.264 veryfast, AAC, at most 1080 lines) as fragmented MP4
and sends it to Media in 8 MB pieces as it is made, as a new private upload (video_uploads.edited_from) through the
recording path. Migration 0015 (video_cuts; the AI edit now keeps its highlights with their ends). One waiting cut
per person, VIDEO_CUTS_PER_DAY (5); a cut a restart interrupted is retried once. POST /u/:id/cut and
/api/v1/videos/:id/cuts.

Changes

ddiff --git a/.env.example b/.env.example
iindex ce20d7f..be94991 100644
---- a/.env.example
++++ b/.env.example
@@@ -61,6 +61,12 @@ MEDIAHUB_MEDIA_APP=media-hub
# are the host-internal addresses (production sets them to the loopback address); links people click
# use livePublicUrl.
MEDIAHUB_VIDEO_URL=https://openvibe.video
++# The AI cut of an upload (server/video/cut.js): edited copies made here with ffmpeg, one at a time under nice.
++#VIDEO_CUTS=off
++#VIDEO_CUTS_PER_DAY=5
++#VIDEO_CUTS_MAX_SOURCE_SECONDS=10800
++#VIDEO_CUTS_SILENCE_DB=40
++#VIDEO_CUTS_THREADS=2
MEDIAHUB_LIVE_URL=https://openvibe.live
MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
MEDIAHUB_CHAT_URL=https://openvibe.live
ddiff --git a/README.md b/README.md
iindex c354a64..03836f0 100644
---- a/README.md
++++ b/README.md
@@@ -236,6 +236,21 @@ pages read Live's public JSON API server-side, signed out (Live already applies
- 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.
++- **AI cut** (`server/video/cut.js`, migration `0015_video_cuts.sql`): the owner's "AI edit" panel on a finished upload
++ makes an edited copy and never touches the original.
++ - **Cut the pauses** (pauses of 1.5 s or more become half a second, lead-in and tail silence go), **cut tight** (0.7 s
++ or more become 0.3 s), or a **highlights reel** (the best six moments the AI edit found, at most 3 minutes; older
++ AI edits fall back to the chapter starts).
++ - One cut at a time on the host, under `nice 19` with two threads: ffmpeg reads the original through Media's signed
++ URL on `MEDIAHUB_MEDIA_URL`, finds the pauses (`silencedetect` at -40 dB, sound only), re-encodes the kept ranges
++ (trim and concat, H.264 veryfast and AAC, at most 1080 lines; a browser recording may have one keyframe in minutes,
++ so it is not cut on keyframes) as fragmented MP4 on stdout, and sends it to Media in 8 MB pieces as it is made: no
++ file on MediaHub's disk.
++ - The copy is a new private upload of the same person (`video_uploads.edited_from`) through the recording path, so
++ it gets Media's processing, its own AI captions and chapters, and counts toward the daily uploads and the quota.
++ - One waiting or running cut per person, `VIDEO_CUTS_PER_DAY` (5) a day, originals up to
++ `VIDEO_CUTS_MAX_SOURCE_SECONDS` (3 h); a cut a restart interrupted loses its partial copy and is retried once.
++ `POST /u/:id/cut`, `POST /api/v1/videos/:id/cuts` `{ mode }`, `GET …/cuts`; `VIDEO_CUTS=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/0015_video_cuts.sql b/migrations/0015_video_cuts.sql
nnew file mode 100644
iindex 0000000..62d803b
---- /dev/null
++++ b/migrations/0015_video_cuts.sql
@@@ -0,0 +1,33 @@
++-- phase: expand
++-- The AI cut of an OpenVibe.Video upload (server/video/cut.js): an edited copy with the pauses taken out, or a reel of
++-- the highlights the AI edit found. The copy is a new private upload of the same person (video_uploads.edited_from);
++-- the original is never changed.
++CREATE TABLE video_cuts (
++ id text COLLATE "C" PRIMARY KEY, -- vcut_<ULID>
++ upload_id text COLLATE "C" NOT NULL REFERENCES video_uploads(id) ON DELETE CASCADE, -- the original
++ owner text COLLATE "C" NOT NULL, -- user:usr_… (the original's owner)
++ mode text COLLATE "C" NOT NULL, -- pauses | tight | highlights
++ status text COLLATE "C" NOT NULL DEFAULT 'queued',
++ ranges jsonb, -- [[start, end], …] kept, in seconds, once planned
++ source_seconds double precision,
++ kept_seconds double precision,
++ removed_count integer, -- pauses taken out (pauses, tight)
++ progress real NOT NULL DEFAULT 0, -- 0..1 while rendering
++ result_upload_id text COLLATE "C", -- the edited copy (vup_…), made when rendering starts
++ attempts integer NOT NULL DEFAULT 0,
++ error text,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ started_at text COLLATE "C",
++ finished_at text COLLATE "C",
++ CHECK (mode IN ('pauses', 'tight', 'highlights')),
++ CHECK (status IN ('queued', 'measuring', 'rendering', 'done', 'failed'))
++);
++CREATE INDEX video_cuts_upload ON video_cuts (upload_id, created_at DESC);
++CREATE INDEX video_cuts_owner ON video_cuts (owner, created_at DESC);
++CREATE INDEX video_cuts_due ON video_cuts (status, created_at);
++
++-- The edited copy points at its original (shown to its owner); the highlights the AI edit found, with their ends
++-- (chapters keep only the starts), for the highlights reel.
++ALTER TABLE video_uploads ADD COLUMN edited_from text COLLATE "C";
++ALTER TABLE video_upload_ai ADD COLUMN highlights jsonb;
ddiff --git a/public/css/video.css b/public/css/video.css
iindex a560b53..1dc7289 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -213,6 +213,13 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-description p { white-space: pre-line; margin: 0; }
.ovv-report fieldset { border: 0; padding: 0; margin: 8px 0; display: grid; gap: 6px; }
.ovv-radio { display: flex; gap: 8px; align-items: baseline; }
++.ovv-cut-modes { border: 0; padding: 0; margin: 10px 0 12px; display: grid; gap: 8px; }
++.ovv-cut-modes legend { padding: 0; margin-bottom: 4px; font-size: .85rem; font-weight: 700; color: var(--ovv-dim); }
++.ovv-cut-modes .ovv-radio { flex-wrap: wrap; column-gap: 8px; row-gap: 0; }
++.ovv-cut-modes .ovv-fine { flex-basis: 100%; margin: 0 0 0 24px; }
++.ovv-cuts { list-style: none; margin: 12px 0 0; padding: 10px 0 0; border-top: 1px solid var(--ovv-line); display: grid; gap: 6px; font-size: .86rem; color: var(--ovv-dim); }
++.ovv-cuts b { color: var(--text-primary, #e6edf7); }
++.ovv-edited-from { margin: 0 0 10px; }
.ovv-table { width: 100%; border-collapse: collapse; }
.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
/* Recording (/record) */
ddiff --git a/server/account.js b/server/account.js
iindex 86a1fa4..2f373fd 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -46,6 +46,8 @@ const TABLES = [
// OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other
// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
++ // The AI cuts a person asked for (migrations/0015_video_cuts.sql); the edited copies are uploads of their own.
++ { table: 'video_cuts', subject: 'owner', value: AS_REQUESTER, file: 'video_cuts.json' },
{ table: 'video_uploads', subject: 'owner', value: AS_REQUESTER, file: 'videos.json' },
// Sync keys (migrations/0011_sync_keys.sql): their names and dates, never the key's hash.
{ table: 'mh_sync_keys', subject: 'owner', value: AS_REQUESTER, file: 'sync_keys.json', columns: ['id', 'name', 'prefix', 'created_at', 'last_used_at', 'revoked_at'] },
ddiff --git a/server/app.js b/server/app.js
iindex cd00d72..59353d1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -76,10 +76,11 @@ async function createApp(opts = {}) {
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
++ const videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
const syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, videoUploads, videoAi, syncKeys, drops, videoSearch, fetchImpl, log };
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, videoUploads, videoAi, videoCuts, syncKeys, drops, videoSearch, 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 b4e0c1e..090f1b4 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -99,6 +99,19 @@ function load(env = process.env) {
quotaBytes: size(env.VIDEO_UPLOAD_QUOTA_BYTES, 20 * GB),
perDay: Math.max(1, size(env.VIDEO_UPLOADS_PER_DAY, 10)),
},
++ // The AI cut (server/video/cut.js): an edited copy of an upload, made here with ffmpeg one at a time under
++ // nice. VIDEO_CUTS=off turns it off.
++ videoCuts: {
++ enabled: env.VIDEO_CUTS !== 'off',
++ perDay: Math.max(1, size(env.VIDEO_CUTS_PER_DAY, 5)),
++ maxSourceSeconds: Math.max(60, size(env.VIDEO_CUTS_MAX_SOURCE_SECONDS, 3 * 3600)),
++ silenceDb: -Math.abs(Number(env.VIDEO_CUTS_SILENCE_DB) || 40),
++ threads: Math.max(1, size(env.VIDEO_CUTS_THREADS, 2)),
++ nice: Math.min(19, Math.max(0, size(env.VIDEO_CUTS_NICE, 19))),
++ timeoutMs: Math.max(60_000, size(env.VIDEO_CUTS_TIMEOUT_MS, 6 * 3600 * 1000)),
++ ffmpeg: env.FFMPEG_PATH || 'ffmpeg',
++ ffprobe: env.FFPROBE_PATH || 'ffprobe',
++ },
pics: {
url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),
maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),
ddiff --git a/server/index.js b/server/index.js
iindex e9ac61e..ff2fee0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -63,6 +63,13 @@ async function start() {
aiTimer.unref();
timers.push(aiTimer);
}
++ // The AI cut (server/video/cut.js): one edited copy at a time; a cut a restart interrupted is retried once.
++ if (ctx.videoCuts.enabled) {
++ ctx.videoCuts.recover().catch((err) => console.warn('[OpenVibe.Video] cut recovery failed:', err && err.message));
++ const cutTimer = setInterval(() => { ctx.videoCuts.sweep().catch((err) => console.warn('[OpenVibe.Video] cut sweep failed:', err && err.message)); }, 30_000);
++ cutTimer.unref();
++ timers.push(cutTimer);
++ }
// Public uploads in OpenVibe.Search (server/video/search-index.js; off without MEDIAHUB_SEARCH_URL).
ctx.videoSearch.start();
createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => ctx.videoSearch.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
ddiff --git a/server/video/ai-edit.js b/server/video/ai-edit.js
iindex 5b5b887..cf58ec0 100644
---- a/server/video/ai-edit.js
++++ b/server/video/ai-edit.js
@@@ -34,6 +34,15 @@ function chaptersOf(highlights) {
return out.slice(0, 30);
}
++/** Highlights as the AI cut's reel wants them: start, end and score, in time order (at most 10). */
++function highlightsOf(highlights) {
++ return (Array.isArray(highlights) ? highlights : [])
++ .filter((h) => h && Number.isFinite(Number(h.start)) && Number.isFinite(Number(h.end)) && Number(h.end) > Number(h.start))
++ .map((h) => ({ start: Math.max(0, Number(h.start)), end: Number(h.end), score: Number(h.score) || 0, excerpt: clean(h.excerpt, 120) }))
++ .sort((a, b) => a.start - b.start)
++ .slice(0, 10);
++}
++
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);
@@@ -97,6 +106,7 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
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),
++ highlights: JSON.stringify(highlightsOf(out.highlights)),
language: out.transcript.language ? clean(out.transcript.language, 8) : null,
});
} catch (err) {
@@@ -126,9 +136,9 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
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;
++ return ai ? { ...ai, chapters: parse(ai.chapters, []), segments: parse(ai.segments, []), highlights: parse(ai.highlights, null) } : null;
},
};
}
--module.exports = { createAiEdit, chaptersOf };
++module.exports = { createAiEdit, chaptersOf, highlightsOf };
ddiff --git a/server/video/cut.js b/server/video/cut.js
nnew file mode 100644
iindex 0000000..b9771ad
---- /dev/null
++++ b/server/video/cut.js
@@@ -0,0 +1,336 @@
++'use strict';
++
++/**
++ * The AI cut of an OpenVibe.Video upload: an edited copy, made here, of a finished upload of the person's own.
++ *
++ * pauses every pause of 1.5 seconds or more becomes half a second (lead-in and tail silence go)
++ * tight every pause of 0.7 seconds or more becomes 0.3 seconds
++ * highlights a reel of the best moments the AI edit found (server/video/ai-edit.js), in time order, at most 3 minutes
++ *
++ * The copy is a new private upload of the same person (video_uploads.edited_from), made through the same path as a
++ * browser recording (uploads.startRecording → chunk → complete), so Media gives it a probe, thumbnail and HLS, the AI
++ * edit gives it captions and chapters, and it counts toward the person's daily uploads and storage. The original is
++ * never changed.
++ *
++ * One cut at a time on this host: ffmpeg reads the original through Media's signed URL on MEDIAHUB_MEDIA_URL (never a
++ * public address), finds the pauses (silencedetect, sound only), then re-encodes the kept ranges (trim and concat, H.264
++ * veryfast and AAC, at most 1080 lines) as fragmented MP4 on its stdout, which goes to Media in 8 MB pieces while it is
++ * made: no file on this disk. ffmpeg runs under `nice 19` with two threads, so live streams on the box keep the CPU.
++ * A copy is re-encoded rather than cut on keyframes because a browser recording (WebM) may have one keyframe in minutes.
++ *
++ * plan…(…) the pure parts: pauses → kept ranges, highlights → reel
++ * request(row, who, mode) queue a cut (one waiting or running per person, VIDEO_CUTS_PER_DAY a day)
++ * forUpload(row) the cuts of an upload, newest first
++ * sweep() the worker: one queued cut at a time; a cut left mid-way by a restart is retried once
++ */
++const { spawn } = require('child_process');
++const { UploadError } = require('./uploads');
++
++const MODES = Object.freeze({
++ pauses: { label: 'pauses cut', minPause: 1.5, keep: 0.25 },
++ tight: { label: 'tight cut', minPause: 0.7, keep: 0.15 },
++ highlights: { label: 'highlights', reelMax: 180, picks: 6 },
++});
++const PIECE = 8 * 1024 * 1024;
++const MAX_RANGES = 400;
++const r2 = (n) => Math.round(n * 100) / 100;
++
++/** ffmpeg's silencedetect lines → [{ start, end }] (a silence still open at the end runs to `duration`). */
++function parseSilence(text, duration) {
++ const out = [];
++ let open = null;
++ for (const line of String(text).split('\n')) {
++ let m;
++ if ((m = /silence_start:\s*(-?[\d.]+)/.exec(line))) open = Math.max(0, Number(m[1]));
++ else if ((m = /silence_end:\s*([\d.]+)/.exec(line)) && open != null) { out.push({ start: open, end: Number(m[1]) }); open = null; }
++ }
++ if (open != null && duration > open) out.push({ start: open, end: duration });
++ return out;
++}
++
++/**
++ * Pauses → the ranges kept. Each pause of at least `minPause` keeps `keep` seconds at either side (so a cut never
++ * clips a word); one at the very start or end goes entirely but for `keep`. Null when nothing worth cutting.
++ */
++function planPauses(silences, duration, mode = 'pauses') {
++ const m = MODES[mode];
++ if (!m || !m.minPause || !(duration > 0)) return null;
++ let removed = [];
++ for (const sp of (silences || []).slice().sort((a, b) => a.start - b.start)) {
++ const a = Math.max(0, Number(sp.start));
++ const b = Math.min(duration, Number(sp.end));
++ if (!(b - a >= m.minPause)) continue;
++ const from = a <= 0.05 ? 0 : a + m.keep;
++ const to = b >= duration - 0.05 ? duration : b - m.keep;
++ if (to - from > 0.1) removed.push([from, to]);
++ }
++ // Too many pieces for one filter graph: keep the longest cuts.
++ if (removed.length > MAX_RANGES - 1) {
++ removed = removed.sort((x, y) => (y[1] - y[0]) - (x[1] - x[0])).slice(0, MAX_RANGES - 1).sort((x, y) => x[0] - y[0]);
++ }
++ const ranges = [];
++ let at = 0;
++ for (const [from, to] of removed) {
++ if (from > at + 0.05) ranges.push([r2(at), r2(from)]);
++ at = Math.max(at, to);
++ }
++ if (duration > at + 0.05) ranges.push([r2(at), r2(duration)]);
++ const kept = ranges.reduce((n, [a, b]) => n + (b - a), 0);
++ if (!ranges.length || duration - kept < 0.5) return null;
++ return { ranges, kept_seconds: r2(kept), removed_count: removed.length };
++}
++
++/**
++ * The AI edit's highlights → a reel: the best `picks`, in time order, overlaps merged, at most `reelMax` seconds.
++ * Older AI edits kept only chapter starts: each becomes a 30-second window.
++ */
++function planHighlights(ai, duration) {
++ const m = MODES.highlights;
++ if (!ai || ai.status !== 'done' || !(duration > 0)) return null;
++ let list = Array.isArray(ai.highlights) && ai.highlights.length ? ai.highlights
++ : (ai.chapters || []).filter((c) => c.start > 0 || c.title !== 'Start').map((c) => ({ start: c.start, end: c.start + 30, score: 0 }));
++ list = list.map((h) => ({ start: Math.max(0, Number(h.start)), end: Math.min(duration, Number(h.end)), score: Number(h.score) || 0 }))
++ .filter((h) => h.end - h.start >= 3)
++ .sort((a, b) => b.score - a.score).slice(0, m.picks)
++ .sort((a, b) => a.start - b.start);
++ const ranges = [];
++ let total = 0;
++ for (const h of list) {
++ const last = ranges[ranges.length - 1];
++ if (last && h.start <= last[1]) { total += Math.max(0, h.end - last[1]); last[1] = Math.max(last[1], h.end); }
++ else { ranges.push([h.start, h.end]); total += h.end - h.start; }
++ if (total >= m.reelMax) break;
++ }
++ const out = ranges.map(([a, b]) => [r2(a), r2(b)]);
++ const kept = out.reduce((n, [a, b]) => n + (b - a), 0);
++ if (out.length < 2 || kept < 20 || kept > duration - 10) return null;
++ return { ranges: out, kept_seconds: r2(kept), removed_count: null };
++}
++
++/** The filter graph for the kept ranges (trim/atrim each, concat, scale to at most 1080 lines, even sizes). */
++function filterGraph(ranges, { audio = true } = {}) {
++ const parts = [];
++ const pads = [];
++ ranges.forEach(([a, b], i) => {
++ parts.push(`[0:v:0]trim=start=${a}:end=${b},setpts=PTS-STARTPTS[v${i}]`);
++ if (audio) parts.push(`[0:a:0]atrim=start=${a}:end=${b},asetpts=PTS-STARTPTS[a${i}]`);
++ pads.push(audio ? `[v${i}][a${i}]` : `[v${i}]`);
++ });
++ parts.push(`${pads.join('')}concat=n=${ranges.length}:v=1:a=${audio ? 1 : 0}[vc]${audio ? '[ac]' : ''}`);
++ parts.push("[vc]scale=-2:'trunc(min(ih,1080)/2)*2',format=yuv420p[vo]");
++ return parts.join(';\n');
++}
++
++function createCuts({ s, media, uploads, videoAi = null, config, log = console, spawnImpl = spawn }) {
++ const cfg = config.videoCuts;
++ const enabled = Boolean(cfg && cfg.enabled);
++ const mediaBase = String((config.media && config.media.url) || '').replace(/\/+$/, '');
++ const byId = (id) => s.db.maybe('SELECT * FROM video_cuts WHERE id = $1', [id]);
++ const save = async (id, patch) => {
++ const cols = Object.keys(patch);
++ await s.db.query(`UPDATE video_cuts SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`,
++ [id, s.iso(), ...cols.map((c) => (c === 'ranges' && patch[c] != null ? JSON.stringify(patch[c]) : patch[c]))]);
++ };
++
++ /** ffmpeg (or ffprobe) under nice, its stdout handed to `onStdout` when given; → { code, stderr }. */
++ function run(bin, args, { onStdout = null, onStderr = null, timeoutMs = cfg.timeoutMs } = {}) {
++ return new Promise((resolve) => {
++ const child = spawnImpl('nice', ['-n', String(cfg.nice), bin, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
++ let stderr = '';
++ let done = false;
++ const finish = (code) => { if (!done) { done = true; clearTimeout(timer); resolve({ code, stderr: stderr.slice(-20000) }); } };
++ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish(-1); }, timeoutMs);
++ if (timer.unref) timer.unref();
++ child.stderr.on('data', (d) => { stderr += d; if (stderr.length > 200000) stderr = stderr.slice(-100000); if (onStderr) onStderr(String(d)); });
++ if (onStdout) onStdout(child.stdout, child);
++ else child.stdout.resume();
++ child.on('error', () => finish(-2));
++ child.on('close', (code) => finish(code));
++ });
++ }
++
++ async function probe(url) {
++ let out = '';
++ const r = await run(cfg.ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', url], {
++ onStdout: (stdout) => stdout.on('data', (d) => { out += d; }), timeoutMs: 60_000,
++ });
++ if (r.code !== 0) return null;
++ try {
++ const j = JSON.parse(out);
++ const streams = j.streams || [];
++ return {
++ duration: Number(j.format && j.format.duration) || 0,
++ video: streams.some((x) => x.codec_type === 'video'),
++ audio: streams.some((x) => x.codec_type === 'audio'),
++ };
++ } catch { return null; }
++ }
++
++ /** The original's bytes, read from Media's own address (the signature covers the id and expiry, not the host). */
++ async function sourceUrl(row) {
++ const signed = await media.vodSignedUrl(row.media_vod_id, 6 * 3600);
++ if (!signed.ok || !signed.data || !signed.data.url) throw new Error(`Media gave no readable address (${signed.code || signed.status})`);
++ const u = new URL(signed.data.url);
++ return mediaBase ? `${mediaBase}${u.pathname}${u.search}` : signed.data.url;
++ }
++
++ async function request(row, who, mode) {
++ if (!enabled) throw new UploadError(503, 'cut.off', 'The AI cut is not available here.');
++ if (!MODES[mode]) throw new UploadError(422, 'cut.mode', 'Choose pauses, tight or highlights.');
++ if (!who || who.requester !== row.owner) throw new UploadError(403, 'cut.not_yours', 'Only the person who uploaded a video can cut it.');
++ if (row.status !== 'ready' || row.state !== 'active') throw new UploadError(409, 'cut.not_ready', 'The video has to be ready (and not hidden) first.');
++ if (row.edited_from) throw new UploadError(409, 'cut.is_copy', 'This is already an edited copy: cut the original instead.');
++ if (Number(row.duration_seconds) > cfg.maxSourceSeconds) throw new UploadError(413, 'cut.too_long', `The AI cut takes videos up to ${Math.round(cfg.maxSourceSeconds / 3600)} hours.`);
++ let plan = null;
++ if (mode === 'highlights') {
++ plan = planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds));
++ if (!plan) throw new UploadError(409, 'cut.no_highlights', 'The AI edit found no highlights to make a reel from.');
++ }
++ const pending = await s.db.value("SELECT count(*) FROM video_cuts WHERE owner = $1 AND status IN ('queued', 'measuring', 'rendering')", [row.owner]);
++ if (Number(pending) > 0) throw new UploadError(409, 'cut.busy', 'One of your cuts is still being made. Start the next when it is done.');
++ const today = Number(await s.db.value('SELECT count(*) FROM video_cuts WHERE owner = $1 AND created_at >= $2', [row.owner, s.iso().slice(0, 10)]));
++ if (today >= cfg.perDay) throw new UploadError(429, 'cut.daily_limit', `That is ${cfg.perDay} cuts today. Try again tomorrow.`);
++ const id = s.newId('vcut');
++ const now = s.iso();
++ await s.db.query(`INSERT INTO video_cuts (id, upload_id, owner, mode, ranges, source_seconds, kept_seconds, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
++ [id, row.id, row.owner, mode, plan ? JSON.stringify(plan.ranges) : null, Number(row.duration_seconds) || null, plan ? plan.kept_seconds : null, now]);
++ kick();
++ return await byId(id);
++ }
++
++ async function measure(cut, url, duration) {
++ const m = MODES[cut.mode];
++ const r = await run(cfg.ffmpeg, ['-hide_banner', '-nostdin', '-i', url, '-map', '0:a:0', '-vn', '-af', `silencedetect=n=${cfg.silenceDb}dB:d=${m.minPause}`, '-f', 'null', '-']);
++ if (r.code !== 0) throw new Error('ffmpeg could not read the sound');
++ return planPauses(parseSilence(r.stderr, duration), duration, cut.mode);
++ }
++
++ /** Render the kept ranges into the copy, piece by piece, while ffmpeg makes them. */
++ async function render(cut, url, ranges, kept, info, copy) {
++ let n = 0;
++ let held = [];
++ let heldBytes = 0;
++ let failure = null;
++ let lastSaved = 0;
++ const send = async (buf) => {
++ n += 1;
++ copy = await uploads.chunk(copy, n, buf);
++ };
++ const graph = filterGraph(ranges, { audio: info.audio });
++ const args = ['-hide_banner', '-nostdin', '-loglevel', 'error', '-progress', 'pipe:2', '-nostats', '-i', url,
++ '-filter_complex', graph, '-map', '[vo]', ...(info.audio ? ['-map', '[ac]', '-c:a', 'aac', '-b:a', '128k', '-ac', '2'] : ['-an']),
++ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-threads', String(cfg.threads),
++ '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', 'pipe:1'];
++ let consumer = Promise.resolve();
++ const r = await run(cfg.ffmpeg, args, {
++ onStdout: (stdout, child) => {
++ // Reading pauses while a piece goes to Media, so ffmpeg waits on the pipe instead of filling memory.
++ consumer = (async () => {
++ try {
++ for await (const d of stdout) {
++ held.push(d);
++ heldBytes += d.length;
++ if (heldBytes >= PIECE) {
++ const buf = Buffer.concat(held);
++ held = [buf.subarray(PIECE)];
++ heldBytes = held[0].length;
++ await send(buf.subarray(0, PIECE));
++ }
++ }
++ if (heldBytes) await send(Buffer.concat(held));
++ } catch (err) {
++ failure = err;
++ try { child.kill('SIGKILL'); } catch { /* gone */ }
++ stdout.resume();
++ }
++ })();
++ },
++ onStderr: (text) => {
++ const m = /out_time_us=(\d+)/.exec(text);
++ if (!m || !kept) return;
++ const p = Math.min(0.99, Number(m[1]) / 1e6 / kept);
++ if (Date.now() - lastSaved > 5000) { lastSaved = Date.now(); save(cut.id, { progress: p }).catch(() => {}); }
++ },
++ });
++ await consumer;
++ if (failure) throw failure;
++ if (r.code !== 0) throw new Error(`ffmpeg stopped (${r.code}): ${(r.stderr.split('\n').filter((l) => l && !/^[a-z_]+=/.test(l)).pop() || '').slice(0, 200)}`);
++ return copy;
++ }
++
++ async function work(cut) {
++ const original = await uploads.byId(cut.upload_id);
++ if (!original || original.state !== 'active' || original.status !== 'ready') return save(cut.id, { status: 'failed', error: 'The original is no longer available.', finished_at: s.iso() });
++ let copy = null;
++ try {
++ await save(cut.id, { status: 'measuring', attempts: cut.attempts + 1, started_at: s.iso(), progress: 0, error: null });
++ const url = await sourceUrl(original);
++ const info = await probe(url);
++ if (!info || !info.video) throw new UploadError(422, 'cut.no_video', 'There is no picture in this video to cut.');
++ const duration = info.duration || Number(original.duration_seconds) || 0;
++ let plan = cut.ranges ? { ranges: typeof cut.ranges === 'string' ? JSON.parse(cut.ranges) : cut.ranges, kept_seconds: Number(cut.kept_seconds), removed_count: null } : null;
++ if (!plan) {
++ if (!info.audio) throw new UploadError(422, 'cut.no_sound', 'This video has no sound to find pauses in.');
++ plan = await measure(cut, url, duration);
++ if (!plan) throw new UploadError(422, 'cut.nothing', 'No pauses long enough to cut were found.');
++ }
++ await save(cut.id, { status: 'rendering', ranges: plan.ranges, kept_seconds: plan.kept_seconds, removed_count: plan.removed_count, source_seconds: duration });
++ const label = MODES[cut.mode].label;
++ copy = await uploads.startRecording({ requester: original.owner, username: original.owner_username }, {
++ title: `${original.title.slice(0, 100)} (${label})`, description: original.description, visibility: 'private', contentType: 'video/mp4',
++ });
++ await s.db.query('UPDATE video_uploads SET edited_from = $2 WHERE id = $1', [copy.id, original.id]);
++ await save(cut.id, { result_upload_id: copy.id });
++ copy = await render(cut, url, plan.ranges, plan.kept_seconds, info, copy);
++ await uploads.complete(copy);
++ return save(cut.id, { status: 'done', progress: 1, finished_at: s.iso() });
++ } catch (err) {
++ const known = err instanceof UploadError;
++ if (!known) log.warn(`[OpenVibe.Video] cut ${cut.id}:`, err.message);
++ if (copy) await uploads.remove(copy).catch(() => {});
++ return save(cut.id, { status: 'failed', error: known ? err.detail : 'The cut could not be made. Try again later.', finished_at: s.iso() });
++ }
++ }
++
++ let busy = false;
++ async function sweep() {
++ if (!enabled || busy) return 0;
++ busy = true;
++ try {
++ const next = await s.db.maybe("SELECT * FROM video_cuts WHERE status = 'queued' ORDER BY created_at LIMIT 1");
++ if (!next) return 0;
++ await work(next);
++ return 1;
++ } finally { busy = false; }
++ }
++
++ /** At boot: a cut a restart left half-made loses its partial copy and goes back in the queue once. */
++ async function recover() {
++ if (!enabled) return;
++ for (const c of await s.db.many("SELECT * FROM video_cuts WHERE status IN ('measuring', 'rendering')")) {
++ if (c.result_upload_id) {
++ const copy = await uploads.byId(c.result_upload_id);
++ if (copy && copy.status === 'uploading') await uploads.remove(copy).catch(() => {});
++ }
++ const again = c.attempts < 2;
++ await save(c.id, again ? { status: 'queued', result_upload_id: null, progress: 0 } : { status: 'failed', error: 'The cut was interrupted. Try again.', finished_at: s.iso() });
++ }
++ }
++
++ let kicked = null;
++ function kick() {
++ if (!enabled || kicked) return;
++ kicked = setTimeout(() => { kicked = null; sweep().catch((err) => log.warn('[OpenVibe.Video] cut sweep:', err.message)); }, 1000);
++ if (kicked.unref) kicked.unref();
++ }
++
++ return {
++ enabled, MODES, request, sweep, recover, kick, byId,
++ forUpload: async (row) => (await s.db.many('SELECT * FROM video_cuts WHERE upload_id = $1 ORDER BY created_at DESC LIMIT 10', [row.id]))
++ .map((c) => ({ ...c, ranges: typeof c.ranges === 'string' ? JSON.parse(c.ranges) : c.ranges })),
++ highlightsPlan: async (row) => planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds)),
++ };
++}
++
++module.exports = { createCuts, planPauses, planHighlights, parseSilence, filterGraph, MODES };
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex b5898cc..1a405df 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -1215,7 +1215,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: SITE_NAME,
summary: 'Videos people upload or record in the browser, and stream recordings and clips from OpenVibe.Live, each with a watch page.',
-- details: 'AI captions, chapters and a summary are made for uploads (OpenVibe.AI, on OpenVibe\'s own servers) and rendered on the watch pages, as are the AI chapters and captions of stream recordings. Uploading needs a signed-in OpenVibe account; /upload and /record.',
++ details: 'AI captions, chapters and a summary are made for uploads (OpenVibe.AI, on OpenVibe\'s own servers) and rendered on the watch pages, as are the AI chapters and captions of stream recordings. The AI cut makes an edited copy of an upload: the pauses cut out, or a highlights reel. Uploading needs a signed-in OpenVibe account; /upload and /record.',
sections: [
{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
{ title: 'Latest recordings', links: links(vodItems, 'vod') },
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 5ce6e72..9757959 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -9,6 +9,8 @@
* POST /api/v1/videos/:id/complete close the file; Media finishes it in the background → { video: { id, url } }
* GET /u/:id the watch page (processing: it says so and reloads itself)
* POST /u/:id/manage | /u/:id/delete | /u/:id/report
++ * POST /u/:id/cut the owner queues an AI cut (server/video/cut.js): pauses, tight or highlights
++ * POST /api/v1/videos/:id/cuts the same as JSON { mode } → 202 { cut }; GET …/cuts → { cuts }
* GET /me/videos your uploads, with their state
* GET /record record from the camera or the screen in the browser (public/js/video-record.js)
* POST /api/v1/videos/recordings start: { title, description, visibility, content_type } → { upload }
@@@ -100,6 +102,30 @@ function createUploadApi({ ctx, config }) {
} catch (err) { return problem(res, err); }
});
++ // The AI cut (server/video/cut.js): queue an edited copy, and read how the cuts of an upload are going.
++ const cutWire = (c) => ({
++ id: c.id, mode: c.mode, status: c.status, progress: Number(c.progress) || 0, error: c.error || null,
++ source_seconds: c.source_seconds == null ? null : Number(c.source_seconds), kept_seconds: c.kept_seconds == null ? null : Number(c.kept_seconds),
++ removed_count: c.removed_count == null ? null : Number(c.removed_count),
++ result: c.result_upload_id && c.status === 'done' ? { id: c.result_upload_id, url: `/u/${c.result_upload_id}` } : null,
++ created_at: c.created_at, finished_at: c.finished_at || null,
++ });
++ r.post('/:id/cuts', express.json({ limit: '4kb' }), async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await own(req, res);
++ if (!row) return undefined;
++ try {
++ const cut = await ctx.videoCuts.request(row, who(req), String((req.body || {}).mode || 'pauses'));
++ return res.status(202).json({ cut: cutWire(cut) });
++ } catch (err) { return problem(res, err); }
++ });
++ r.get('/:id/cuts', async (req, res) => {
++ if (!who(req)) return problem(res, new UploadError(401, 'auth.required', 'Sign in first.'));
++ const row = await own(req, res);
++ if (!row) return undefined;
++ return res.json({ cuts: (await ctx.videoCuts.forUpload(row)).map(cutWire) });
++ });
++
return r;
}
@@@ -115,6 +141,34 @@ function mountUploadPages(r, h) {
: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
: row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));
++ const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };
++ const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };
++ function cutLine(c) {
++ const what = html`<b>${CUT_LABEL[c.mode] || c.mode}</b>`;
++ if (c.status === 'done') {
++ const shorter = c.source_seconds && c.kept_seconds ? html` · ${durationLabel(c.source_seconds)} → ${durationLabel(c.kept_seconds)}` : '';
++ const count = c.removed_count ? html` · ${c.removed_count} pause${c.removed_count === 1 ? '' : 's'} out` : '';
++ return html`<li>${what}${shorter}${count} · ${c.result_upload_id ? html`<a href="/u/${c.result_upload_id}">open the copy</a>` : 'done'}</li>`;
++ }
++ if (c.status === 'failed') return html`<li>${what} · not made: ${c.error || 'something went wrong'}</li>`;
++ const pct = c.status === 'rendering' ? html` ${Math.round(Number(c.progress || 0) * 100)}%` : '';
++ return html`<li>${what} · <span role="status">${CUT_STATUS[c.status]}${pct}…</span></li>`;
++ }
++ function cutPanel(row, cuts, busy, reel) {
++ const m = ctx.videoCuts.MODES;
++ return html`<section class="ovv-panel ovv-cut" aria-labelledby="h-cut"><h2 id="h-cut">AI edit</h2>
++<p class="ovv-fine">Make an edited copy. The original stays as it is; the copy is private until you publish it and gets its own captions and chapters.</p>
++<form method="post" action="/u/${row.id}/cut">
++<fieldset class="ovv-cut-modes"><legend>Make</legend>
++<label class="ovv-radio"><input type="radio" name="mode" value="pauses" checked> Cut the pauses <span class="ovv-fine">pauses over ${m.pauses.minPause} s become half a second</span></label>
++<label class="ovv-radio"><input type="radio" name="mode" value="tight"> Cut tight <span class="ovv-fine">pauses over ${m.tight.minPause} s become ${m.tight.keep * 2} s</span></label>
++${reel ? html`<label class="ovv-radio"><input type="radio" name="mode" value="highlights"> Highlights reel <span class="ovv-fine">${reel.ranges.length} moments the AI picked, ${durationLabel(reel.kept_seconds)}</span></label>` : ''}
++</fieldset>
++<button class="ovv-btn" type="submit"${busy ? ' disabled' : ''}>Make the edited copy</button>${busy ? html` <span class="ovv-fine">One cut at a time.</span>` : ''}</form>
++${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
++</section>`;
++ }
++
r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {
title: 'Upload a video',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],
@@@ -197,6 +251,13 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
const inList = upNext ? await upNext(req, 'upload', row.seq) : { html: '', nextUrl: null };
const nextAttr = inList.nextUrl ? html` data-ovv-next="${inList.nextUrl}"` : '';
++ // The AI cut (server/video/cut.js): the owner's panel, and the original an edited copy came from.
++ const cutsOn = Boolean(own && ctx.videoCuts && ctx.videoCuts.enabled && ready && row.state === 'active' && !row.edited_from);
++ const cuts = cutsOn ? await ctx.videoCuts.forUpload(row) : [];
++ const cutBusy = cuts.some((c) => ['queued', 'measuring', 'rendering'].includes(c.status));
++ const reel = cutsOn ? await ctx.videoCuts.highlightsPlan(row) : null;
++ const original = own && row.edited_from ? await up.byId(row.edited_from) : null;
++ if (cutBusy) res.set('Refresh', '15');
return page(req, res, {
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
@@@ -209,6 +270,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') : ''}
++${original && original.state !== 'removed' ? html`<p class="ovv-fine ovv-edited-from">An edited copy of <a href="/u/${original.id}">${original.title}</a>, made by the AI cut.</p>` : ''}
${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${nextAttr}${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>`
@@@ -237,6 +299,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>` : ''}
++${cutsOn ? cutPanel(row, cuts, cutBusy, reel) : ''}
${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">
@@@ -281,6 +344,20 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
return res.redirect(303, `/u/${row.id}?saved=1`);
});
++ r.post('/u/:id/cut', mustSignIn('here'), async (req, res) => {
++ const row = await up.byId(req.params.id);
++ const who = whoOf(req);
++ if (!row || row.state === 'removed' || row.owner !== who.requester) return notFound(req, res);
++ if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
++ try {
++ await ctx.videoCuts.request(row, who, String((req.body || {}).mode || 'pauses'));
++ } catch (err) {
++ if (!(err instanceof UploadError)) throw err;
++ return page(req, res, { title: 'Not started', body: html`<h1>The cut did not start</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back to the video</a></p>` }, err.status);
++ }
++ return res.redirect(303, `/u/${row.id}#h-cut`);
++ });
++
r.post('/u/:id/delete', mustSignIn('here'), async (req, res) => {
const row = await up.byId(req.params.id);
const who = whoOf(req);
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex be8f1b6..4ad7a92 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -91,7 +91,24 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const entry = { method: req.method, path, query: url.search, authorization: req.headers.authorization || null, subject: req.headers['x-ov-subject'] || null, bytes: raw.length };
requests.push(entry);
if (state.down) return problem(res, 503, 'media.down', 'Media is restarting');
-- if (!path.startsWith('/api/v2/') && !path.startsWith('/o/') && !path.startsWith('/api/v1/')) return problem(res, 404, 'route.not_found', 'no such route');
++ if (!path.startsWith('/api/v2/') && !path.startsWith('/o/') && !path.startsWith('/api/v1/') && !path.startsWith('/v/')) return problem(res, 404, 'route.not_found', 'no such route');
++
++ // ── A VOD's bytes at its signed address (/v/vod-<id>.mp4?exp=&sig=stand-in, what …/signed-url hands out), with
++ // Range reads, so ffmpeg can read an upload the way it reads one from Media (the AI cut, server/video/cut.js).
++ if (path.startsWith('/v/')) {
++ const v = vods.get(Number((/^\/v\/vod-(\d+)\.mp4$/.exec(path) || [])[1]));
++ if (!v || v.deleted || url.searchParams.get('sig') !== 'stand-in') return problem(res, 404, 'vod.not_found', 'VOD not found');
++ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
++ if (range && range[1]) {
++ const start = Number(range[1]);
++ const end = range[2] ? Math.min(Number(range[2]), v.bytes.length - 1) : v.bytes.length - 1;
++ if (start >= v.bytes.length) { res.writeHead(416, { 'Content-Range': `bytes */${v.bytes.length}` }); return res.end(); }
++ res.writeHead(206, { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${start}-${end}/${v.bytes.length}`, 'Content-Length': String(end - start + 1) });
++ return res.end(v.bytes.subarray(start, end + 1));
++ }
++ res.writeHead(200, { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Content-Length': String(v.bytes.length) });
++ return res.end(req.method === 'HEAD' ? undefined : v.bytes);
++ }
// ── The v1 VOD API OpenVibe.Video uploads use (/api/v1/:app/vods): create, chunks (multipart, appended in
// order), complete (a probe stand-in: duration from vodState.duration, a thumbnail and the HLS URL), get, put,
ddiff --git a/test/video-cut.test.js b/test/video-cut.test.js
nnew file mode 100644
iindex 0000000..8de09eb
---- /dev/null
++++ b/test/video-cut.test.js
@@@ -0,0 +1,195 @@
++'use strict';
++/**
++ * The AI cut of an OpenVibe.Video upload (server/video/cut.js): the plans (pauses → kept ranges, the AI's highlights →
++ * a reel), then the real thing with ffmpeg against the stand-in Media: the pause in a 14-second video is found and cut,
++ * and the edited copy arrives as a new private upload of the owner's, pointing at its original. Only the owner may cut;
++ * one cut at a time; a copy is not cut again; a video with no sound has no pauses; a restart retries a cut once.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++const { boot, check, done } = require('./helpers/boot');
++const { planPauses, planHighlights, parseSilence, filterGraph } = require('../server/video/cut');
++
++/** A request with Host: openvibe.video (Node's fetch drops a Host header): t.get's options, the same answer shape. */
++function videoClient(t) {
++ return (p, o = {}) => new Promise((resolve, reject) => {
++ const http = require('http');
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = t.signIn(o.as);
++ let body = null;
++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
++ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (body) headers['content-length'] = String(body.length);
++ const u = new URL(t.base + p);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body ? 'POST' : 'GET'), headers }, (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 (body) req.write(body);
++ req.end();
++ });
++}
++
++const HAVE_FFMPEG = spawnSync('ffmpeg', ['-version']).status === 0 && spawnSync('ffprobe', ['-version']).status === 0;
++
++function makeVideo(file, { audio = true } = {}) {
++ // 14 s: a tone, five seconds of silence (4–9 s), the tone again.
++ const args = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15:duration=14'];
++ if (audio) args.push('-f', 'lavfi', '-i', 'sine=frequency=440:duration=14', '-af', "volume=enable='between(t,4,9)':volume=0", '-c:a', 'aac');
++ args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', file);
++ const r = spawnSync('ffmpeg', args);
++ if (r.status !== 0) throw new Error(`ffmpeg: ${r.stderr}`);
++ return fs.readFileSync(file);
++}
++
++function probe(buf) {
++ const file = path.join(os.tmpdir(), `ov-cut-probe-${process.pid}-${Date.now()}.mp4`);
++ fs.writeFileSync(file, buf);
++ try {
++ const r = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);
++ const j = JSON.parse(String(r.stdout));
++ return { duration: Number(j.format.duration), video: j.streams.some((x) => x.codec_type === 'video'), audio: j.streams.some((x) => x.codec_type === 'audio') };
++ } finally { fs.rmSync(file, { force: true }); }
++}
++
++(async () => {
++ await check('silencedetect lines → spans; one still open at the end runs to the end', () => {
++ const text = '[silencedetect @ 0x1] silence_start: 4.02\n[silencedetect @ 0x1] silence_end: 9.01 | silence_duration: 4.99\nframe=1\n[silencedetect @ 0x1] silence_start: 20.5\n';
++ assert.deepStrictEqual(parseSilence(text, 25), [{ start: 4.02, end: 9.01 }, { start: 20.5, end: 25 }]);
++ });
++
++ await check('pauses: a long pause keeps a quarter second each side; lead-in and tail go; short pauses stay; nothing to cut is null', () => {
++ const plan = planPauses([{ start: 0, end: 2 }, { start: 10, end: 14 }, { start: 20, end: 21 }, { start: 28, end: 30 }], 30, 'pauses');
++ assert.deepStrictEqual(plan.ranges, [[1.75, 10.25], [13.75, 28.25]]);
++ assert.strictEqual(plan.removed_count, 3);
++ assert.strictEqual(plan.kept_seconds, 23);
++ assert.strictEqual(planPauses([{ start: 5, end: 6 }], 30, 'pauses'), null, 'a one-second pause is not cut');
++ assert.strictEqual(planPauses([{ start: 5, end: 6 }], 30, 'tight').removed_count, 1, 'but tight cuts it');
++ assert.strictEqual(planPauses([], 30), null);
++ });
++
++ await check('highlights: the best six in time order, overlaps merged; old AI edits fall back to chapter starts', () => {
++ const ai = { status: 'done', highlights: [{ start: 100, end: 130, score: 0.9 }, { start: 10, end: 40, score: 0.5 }, { start: 120, end: 150, score: 0.7 }, { start: 300, end: 330, score: 0.1 }] };
++ assert.deepStrictEqual(planHighlights(ai, 600).ranges, [[10, 40], [100, 150], [300, 330]]);
++ const old = { status: 'done', highlights: null, chapters: [{ start: 0, title: 'Start' }, { start: 60, title: 'A' }, { start: 200, title: 'B' }] };
++ assert.deepStrictEqual(planHighlights(old, 600).ranges, [[60, 90], [200, 230]]);
++ assert.strictEqual(planHighlights({ status: 'done', highlights: [{ start: 1, end: 30, score: 1 }] }, 600), null, 'one moment is not a reel');
++ assert.strictEqual(planHighlights({ status: 'running' }, 600), null);
++ });
++
++ await check('the filter graph trims each range and concatenates them (no audio: video only)', () => {
++ const g = filterGraph([[0, 2], [5, 7]]);
++ assert.ok(g.includes('[0:v:0]trim=start=5:end=7,setpts=PTS-STARTPTS[v1]') && g.includes('[0:a:0]atrim=start=0:end=2'));
++ assert.ok(g.includes('[v0][a0][v1][a1]concat=n=2:v=1:a=1[vc][ac]'));
++ assert.ok(!filterGraph([[0, 2], [5, 7]], { audio: false }).includes('atrim'));
++ });
++
++ if (!HAVE_FFMPEG) {
++ console.log(' - ffmpeg is not installed here: the end-to-end cut is skipped');
++ return done();
++ }
++
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cut-'));
++ const t0 = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', VIDEO_CUTS_NICE: '0' } });
++ const t = { ...t0, get: videoClient(t0) };
++ const s = t.ctx.s;
++ const V = { Host: 'openvibe.video', 'sec-fetch-site': 'same-origin' };
++ try {
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const owner = `user:${kim.subject}`;
++ const addVod = (id, bytes) => t.media.vods.set(id, { id, title: 'x', visibility: 'private', bytes, types: ['video/mp4'], status: 'ready' });
++ const addUpload = async (id, vodId, o = {}) => {
++ await s.db.exec(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at, duration_seconds)
++ VALUES ($1, $2, 'kim', $3, 'About the tone.', 'public', $4, 'video/mp4', 1000, 1000, 1, 1, 'ready', 'active', $5, $5, $5, 14)`,
++ [id, owner, o.title || 'A tone and a pause', vodId, s.iso()]);
++ };
++ addVod(900, makeVideo(path.join(dir, 'a.mp4')));
++ addVod(901, makeVideo(path.join(dir, 'b.mp4'), { audio: false }));
++ const A = 'vup_01J0000000000000000000000A';
++ const B = 'vup_01J0000000000000000000000B';
++ await addUpload(A, 900);
++ await addUpload(B, 901, { title: 'No sound' });
++
++ await check('the owner sees the AI edit panel; anyone else does not, and cannot start a cut', async () => {
++ const mine = await t.get(`/u/${A}`, { as: kim, headers: V });
++ assert.ok(mine.text.includes('AI edit') && mine.text.includes('Cut the pauses') && !mine.text.includes('Highlights reel'), 'no reel without the AI edit');
++ assert.ok(!(await t.get(`/u/${A}`, { as: lee, headers: V })).text.includes('AI edit'));
++ assert.strictEqual((await t.get(`/u/${A}/cut`, { as: lee, form: { mode: 'pauses' }, headers: V })).status, 404);
++ });
++
++ let copyId;
++ await check('pauses: the five-second pause is found and cut; the copy is a new private upload pointing at its original', async () => {
++ const r = await t.get(`/u/${A}/cut`, { as: kim, form: { mode: 'pauses' }, headers: V });
++ assert.strictEqual(r.status, 303);
++ const again = await t.get(`/api/v1/videos/${A}/cuts`, { as: kim, method: 'POST', json: { mode: 'tight' }, headers: V });
++ assert.deepStrictEqual([again.status, again.json().code], [409, 'cut.busy'], 'one at a time');
++ await t.ctx.videoCuts.sweep();
++ const cut = (await t.get(`/api/v1/videos/${A}/cuts`, { as: kim, headers: V })).json().cuts[0];
++ assert.strictEqual(cut.status, 'done', JSON.stringify(cut));
++ assert.strictEqual(cut.removed_count, 1);
++ assert.ok(Math.abs(cut.kept_seconds - 9.5) < 0.6, `kept ${cut.kept_seconds}`);
++ copyId = cut.result.id;
++ const copy = await s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [copyId]);
++ assert.deepStrictEqual([copy.owner, copy.visibility, copy.edited_from, copy.title], [owner, 'private', A, 'A tone and a pause (pauses cut)']);
++ const bytes = t.media.vods.get(Number(copy.media_vod_id)).bytes;
++ const p = probe(bytes);
++ assert.ok(p.video && p.audio, 'picture and sound');
++ assert.ok(Math.abs(p.duration - 9.5) < 0.8, `the copy is ${p.duration} s`);
++ });
++
++ await check('the pages: the original lists the cut with a link; the copy names its original; a copy is not cut again', async () => {
++ const orig = await t.get(`/u/${A}`, { as: kim, headers: V });
++ assert.ok(orig.text.includes('Pauses cut') && orig.text.includes(`href="/u/${copyId}"`) && orig.text.includes('1 pause out'));
++ const copy = await t.get(`/u/${copyId}`, { as: kim, headers: V });
++ assert.ok(copy.text.includes('An edited copy of') && copy.text.includes(`href="/u/${A}"`));
++ await s.db.exec("UPDATE video_uploads SET status = 'ready', duration_seconds = 9.5 WHERE id = $1", [copyId]);
++ const r = await t.get(`/api/v1/videos/${copyId}/cuts`, { as: kim, method: 'POST', json: { mode: 'pauses' }, headers: V });
++ assert.deepStrictEqual([r.status, r.json().code], [409, 'cut.is_copy']);
++ });
++
++ await check('highlights: the reel comes from the AI edit and skips the pause search', async () => {
++ await s.db.exec(`INSERT INTO video_upload_ai (upload_id, status, chapters, segments, highlights, updated_at) VALUES ($1, 'done', '[]', '[]', $2, $3)`,
++ [A, JSON.stringify([{ start: 0.5, end: 3.5, score: 0.9 }, { start: 9.5, end: 13, score: 0.8 }]), s.iso()]);
++ assert.ok(!(await t.get(`/u/${A}`, { as: kim, headers: V })).text.includes('Highlights reel'), 'a reel of 6.5 s is not offered (at least 20 s)');
++ const wrong = await t.get(`/api/v1/videos/${A}/cuts`, { as: kim, method: 'POST', json: { mode: 'highlights' }, headers: V });
++ assert.deepStrictEqual([wrong.status, wrong.json().code], [409, 'cut.no_highlights']);
++ await s.db.exec('UPDATE video_upload_ai SET highlights = $2 WHERE upload_id = $1', [A, JSON.stringify([{ start: 0, end: 30, score: 0.9 }, { start: 40, end: 70, score: 0.8 }])]);
++ await s.db.exec('UPDATE video_uploads SET duration_seconds = 120 WHERE id = $1', [A]);
++ const page = await t.get(`/u/${A}`, { as: kim, headers: V });
++ assert.ok(page.text.includes('Highlights reel') && page.text.includes('2 moments the AI picked, 1:00'), 'offered with its length');
++ const ok = await t.get(`/api/v1/videos/${A}/cuts`, { as: kim, method: 'POST', json: { mode: 'highlights' }, headers: V });
++ assert.strictEqual(ok.status, 202, ok.text);
++ assert.deepStrictEqual(ok.json().cut.kept_seconds, 60);
++ await s.db.exec("UPDATE video_cuts SET status = 'failed' WHERE id = $1", [ok.json().cut.id]);
++ await s.db.exec('UPDATE video_uploads SET duration_seconds = 14 WHERE id = $1', [A]);
++ });
++
++ await check('no sound, no pauses: the cut fails with a plain reason', async () => {
++ await t.get(`/u/${B}/cut`, { as: kim, form: { mode: 'pauses' }, headers: V });
++ await t.ctx.videoCuts.sweep();
++ const cut = (await t.get(`/api/v1/videos/${B}/cuts`, { as: kim, headers: V })).json().cuts[0];
++ assert.deepStrictEqual([cut.status, cut.error], ['failed', 'This video has no sound to find pauses in.']);
++ assert.strictEqual(Number(await s.db.value("SELECT count(*) FROM video_uploads WHERE edited_from = $1 AND state <> 'removed'", [B])), 0, 'no copy left behind');
++ });
++
++ await check('a cut a restart left mid-way loses its partial copy and goes back in the queue once', async () => {
++ const partial = await t.ctx.videoUploads.startRecording({ requester: owner, username: 'kim' }, { title: 'partial', contentType: 'video/mp4' });
++ await s.db.exec(`INSERT INTO video_cuts (id, upload_id, owner, mode, status, result_upload_id, attempts, created_at, updated_at)
++ VALUES ('vcut_01J0000000000000000000000Z', $1, $2, 'tight', 'rendering', $3, 1, $4, $4)`, [A, owner, partial.id, s.iso()]);
++ await t.ctx.videoCuts.recover();
++ const c = await t.ctx.videoCuts.byId('vcut_01J0000000000000000000000Z');
++ assert.deepStrictEqual([c.status, c.result_upload_id], ['queued', null]);
++ assert.strictEqual((await t.ctx.videoUploads.byId(partial.id)).state, 'removed');
++ });
++ } finally {
++ await t0.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ done();
++})();