openvibers/openvibe.mediahub Commits
Video: make a clip on openvibe.video (any signed-in viewer, cut on Live as the person, with their address) (#38)
Changes
@@@ -138,8 +138,13 @@ view.description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends toOpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), soLive's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists--are dropped after a change. A private item is shown to its owner only (noindex, never cached). New clips are still cut--on Live.++are dropped after a change. A private item is shown to its owner only (noindex, never cached).++++**Make a clip.** Any signed-in viewer of a finished recording cuts a clip of up to 60 seconds from its page (start and++end as m:ss or h:mm:ss; "Start here" / "End here" mark the player's position). openvibe.video sends it to Live's own++`POST /api/vods/clips` as the person, with their address in `X-Forwarded-For` (Live limits clip-making per person and++per address), so Live's rules, limits and channel settings apply; the new clip's page says it is being cut, and++checks again by itself, until Media has cut it.The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link tothe OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own@@@ -151,6 +156,7 @@ does not sign in on the other.| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips || `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist || `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |++| `POST /v/:id/clip` | any signed-in viewer (Live decides) | cut a clip `{ start, end, title? }` of up to 60 seconds || `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) || `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete || `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |@@@ -189,3 +189,19 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-danger > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 2px; border-radius: 4px; }.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }.ovv-watch--clip .ovv-owner { max-width: 640px; }++++/* Make a clip: a disclosure in the side column (any signed-in viewer). */++.ovv-clipper > summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; }++.ovv-clipper > summary::-webkit-details-marker { display: none; }++.ovv-clipper > summary h2 { margin: 0; }++.ovv-clipper > summary::after { content: '+'; font-size: 1.3rem; line-height: 1; color: var(--ovv-dim); }++.ovv-clipper[open] > summary::after { content: '−'; }++.ovv-clipper > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }++.ovv-clip-times { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }++.ovv-clip-now { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }++.ovv-clip-now[hidden] { display: none; }++++/* A clip Media is still cutting: the player's place, with a note. */++.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }++.ovv-cutting p { margin: 0; max-width: 40ch; }++.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }@@@ -62,10 +62,42 @@}, function () { return 'file'; });}++ function clockOf(seconds) {++ const n = Math.max(0, Math.floor(seconds || 0));++ const h = Math.floor(n / 3600);++ const m = Math.floor((n % 3600) / 60);++ const s = String(n % 60).padStart(2, '0');++ return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;++ }++document.addEventListener('DOMContentLoaded', function () {++ // A clip still being cut: look again in a few seconds (the server answers once it is ready).++ const cutting = document.querySelector('[data-ovv-reload]');++ if (cutting) {++ const wait = Math.max(3, Number(cutting.getAttribute('data-ovv-reload')) || 8) * 1000;++ setTimeout(function () { window.location.replace(window.location.pathname); }, wait);++ }++const video = document.querySelector('video[data-ovv-video]');if (!video) return;++ // "Make a clip": with the player here, mark the start and end at the current time.++ const now = document.querySelector('[data-ovv-clip-now]');++ if (now) {++ now.hidden = false;++ now.querySelectorAll('[data-ovv-clip-mark]').forEach(function (b) {++ b.addEventListener('click', function () {++ const which = b.getAttribute('data-ovv-clip-mark');++ const input = document.querySelector(which === 'start' ? '[data-ovv-clip-start]' : '[data-ovv-clip-end]');++ if (input) input.value = clockOf(video.currentTime);++ if (which === 'start') {++ const end = document.querySelector('[data-ovv-clip-end]');++ if (end && !end.value) end.value = clockOf(video.currentTime + 30);++ }++ });++ });++ }++const t = startSeconds();attachSource(video).then(function (source) {video.setAttribute('data-ovv-source', source);@@@ -96,12 +96,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console* they may do), never cached. 2xx → the JSON body; a 4xx → LiveRefusedError carrying Live's status and message* (a 401 lets the caller refresh the session and try once more); anything else → LiveUnavailableError.*/-- async function asPerson(token, method, path, body) {++ async function asPerson(token, method, path, body, { viewerIp = null } = {}) {let res;try {res = await fetchImpl(`${config.video.liveUrl}${path}`, {method,-- headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },++ headers: {++ Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),++ // Live limits clip-making per address too; every call from here comes from this server, so the++ // person's own address is passed (Live reads the first X-Forwarded-For entry).++ ...(viewerIp ? { 'X-Forwarded-For': viewerIp } : {}),++ },body: body ? JSON.stringify(body) : undefined,credentials: 'omit',cache: 'no-store',@@@ -127,7 +132,13 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console}}++ /** Only this item's cached read (a clip still being cut is read afresh until it is ready). */++ function forgetItem(kind, id) {++ cache.delete(`${config.video.liveUrl}/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}`);++ }++return {++ forgetItem,/** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */async vodAs(token, id) {try { return await asPerson(token, 'GET', `/api/vods/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }@@@ -140,6 +151,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consolesetClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),++ /** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */++ createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),forget,feed({ type, limit, cursor = null }) {const qs = new URLSearchParams({ type, limit: String(limit) });@@@ -73,6 +73,24 @@ function isoDuration(seconds) {return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s || (!h && !m) ? `${s}S` : ''}`;}++/** "1:02:03", "2:03", "123" or "123.5" → seconds, or NaN. */++function parseClock(v) {++ const t = String(v == null ? '' : v).trim();++ if (/^\d+(\.\d+)?$/.test(t)) return Number(t);++ const m = /^(?:(\d+):)?([0-5]?\d):([0-5]\d(?:\.\d+)?)$/.exec(t);++ if (!m) return NaN;++ return (Number(m[1] || 0) * 3600) + (Number(m[2]) * 60) + Number(m[3]);++}++/** Seconds → "m:ss" or "h:mm:ss". */++function clock(seconds) {++ const n = Math.max(0, Math.floor(Number(seconds) || 0));++ const h = Math.floor(n / 3600);++ const m = Math.floor((n % 3600) / 60);++ const s = String(n % 60).padStart(2, '0');++ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;++}++const CLIP_MAX_SECONDS = 60; // Live's default max_clip_duration; Live has the last word and says so++const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; };/**@@@ -434,6 +452,26 @@ function createVideoRoutes(ctx) {}const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);++ /** "Make a clip" of a finished recording, for any signed-in viewer who can see it (Live's rule, as on Live). */++ function clipPanel(req, id, vod) {++ if (vod.is_recording) return '';++ if (!signedIn(req)) {++ return html`<section class="ovv-panel ovv-clipper" aria-labelledby="h-clip"><h2 id="h-clip">Make a clip</h2><p class="ovv-fine"><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a> to cut a clip of up to ${CLIP_MAX_SECONDS} seconds from this recording.</p></section>`;++ }++ const dur = Math.floor(Number(vod.duration_seconds) || 0);++ return html`<details class="ovv-panel ovv-clipper" data-ovv-clipper><summary><h2 id="h-clip">Make a clip</h2></summary>++<form class="ovv-owner-form" method="post" action="/v/${id}/clip">++<div class="ovv-clip-times">++<label class="ovv-field"><span>Start</span><input name="start" type="text" inputmode="numeric" placeholder="0:00" pattern="[0-9:.]{1,12}" required data-ovv-clip-start></label>++<label class="ovv-field"><span>End</span><input name="end" type="text" inputmode="numeric" placeholder="0:30" pattern="[0-9:.]{1,12}" required data-ovv-clip-end></label>++</div>++<p class="ovv-clip-now" hidden data-ovv-clip-now><button type="button" class="ovv-btn" data-ovv-clip-mark="start">Start here</button> <button type="button" class="ovv-btn" data-ovv-clip-mark="end">End here</button></p>++<label class="ovv-field"><span>Title <span class="ovv-fine">(optional)</span></span><input name="title" type="text" maxlength="100"></label>++<p class="ovv-fine">Up to ${CLIP_MAX_SECONDS} seconds${dur ? `, within ${clock(dur)}` : ''}. Times are m:ss or h:mm:ss. The clip is cut on OpenVibe.Media and credited to you.</p>++<button class="ovv-btn ovv-btn--primary" type="submit">Make the clip</button>++</form></details>`;++ }++function ownerPanel(kind, id, item) {const what = kind === 'vod' ? 'recording' : 'clip';const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;@@@ -448,7 +486,6 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar<details class="ovv-danger"><summary>Delete this ${what}</summary><form class="ovv-owner-delete" method="post" action="${action}/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>--${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="${livePublic}/vod/${id}">OpenVibe.Live</a>.</p>` : ''}</section>`;}@@@ -694,6 +731,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c</div><aside class="ovv-side">${manageable ? ownerPanel('vod', id, vod) : ''}++${ownOnly ? '' : clipPanel(req, id, vod)}${up.html}${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}@@@ -727,6 +765,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 iconst unlisted = clip.visibility === 'unlisted';// AI clips stay out of search, as on Live: noindex and no structured data.const aiClip = Boolean(clip.auto_generated);++ // A clip Media is still cutting (or failed to cut) has no file to play yet.++ const cutting = Boolean(clip.status) && clip.status !== 'ready';++ if (cutting) live.forgetItem('clip', id);const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';const madeBy = clip.display_name || clip.username || 'an OpenVibe member';@@@ -751,8 +792,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 iscripts: ['js/video.js'],body: html`<div class="ovv-watch ovv-watch--clip"><div class="ovv-main">--${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}--<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>++${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}++${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`++ : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}<h1 class="ovv-title">${clip.title || 'Clip'}</h1><div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>@@@ -783,6 +825,27 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}body: html`<h1>That was not saved</h1>${notice(text, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,}, 422);++ r.post('/v/:id/clip', mustSignIn('here', 'Sign in to make a clip.'), async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ const back = `/v/${id}`;++ if (!sameSite(req, res, back)) return undefined;++ const b = req.body || {};++ const start = parseClock(b.start);++ const end = parseClock(b.end);++ if (!Number.isFinite(start) || !Number.isFinite(end)) return fieldError(req, res, 'Write the start and end as m:ss (for example 1:05) or h:mm:ss.', back);++ if (end - start < 1) return fieldError(req, res, 'The end has to come at least a second after the start.', back);++ if (end - start > CLIP_MAX_SECONDS) return fieldError(req, res, `A clip is at most ${CLIP_MAX_SECONDS} seconds.`, back);++ const title = String(b.title || '').trim().slice(0, 100);++ let out;++ try {++ out = await asPerson(req, res, (token) => live.createClip(token, { vod_id: Number(id), start_time: start, end_time: end, ...(title ? { title } : {}) }, req.ip));++ } catch (err) { return ownerRefused(req, res, err, back); }++ const clipId = out && out.clip && out.clip.id;++ live.forget('vod', id);++ return res.redirect(303, live.isId(clipId) ? `/c/${clipId}?made=1` : `${back}?saved=1`);++ });++r.post('/v/:id/manage', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {const id = String(req.params.id || '');if (!live.isId(id)) return notFound(req, res);nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * OpenVibe.Video "Make a clip": any signed-in viewer of a finished recording cuts a clip on OpenVibe.Live (Live's++ * POST /api/vods/clips, as the person, with their own address for Live's per-address cooldown); times are m:ss,++ * h:mm:ss or seconds, checked before Live is called; the new clip's page says it is being cut until Media is done.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const usernameOf = (auth) => {++ const tok = String(auth || '').replace(/^Bearer /, '');++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }++};++++function startLive() {++ const vod = { id: 101, title: 'A long stream', description: '', duration_seconds: 3600, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' };++ const clips = {};++ const calls = [];++ let refuse = null;++ const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ if (req.method === 'POST' && url.pathname === '/api/vods/clips') {++ const body = JSON.parse(raw || '{}');++ calls.push({ who: usernameOf(req.headers.authorization), xff: req.headers['x-forwarded-for'] || null, body });++ if (refuse) return json(refuse.status, { error: refuse.error });++ const clip = { id: 301, vod_id: body.vod_id, title: body.title || 'Clip', start_time: body.start_time, end_time: body.end_time, duration_seconds: body.end_time - body.start_time, status: 'processing', visibility: 'public', username: usernameOf(req.headers.authorization), playback_url: 'https://openvibe.media/c/301', created_at: '2026-10-12T21:00:00.000Z' };++ clips[301] = clip;++ return json(201, { clip });++ }++ if (url.pathname === '/api/vods/101') return json(200, { vod, clips: [] });++ const c = /^\/api\/clips\/(\d+)$/.exec(url.pathname);++ if (c) return clips[c[1]] ? json(200, { clip: clips[c[1]] }) : json(404, { error: 'not found' });++ return json(404, { error: 'not found' });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, clips, calls, refuse: (r) => { refuse = r; }, close: () => new Promise((r) => server.close(r)) })));++}++++function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + path);++ const h = { Host: 'openvibe.video' };++ if (cookie) h.cookie = cookie;++ if (same && 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'; 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', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++(async () => {++ const live = await startLive();++ 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 } });++ const sam = t.network.addUser('sam');++ const samCookie = t.signIn(sam);++++ await check('signed out, the panel offers sign-in; signed in, the form', async () => {++ const anon = await vreq(t.base, '/v/101');++ assert.ok(anon.text.includes('Make a clip') && anon.text.includes('to cut a clip of up to 60 seconds'), 'a sign-in prompt');++ assert.ok(!anon.text.includes('action="/v/101/clip"'));++ const r = await vreq(t.base, '/v/101', { cookie: samCookie });++ assert.ok(r.text.includes('action="/v/101/clip"'), 'any signed-in viewer, not only the channel');++ assert.ok(r.text.includes('within 1:00:00'), 'the recording length');++ assert.ok(r.text.includes('data-ovv-clip-now'), 'the Start here / End here buttons (shown by the script)');++ });++++ await check('a clip is made on Live as the person, with their address, then its page says it is being cut', async () => {++ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '1:05', end: '1:35', title: 'The good part' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.strictEqual(r.headers.location, '/c/301?made=1');++ const call = live.calls.pop();++ assert.strictEqual(call.who, 'sam');++ assert.deepStrictEqual(call.body, { vod_id: 101, start_time: 65, end_time: 95, title: 'The good part' });++ assert.ok(call.xff, 'the viewer\'s address is passed for Live\'s per-address limit');++ const page = await vreq(t.base, '/c/301?made=1', { cookie: samCookie });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Your clip was made'), 'a made notice');++ assert.ok(page.text.includes('This clip is being cut') && page.text.includes('data-ovv-reload="8"'), 'cutting, and the page looks again');++ assert.ok(!page.text.includes('<video'), 'no player before there is a file');++ live.clips[301].status = 'ready';++ const ready = await vreq(t.base, '/c/301', { cookie: samCookie });++ assert.ok(ready.text.includes('<video') && !ready.text.includes('being cut'), 'the player once it is ready');++ });++++ await check('times are checked before Live is called', async () => {++ const before = live.calls.length;++ for (const form of [{ start: 'abc', end: '0:30' }, { start: '0:30', end: '0:30' }, { start: '0:40', end: '0:10' }, { start: '0:00', end: '1:01' }, { start: '1:99', end: '2:10' }]) {++ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form });++ assert.strictEqual(r.status, 422, JSON.stringify(form));++ }++ assert.strictEqual((await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, same: false, form: { start: '0:00', end: '0:10' } })).status, 403, 'cross-site');++ assert.strictEqual((await vreq(t.base, '/v/101/clip', { method: 'POST', form: { start: '0:00', end: '0:10' } })).status, 401, 'signed out');++ assert.strictEqual(live.calls.length, before, 'Live was never called');++ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '3600', end: '3610.5' } });++ assert.strictEqual(r.status, 303, 'plain seconds work too');++ assert.deepStrictEqual(live.calls.pop().body, { vod_id: 101, start_time: 3600, end_time: 3610.5 });++ });++++ await check('Live\'s refusal is shown as it is', async () => {++ live.refuse({ status: 429, error: 'You are clipping too fast. Please wait 10 seconds before making another clip.' });++ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '0:00', end: '0:10' } });++ assert.strictEqual(r.status, 403);++ assert.ok(r.text.includes('OpenVibe.Live said: You are clipping too fast'), r.text.slice(0, 400));++ live.refuse(null);++ });++++ await t.close();++ await live.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -138,8 +138,13 @@ view.
@@ -138,8 +138,13 @@ view.
description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
Live's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists
Live's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists
-are dropped after a change. A private item is shown to its owner only (noindex, never cached). New clips are still cut
-on Live.
+are dropped after a change. A private item is shown to its owner only (noindex, never cached).
+
+**Make a clip.** Any signed-in viewer of a finished recording cuts a clip of up to 60 seconds from its page (start and
+end as m:ss or h:mm:ss; "Start here" / "End here" mark the player's position). openvibe.video sends it to Live's own
+`POST /api/vods/clips` as the person, with their address in `X-Forwarded-For` (Live limits clip-making per person and
+per address), so Live's rules, limits and channel settings apply; the new clip's page says it is being cut, and
+checks again by itself, until Media has cut it.
The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
@@ -151,6 +156,7 @@ does not sign in on the other.
@@ -151,6 +156,7 @@ does not sign in on the other.
| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
+| `POST /v/:id/clip` | any signed-in viewer (Live decides) | cut a clip `{ start, end, title? }` of up to 60 seconds |
| `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) |
| `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) |
| `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete |
| `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete |
| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
@@ -189,3 +189,19 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -189,3 +189,19 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-danger > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 2px; border-radius: 4px; }
.ovv-danger > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 2px; border-radius: 4px; }
.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.ovv-watch--clip .ovv-owner { max-width: 640px; }
.ovv-watch--clip .ovv-owner { max-width: 640px; }
+
+/* Make a clip: a disclosure in the side column (any signed-in viewer). */
+.ovv-clipper > summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.ovv-clipper > summary::-webkit-details-marker { display: none; }
+.ovv-clipper > summary h2 { margin: 0; }
+.ovv-clipper > summary::after { content: '+'; font-size: 1.3rem; line-height: 1; color: var(--ovv-dim); }
+.ovv-clipper[open] > summary::after { content: '−'; }
+.ovv-clipper > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }
+.ovv-clip-times { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.ovv-clip-now { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
+.ovv-clip-now[hidden] { display: none; }
+
+/* A clip Media is still cutting: the player's place, with a note. */
+.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }
+.ovv-cutting p { margin: 0; max-width: 40ch; }
+.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }
@@ -62,10 +62,42 @@
@@ -62,10 +62,42 @@
}, function () { return 'file'; });
}, function () { return 'file'; });
}
}
+ function clockOf(seconds) {
+ const n = Math.max(0, Math.floor(seconds || 0));
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const s = String(n % 60).padStart(2, '0');
+ return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;
+ }
+
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function () {
+ // A clip still being cut: look again in a few seconds (the server answers once it is ready).
+ const cutting = document.querySelector('[data-ovv-reload]');
+ if (cutting) {
+ const wait = Math.max(3, Number(cutting.getAttribute('data-ovv-reload')) || 8) * 1000;
+ setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
+ }
+
const video = document.querySelector('video[data-ovv-video]');
const video = document.querySelector('video[data-ovv-video]');
if (!video) return;
if (!video) return;
+ // "Make a clip": with the player here, mark the start and end at the current time.
+ const now = document.querySelector('[data-ovv-clip-now]');
+ if (now) {
+ now.hidden = false;
+ now.querySelectorAll('[data-ovv-clip-mark]').forEach(function (b) {
+ b.addEventListener('click', function () {
+ const which = b.getAttribute('data-ovv-clip-mark');
+ const input = document.querySelector(which === 'start' ? '[data-ovv-clip-start]' : '[data-ovv-clip-end]');
+ if (input) input.value = clockOf(video.currentTime);
+ if (which === 'start') {
+ const end = document.querySelector('[data-ovv-clip-end]');
+ if (end && !end.value) end.value = clockOf(video.currentTime + 30);
+ }
+ });
+ });
+ }
+
const t = startSeconds();
const t = startSeconds();
attachSource(video).then(function (source) {
attachSource(video).then(function (source) {
video.setAttribute('data-ovv-source', source);
video.setAttribute('data-ovv-source', source);
@@ -96,12 +96,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -96,12 +96,17 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
* they may do), never cached. 2xx → the JSON body; a 4xx → LiveRefusedError carrying Live's status and message
* they may do), never cached. 2xx → the JSON body; a 4xx → LiveRefusedError carrying Live's status and message
* (a 401 lets the caller refresh the session and try once more); anything else → LiveUnavailableError.
* (a 401 lets the caller refresh the session and try once more); anything else → LiveUnavailableError.
*/
*/
- async function asPerson(token, method, path, body) {
+ async function asPerson(token, method, path, body, { viewerIp = null } = {}) {
let res;
let res;
try {
try {
res = await fetchImpl(`${config.video.liveUrl}${path}`, {
res = await fetchImpl(`${config.video.liveUrl}${path}`, {
method,
method,
- headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ headers: {
+ Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),
+ // Live limits clip-making per address too; every call from here comes from this server, so the
+ // person's own address is passed (Live reads the first X-Forwarded-For entry).
+ ...(viewerIp ? { 'X-Forwarded-For': viewerIp } : {}),
+ },
body: body ? JSON.stringify(body) : undefined,
body: body ? JSON.stringify(body) : undefined,
credentials: 'omit',
credentials: 'omit',
cache: 'no-store',
cache: 'no-store',
@@ -127,7 +132,13 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -127,7 +132,13 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
}
}
+ /** Only this item's cached read (a clip still being cut is read afresh until it is ready). */
+ function forgetItem(kind, id) {
+ cache.delete(`${config.video.liveUrl}/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}`);
+ }
+
return {
return {
+ forgetItem,
/** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */
/** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */
async vodAs(token, id) {
async vodAs(token, id) {
try { return await asPerson(token, 'GET', `/api/vods/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
try { return await asPerson(token, 'GET', `/api/vods/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
@@ -140,6 +151,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -140,6 +151,8 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
+ /** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */
+ createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
forget,
forget,
feed({ type, limit, cursor = null }) {
feed({ type, limit, cursor = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
const qs = new URLSearchParams({ type, limit: String(limit) });
@@ -73,6 +73,24 @@ function isoDuration(seconds) {
@@ -73,6 +73,24 @@ function isoDuration(seconds) {
return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s || (!h && !m) ? `${s}S` : ''}`;
return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s || (!h && !m) ? `${s}S` : ''}`;
}
}
+/** "1:02:03", "2:03", "123" or "123.5" → seconds, or NaN. */
+function parseClock(v) {
+ const t = String(v == null ? '' : v).trim();
+ if (/^\d+(\.\d+)?$/.test(t)) return Number(t);
+ const m = /^(?:(\d+):)?([0-5]?\d):([0-5]\d(?:\.\d+)?)$/.exec(t);
+ if (!m) return NaN;
+ return (Number(m[1] || 0) * 3600) + (Number(m[2]) * 60) + Number(m[3]);
+}
+/** Seconds → "m:ss" or "h:mm:ss". */
+function clock(seconds) {
+ const n = Math.max(0, Math.floor(Number(seconds) || 0));
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const s = String(n % 60).padStart(2, '0');
+ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
+}
+const CLIP_MAX_SECONDS = 60; // Live's default max_clip_duration; Live has the last word and says so
+
const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; };
const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; };
/**
/**
@@ -434,6 +452,26 @@ function createVideoRoutes(ctx) {
@@ -434,6 +452,26 @@ function createVideoRoutes(ctx) {
}
}
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
+ /** "Make a clip" of a finished recording, for any signed-in viewer who can see it (Live's rule, as on Live). */
+ function clipPanel(req, id, vod) {
+ if (vod.is_recording) return '';
+ if (!signedIn(req)) {
+ return html`<section class="ovv-panel ovv-clipper" aria-labelledby="h-clip"><h2 id="h-clip">Make a clip</h2><p class="ovv-fine"><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a> to cut a clip of up to ${CLIP_MAX_SECONDS} seconds from this recording.</p></section>`;
+ }
+ const dur = Math.floor(Number(vod.duration_seconds) || 0);
+ return html`<details class="ovv-panel ovv-clipper" data-ovv-clipper><summary><h2 id="h-clip">Make a clip</h2></summary>
+<form class="ovv-owner-form" method="post" action="/v/${id}/clip">
+<div class="ovv-clip-times">
+<label class="ovv-field"><span>Start</span><input name="start" type="text" inputmode="numeric" placeholder="0:00" pattern="[0-9:.]{1,12}" required data-ovv-clip-start></label>
+<label class="ovv-field"><span>End</span><input name="end" type="text" inputmode="numeric" placeholder="0:30" pattern="[0-9:.]{1,12}" required data-ovv-clip-end></label>
+</div>
+<p class="ovv-clip-now" hidden data-ovv-clip-now><button type="button" class="ovv-btn" data-ovv-clip-mark="start">Start here</button> <button type="button" class="ovv-btn" data-ovv-clip-mark="end">End here</button></p>
+<label class="ovv-field"><span>Title <span class="ovv-fine">(optional)</span></span><input name="title" type="text" maxlength="100"></label>
+<p class="ovv-fine">Up to ${CLIP_MAX_SECONDS} seconds${dur ? `, within ${clock(dur)}` : ''}. Times are m:ss or h:mm:ss. The clip is cut on OpenVibe.Media and credited to you.</p>
+<button class="ovv-btn ovv-btn--primary" type="submit">Make the clip</button>
+</form></details>`;
+ }
+
function ownerPanel(kind, id, item) {
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
@@ -448,7 +486,6 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -448,7 +486,6 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<form class="ovv-owner-delete" method="post" action="${action}/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="${action}/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>
</details>
-${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="${livePublic}/vod/${id}">OpenVibe.Live</a>.</p>` : ''}
</section>`;
</section>`;
}
}
@@ -694,6 +731,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c
@@ -694,6 +731,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c
</div>
</div>
<aside class="ovv-side">
<aside class="ovv-side">
${manageable ? ownerPanel('vod', id, vod) : ''}
${manageable ? ownerPanel('vod', id, vod) : ''}
+${ownOnly ? '' : clipPanel(req, id, vod)}
${up.html}
${up.html}
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
@@ -727,6 +765,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -727,6 +765,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const unlisted = clip.visibility === 'unlisted';
const unlisted = clip.visibility === 'unlisted';
// AI clips stay out of search, as on Live: noindex and no structured data.
// AI clips stay out of search, as on Live: noindex and no structured data.
const aiClip = Boolean(clip.auto_generated);
const aiClip = Boolean(clip.auto_generated);
+ // A clip Media is still cutting (or failed to cut) has no file to play yet.
+ const cutting = Boolean(clip.status) && clip.status !== 'ready';
+ if (cutting) live.forgetItem('clip', id);
const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
@@ -751,8 +792,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -751,8 +792,9 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-main">
<div class="ovv-main">
-${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
-<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>
+${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
+${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
+ : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-byline">
<div class="ovv-byline">
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
@@ -783,6 +825,27 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
@@ -783,6 +825,27 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
body: html`<h1>That was not saved</h1>${notice(text, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,
body: html`<h1>That was not saved</h1>${notice(text, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,
}, 422);
}, 422);
+ r.post('/v/:id/clip', mustSignIn('here', 'Sign in to make a clip.'), async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ const back = `/v/${id}`;
+ if (!sameSite(req, res, back)) return undefined;
+ const b = req.body || {};
+ const start = parseClock(b.start);
+ const end = parseClock(b.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return fieldError(req, res, 'Write the start and end as m:ss (for example 1:05) or h:mm:ss.', back);
+ if (end - start < 1) return fieldError(req, res, 'The end has to come at least a second after the start.', back);
+ if (end - start > CLIP_MAX_SECONDS) return fieldError(req, res, `A clip is at most ${CLIP_MAX_SECONDS} seconds.`, back);
+ const title = String(b.title || '').trim().slice(0, 100);
+ let out;
+ try {
+ out = await asPerson(req, res, (token) => live.createClip(token, { vod_id: Number(id), start_time: start, end_time: end, ...(title ? { title } : {}) }, req.ip));
+ } catch (err) { return ownerRefused(req, res, err, back); }
+ const clipId = out && out.clip && out.clip.id;
+ live.forget('vod', id);
+ return res.redirect(303, live.isId(clipId) ? `/c/${clipId}?made=1` : `${back}?saved=1`);
+ });
+
r.post('/v/:id/manage', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {
r.post('/v/:id/manage', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {
const id = String(req.params.id || '');
const id = String(req.params.id || '');
if (!live.isId(id)) return notFound(req, res);
if (!live.isId(id)) return notFound(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * OpenVibe.Video "Make a clip": any signed-in viewer of a finished recording cuts a clip on OpenVibe.Live (Live's
+ * POST /api/vods/clips, as the person, with their own address for Live's per-address cooldown); times are m:ss,
+ * h:mm:ss or seconds, checked before Live is called; the new clip's page says it is being cut until Media is done.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const usernameOf = (auth) => {
+ const tok = String(auth || '').replace(/^Bearer /, '');
+ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }
+};
+
+function startLive() {
+ const vod = { id: 101, title: 'A long stream', description: '', duration_seconds: 3600, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' };
+ const clips = {};
+ const calls = [];
+ let refuse = null;
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ if (req.method === 'POST' && url.pathname === '/api/vods/clips') {
+ const body = JSON.parse(raw || '{}');
+ calls.push({ who: usernameOf(req.headers.authorization), xff: req.headers['x-forwarded-for'] || null, body });
+ if (refuse) return json(refuse.status, { error: refuse.error });
+ const clip = { id: 301, vod_id: body.vod_id, title: body.title || 'Clip', start_time: body.start_time, end_time: body.end_time, duration_seconds: body.end_time - body.start_time, status: 'processing', visibility: 'public', username: usernameOf(req.headers.authorization), playback_url: 'https://openvibe.media/c/301', created_at: '2026-10-12T21:00:00.000Z' };
+ clips[301] = clip;
+ return json(201, { clip });
+ }
+ if (url.pathname === '/api/vods/101') return json(200, { vod, clips: [] });
+ const c = /^\/api\/clips\/(\d+)$/.exec(url.pathname);
+ if (c) return clips[c[1]] ? json(200, { clip: clips[c[1]] }) : json(404, { error: 'not found' });
+ return json(404, { error: 'not found' });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, clips, calls, refuse: (r) => { refuse = r; }, close: () => new Promise((r) => server.close(r)) })));
+}
+
+function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + path);
+ const h = { Host: 'openvibe.video' };
+ if (cookie) h.cookie = cookie;
+ if (same && 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'; 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', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+(async () => {
+ const live = await startLive();
+ 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 } });
+ const sam = t.network.addUser('sam');
+ const samCookie = t.signIn(sam);
+
+ await check('signed out, the panel offers sign-in; signed in, the form', async () => {
+ const anon = await vreq(t.base, '/v/101');
+ assert.ok(anon.text.includes('Make a clip') && anon.text.includes('to cut a clip of up to 60 seconds'), 'a sign-in prompt');
+ assert.ok(!anon.text.includes('action="/v/101/clip"'));
+ const r = await vreq(t.base, '/v/101', { cookie: samCookie });
+ assert.ok(r.text.includes('action="/v/101/clip"'), 'any signed-in viewer, not only the channel');
+ assert.ok(r.text.includes('within 1:00:00'), 'the recording length');
+ assert.ok(r.text.includes('data-ovv-clip-now'), 'the Start here / End here buttons (shown by the script)');
+ });
+
+ await check('a clip is made on Live as the person, with their address, then its page says it is being cut', async () => {
+ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '1:05', end: '1:35', title: 'The good part' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.strictEqual(r.headers.location, '/c/301?made=1');
+ const call = live.calls.pop();
+ assert.strictEqual(call.who, 'sam');
+ assert.deepStrictEqual(call.body, { vod_id: 101, start_time: 65, end_time: 95, title: 'The good part' });
+ assert.ok(call.xff, 'the viewer\'s address is passed for Live\'s per-address limit');
+ const page = await vreq(t.base, '/c/301?made=1', { cookie: samCookie });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Your clip was made'), 'a made notice');
+ assert.ok(page.text.includes('This clip is being cut') && page.text.includes('data-ovv-reload="8"'), 'cutting, and the page looks again');
+ assert.ok(!page.text.includes('<video'), 'no player before there is a file');
+ live.clips[301].status = 'ready';
+ const ready = await vreq(t.base, '/c/301', { cookie: samCookie });
+ assert.ok(ready.text.includes('<video') && !ready.text.includes('being cut'), 'the player once it is ready');
+ });
+
+ await check('times are checked before Live is called', async () => {
+ const before = live.calls.length;
+ for (const form of [{ start: 'abc', end: '0:30' }, { start: '0:30', end: '0:30' }, { start: '0:40', end: '0:10' }, { start: '0:00', end: '1:01' }, { start: '1:99', end: '2:10' }]) {
+ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form });
+ assert.strictEqual(r.status, 422, JSON.stringify(form));
+ }
+ assert.strictEqual((await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, same: false, form: { start: '0:00', end: '0:10' } })).status, 403, 'cross-site');
+ assert.strictEqual((await vreq(t.base, '/v/101/clip', { method: 'POST', form: { start: '0:00', end: '0:10' } })).status, 401, 'signed out');
+ assert.strictEqual(live.calls.length, before, 'Live was never called');
+ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '3600', end: '3610.5' } });
+ assert.strictEqual(r.status, 303, 'plain seconds work too');
+ assert.deepStrictEqual(live.calls.pop().body, { vod_id: 101, start_time: 3600, end_time: 3610.5 });
+ });
+
+ await check('Live\'s refusal is shown as it is', async () => {
+ live.refuse({ status: 429, error: 'You are clipping too fast. Please wait 10 seconds before making another clip.' });
+ const r = await vreq(t.base, '/v/101/clip', { method: 'POST', cookie: samCookie, form: { start: '0:00', end: '0:10' } });
+ assert.strictEqual(r.status, 403);
+ assert.ok(r.text.includes('OpenVibe.Live said: You are clipping too fast'), r.text.slice(0, 400));
+ live.refuse(null);
+ });
+
+ await t.close();
+ await live.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });