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)

OP OpenVibers committed 12 Oct 2026 · 18ae2833da · 6 files, +259 −7

Video's last missing owner-and-viewer tool: making a clip. With this, a recording's page on openvibe.video does everything Live's page does.

- **Make a clip.** It is a panel in the recording's side column, for any signed-in viewer of a finished recording. Live's own rule allows anyone who can see the recording to clip it.
  - Start and end take m:ss, h:mm:ss or plain seconds. With the player present, "Start here" and "End here" fill them from its position; marking a start also fills an end 30 s later.
  - The title is optional, and the panel shows the 60 s maximum and the recording's length.
  - Signed out, the panel offers sign-in.
- **Made on Live, as the person.** The form is a same-origin POST, and the times are checked first: they must parse, the end must be at least a second after the start, and the clip must be 60 s or less. openvibe.video then calls Live's `POST /api/vods/clips` with the person's Network token.
  - It also sends their address in `X-Forwarded-For`. Live limits clip-making per address as well as per person, and every call from here comes from one server, so without this one viewer's clip would hold back everyone else's for ten seconds.
  - Live's channel settings (clipping disabled, the default visibility) and its limits apply, and its refusal is shown as written ("You are clipping too fast…").
- **While Media cuts it.** The new clip's page says so in the player's place and looks again every 8 s by itself. That clip's cached read is dropped each time (only that item), so the player appears as soon as the clip is ready.
- The owner panel no longer says "New clips are still cut on OpenVibe.Live". The README describes clip-making and lists the route.

Tests: the new `video-clip.test.js` covers:
- the panel for signed-out and signed-in viewers;
- a clip made as the person with their address and the exact times sent;
- the "being cut" page without a player, then the player once ready;
- five bad time forms, cross-site and signed-out refused with no Live call;
- plain seconds;
- Live's 429 shown.

The full suite passes (28 of 28). axe (WCAG 2.1 A/AA) is clean on the recording page with the panel open (1366 px) and on a clip being cut (412 px).

Changes

ddiff --git a/README.md b/README.md
iindex a92494e..c83242d 100644
---- a/README.md
++++ b/README.md
@@@ -138,8 +138,13 @@ view.
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
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 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 |
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 5b87e5c..310beaa 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 4ecdba3..711ba69 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -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);
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex a5ec510..cb23873 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -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 = console
setClipTitle: (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) });
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 27e6593..3c0840d 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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 i
const 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 i
scripts: ['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);
ddiff --git a/test/video-clip.test.js b/test/video-clip.test.js
nnew file mode 100644
iindex 0000000..2b37cee
---- /dev/null
++++ b/test/video-clip.test.js
@@@ -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); });