openvibers/openvibe.mediahub Commits

Content labels on Video and Pics: chips, each viewer's choice, and the owners' pickers (#253)

OP OpenVibers committed 11 days ago · 7e265988e1 · 16 files, +411 −24

* Content labels on Video and Pics: chips, each viewer's choice, and the owners' pickers (L1)

* Changelog: content labels on Video and Pics

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d4c2823..7163d3c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- Content labels show on openvibe.video and OpenVibe.Pics (2026-11-02, L1). Cards, watch pages and pictures say in
++ words what they carry (Nudity, Strong language, 18+…), and each viewer's own choice on openvibe.network shows,
++ blurs or hides them. A maker always sees their own; on a video's or picture's own page a hide covers it with a
++ "Show" button rather than leaving the page empty. Owners set the labels in the edit forms: AI suggestions are
++ marked and never ticked for them, and ticking nothing clears them. openvibe-shared 4.94.0.
- OpenVibe.Pics says in writing what it does with a picture (2026-10-30, the market research): three policy pages —
`/exif` (the metadata — the GPS position, the camera, the time it was taken — is taken out by us, and the file you
upload is not kept), `/content-policy` (what is not allowed, how a rule is enforced, and what this site does not
ddiff --git a/README.md b/README.md
iindex 7846d33..d4b36ee 100644
---- a/README.md
++++ b/README.md
@@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.74.0
--- openvibe-shared: v4.93.2
++- openvibe-shared: v4.94.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b3cb4dc..805df0b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,7 @@
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.74.0",
-- "openvibe-shared v4.93.2"
++ "openvibe-shared v4.94.0"
],
"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
@@@ -44,7 +44,7 @@
],
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.74.0",
-- "shared": "openvibe-shared v4.93.2",
++ "shared": "openvibe-shared v4.94.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
"updated": "2026-11-02"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 99251ca..b1f1e18 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
@@@ -1654,9 +1654,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.93.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
-- "integrity": "sha512-wprQsl5zBZRwosJePELWGO1fmgFomKTbf1SyKsRVC4a0cpcronW6MLiWAVpAn3xm/IYSpw24/4Gf4f8AZQ0vAw==",
++ "version": "4.94.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
++ "integrity": "sha512-iMsgL7a5vxouygXsWxQACJrnRubhpe6tnMAKjiJZ0m6Fn+hFcJjQzrI/fERZB3ToC+xK4zO6b6hsm4zK6x4MYg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 2adf70a..3967f0c 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 4624b72..662e915 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -84,3 +84,10 @@ button.sc-btn.sc-primary { border: 0; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
++
++/* Content labels (L1). The chips say what a thing carries, as Shared's kit.labels draws them (neither site loads the
++ kit's sheet on every page); each viewer's own choice (Shared's ov-labels.js) shows, covers or hides the thing. */
++.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }
++.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em; line-height: 1.2;
++ color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
ddiff --git a/public/css/labels.css b/public/css/labels.css
nnew file mode 100644
iindex 0000000..7ff2ff4
---- /dev/null
++++ b/public/css/labels.css
@@@ -0,0 +1,25 @@
++/* OpenVibe.MediaHub: the content labels picker (L1) in an owner's edit form, on openvibe.video and OpenVibe.Pics.
++ Loaded only on a page that draws the picker (layout localStyles), so no other page pays for it. */
++/* The labels in an edit form: six toggles, two or three to a row, each with what it covers. */
++.ovl-pick { border: 0; margin: 4px 0 0; padding: 0; min-width: 0; }
++.ovl-pick legend { padding: 0; margin-bottom: 4px; font-size: .85rem; font-weight: 700; }
++.ovl-help { margin: 0 0 8px; font-size: .8rem; line-height: 1.4; color: var(--text-muted, #7386a3); max-width: 62ch; }
++.ovl-opts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
++.ovl-pick .ovl-opt { position: relative; display: block; cursor: pointer; font-size: 1rem; font-weight: 400; color: inherit; }
++.ovl-opt input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
++.ovl-card { position: relative; display: grid; align-content: start; gap: 2px; height: 100%; box-sizing: border-box; padding: 10px 12px 10px 36px; border-radius: 12px;
++ border: 1px solid var(--border-light, #2c3d5c); background: color-mix(in srgb, var(--bg-card, #131c2e) 70%, transparent); transition: border-color .15s, background .15s; }
++.ovl-card::before { content: ""; position: absolute; left: 12px; top: 12px; width: 16px; height: 16px; box-sizing: border-box; border-radius: 5px;
++ border: 1.5px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0a0f1c); }
++.ovl-card b { font-size: .9rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.ovl-card small { font-size: .76rem; line-height: 1.3; color: var(--text-muted, #7386a3); }
++.ovl-sug { justify-self: start; margin-top: 3px; padding: 0 7px; border-radius: 999px; font-style: normal; font-size: .7rem; font-weight: 700;
++ color: var(--accent-light, #60a5fa); border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
++.ovl-opt input:checked + .ovl-card { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, var(--bg-card, #131c2e)); }
++.ovl-opt input:checked + .ovl-card::before { border-color: var(--accent, #3b82f6); background: var(--accent, #3b82f6)
++ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2l3 3 6-6.4' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat; }
++.ovl-opt--adult input:checked + .ovl-card { border-color: var(--warning-text, #fbbf24); background: color-mix(in srgb, var(--warning-text, #fbbf24) 10%, var(--bg-card, #131c2e)); }
++.ovl-opt--adult input:checked + .ovl-card::before { border-color: var(--warning-text, #fbbf24); background-color: var(--warning-text, #fbbf24); }
++.ovl-opt input:focus-visible + .ovl-card { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovl-opt:hover .ovl-card { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border-light, #2c3d5c)); }
++@media (prefers-reduced-motion: reduce) { .ovl-card { transition: none; } }
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex 30f664a..2d9e0df 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -205,3 +205,9 @@
/* The kit's rows are one line each; on a policy page the whole sentence has to show. */
.ovp-policy .ovk-row-sub > span:first-child,
.ovp-policy .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }
++
++/* A labelled tile and picture (L1): the cover takes the tile's place in the grid; the chips sit under the title. */
++.ovp-tile-cover { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
++.ovp-tile-cover > .ovp-tile { height: 100%; border-radius: inherit; }
++.ovp-title + .ovk-labels { margin: -4px 0 12px; }
++@media (max-width: 640px) { .ovp-tile-cover { border-radius: 8px; } }
ddiff --git a/public/css/video.css b/public/css/video.css
iindex f6a1fdf..3634161 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -572,7 +572,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-companion--tabs, .ovv-companion--one { height: auto; max-height: min(560px, 68vh); max-height: min(560px, 68dvh); }
.ovv-companion .ovv-tp-body { flex: 0 1 auto; }
}
--@media (max-width: 420px) { .ovv-vis-card small { display: none; } }
++@media (max-width: 420px) {
++ .ovv-vis { grid-template-columns: minmax(0, 1fr); }
++ .ovv-vis-card { grid-template-columns: 28px minmax(0, 1fr); justify-items: start; align-items: center; column-gap: 10px; text-align: left; padding: 10px 12px; }
++ .ovv-vis-card i { grid-row: span 2; justify-self: center; }
++}
@media (prefers-reduced-motion: reduce) { .ovv-owner[open] { animation: none; } }
/* Home, 10-28: the stats are links laid out as an even grid (the live tile first, with its pulse), the hero's actions
@@@ -651,3 +655,9 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
.ovv-card-who .ovv-who-bg { position: absolute; inset: -12%; width: 124%; height: 124%; object-fit: cover; filter: blur(22px) saturate(1.2) brightness(.62); }
.ovv-card-who .ovv-who-face { position: relative; width: 30%; max-width: 84px; aspect-ratio: 1; height: auto; border-radius: 50%; object-fit: cover;
box-shadow: 0 0 0 3px color-mix(in srgb, #fff 22%, transparent), 0 8px 24px rgba(0, 0, 0, .45); }
++
++/* A labelled card's picture sits in a cover (Shared's ov-labels covers it with a "Show" button, outside the hidden link). */
++.ovv-card-cover { position: relative; border-radius: 12px; overflow: hidden; }
++.ovv-card-cover > .ovv-card-media { border-radius: inherit; }
++.ovv-card-body > .ovk-labels { margin: .3rem 0 0; }
++.ovv-head > .ovk-labels { margin: .35rem 0 .1rem; }
ddiff --git a/server/pics/layout.js b/server/pics/layout.js
iindex 2754f41..ffbd339 100644
---- a/server/pics/layout.js
++++ b/server/pics/layout.js
@@@ -49,7 +49,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/pics.css')}">
--${(o.styles || []).map((rel) => `<link rel="stylesheet" href="${ovServe.url(rel)}">`).join('\n')}
++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${(o.styles || []).map((rel) => `<link rel="stylesheet" href="${ovServe.url(rel)}">`).join('\n')}
${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}
${(o.sharedScripts || []).map((rel) => `<script src="${ovServe.url(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 8919ecb..030d411 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -33,6 +33,7 @@ const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
const { html, raw, notice, time } = require('../render/html');
++const labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
@@@ -184,7 +185,11 @@ function createPicsRoutes(ctx) {
// ── Pages: forms are urlencoded, uploads multipart ──────
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
-- const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;
++ const tile = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;
++ const thumb = (row) => {
++ const labels = labelsUi.inForce(row);
++ return labels.length ? html`<div class="ovp-tile-cover"${labelsUi.attrs(labels, { owner: row.owner })}>${tile(row)}</div>` : tile(row);
++ };
const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
function uploadForm(req, { compact = false } = {}) {
@@@ -337,13 +342,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
++ ...(owner ? { localStyles: ['css/labels.css'] } : {}),
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
<article class="ovp-picture">
<h1 class="ovp-title">${title}</h1>
--<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
++${labelsUi.chips(labelsUi.inForce(row))}
++<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
@@@ -369,6 +376,7 @@ ${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours">
<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
<label for="e-alt">Alt text</label><input id="e-alt" name="alt" type="text" maxlength="160" value="${row.alt || ''}" aria-describedby="e-alt-help">
<p class="muted" id="e-alt-help">What the picture shows, in one sentence, for people who cannot see it. Screen readers say it, and the embed snippets above carry it.${row.alt_source === 'ai' ? ' This one was written by AI: check it says what you want.' : ''}</p>
++${labelsUi.picker(row, { what: 'the picture', id: 'e-labels' })}
<p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
${describeOn && !hidden ? html`<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/describe"><button class="ovp-btn" type="submit">${row.ai_described_at ? 'Write it again with AI' : 'Write alt text with AI'}</button> <span class="muted">Sends this picture to OpenVibe.AI, which may use an outside model provider, to draft alt text, a caption and tags. Nothing of the picture is kept there.</span></form>` : ''}
${tagsOf(row).length ? html`<p class="muted">AI tags: ${tagsOf(row).join(', ')}</p>` : ''}
@@@ -407,7 +415,9 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const row = await ownPicture(req, res);
if (!row) return undefined;
-- await pics.edit(row, { title: req.body.title, description: req.body.description, alt: req.body.alt });
++ const labels = labelsUi.fromForm(req.body);
++ if (labels === null) return picturePage(req, res, row, { message: 'Choose the labels from the list.', tone: 'warn' }, 422);
++ await pics.edit(row, { title: req.body.title, description: req.body.description, alt: req.body.alt, ...(labels !== undefined ? { labels } : {}) });
return res.redirect(303, `/i/${row.slug}`);
});
ddiff --git a/server/render/labels-ui.js b/server/render/labels-ui.js
nnew file mode 100644
iindex 0000000..46d8918
---- /dev/null
++++ b/server/render/labels-ui.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * Content labels on the page (L1, labels and choices; Contracts common.content-labels@1).
++ *
++ * What openvibe.video and OpenVibe.Pics draw for a label, server-side and without JavaScript:
++ * - attrs(): data-ov-labels on the labelled thing, which Shared's ov-labels.js (loaded by the navbar when one
++ * appears) uses to apply each viewer's own choice: show, blur behind a "Show" button, or hide. The maker always
++ * sees their own (data-ov-labels-owner: '@username' or their subject). On the thing's own page (opened), a hide
++ * covers it instead of emptying the page (openvibe-shared 4.94.0).
++ * - chips(): the labels in words beside a title, so anyone can see why a thing is covered.
++ * - picker() and fromForm(): the labels in an edit form. The AI's suggestions are marked and never ticked for the
++ * creator; nothing changes until the creator ticks one.
++ * A label is a choice for viewers, never a ban: nothing here removes anything for anybody.
++ */
++const { html, raw } = require('./html');
++const contentLabels = require('../labels');
++
++const NAMES = Object.freeze({ nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' });
++const HINTS = Object.freeze({
++ nudity: 'Nudity, art included',
++ language: 'Swearing and slurs',
++ graphic: 'Blood, injury, gore',
++ '18+': 'For adults only',
++ flashing: 'Strobes, fast flashes',
++ spoilers: 'Endings and twists',
++});
++
++/** The labels in force on a stored row or a wire item: its content_labels, or a bare labels list. */
++function inForce(x) {
++ if (!x || typeof x !== 'object') return [];
++ const c = x.content_labels;
++ // The contract's object, a jsonb string, or a bare list.
++ if (c !== undefined) return contentLabels.labelsOf(Array.isArray(c) ? { labels: c } : c).labels;
++ return contentLabels.labelsOf({ labels: x.labels }).labels;
++}
++
++/** The suggestions waiting for the creator (never shown to viewers). */
++const suggestedOf = (x) => (x && x.content_labels && !Array.isArray(x.content_labels) ? contentLabels.labelsOf(x.content_labels).suggested : []);
++
++const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
++const SUBJECT = /^[A-Za-z0-9_-]{1,80}$/;
++
++/**
++ * The maker, as ov-labels reads it: their subject from a stored owner ('user:<subject>', as Pics keeps it), a bare
++ * subject (usr_…, Network's form), or '@username' from a username. Anything else is no owner.
++ */
++function ownerAttr(owner) {
++ const s = String(owner || '');
++ if (s.startsWith('user:')) return SUBJECT.test(s.slice(5)) ? s.slice(5) : null;
++ if (/^usr_/.test(s) && SUBJECT.test(s)) return s;
++ const name = s.startsWith('@') ? s.slice(1) : s;
++ return USERNAME.test(name) ? `@${name}` : null;
++}
++
++/** ' data-ov-labels="…"' and its companions for a labelled element (nothing for none). */
++function attrs(labels, { owner = null, opened = false } = {}) {
++ if (!labels || !labels.length) return '';
++ const who = ownerAttr(owner);
++ return html` data-ov-labels="${labels.join(',')}"${who ? html` data-ov-labels-owner="${who}"` : ''}${opened ? raw(' data-ov-labels-opened') : ''}`;
++}
++
++/** The labels in words (18+ in the warning colour), as Shared's kit.labels draws them. */
++function chips(labels) {
++ if (!labels || !labels.length) return '';
++ return html`<ul class="ovk-labels" aria-label="Content labels">${labels.map((l) => html`<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${l}">${NAMES[l]}</li>`)}</ul>`;
++}
++
++/**
++ * The picker for an edit form: one toggle per label. `item` is the row or wire item (its content_labels).
++ * A hidden labels_form field says the picker was there, so ticking nothing clears the labels.
++ */
++function picker(item, { what = 'it', id = 'labels' } = {}) {
++ const now = new Set(inForce(item));
++ const suggested = new Set(suggestedOf(item).filter((l) => !now.has(l)));
++ return html`<fieldset class="ovl-pick" aria-describedby="${id}-help"><legend>Content labels</legend>
++<p class="ovl-help" id="${id}-help">Each viewer picks what they see for a label: show it, blur it, or hide it. A label never takes ${what} down.${suggested.size ? ' AI suggested the ones marked; nothing changes until you tick one.' : ''}</p>
++<input type="hidden" name="labels_form" value="1">
++<div class="ovl-opts">${contentLabels.LABELS.map((l) => html`<label class="ovl-opt${l === '18+' ? ' ovl-opt--adult' : ''}"><input type="checkbox" name="labels" value="${l}"${now.has(l) ? raw(' checked') : ''}><span class="ovl-card"><b>${NAMES[l]}</b><small>${HINTS[l]}</small>${suggested.has(l) ? html`<em class="ovl-sug">Suggested</em>` : ''}</span></label>`)}</div>
++</fieldset>`;
++}
++
++/**
++ * The labels a submitted form asks for: a clean list, [] when the picker was there with nothing ticked, undefined when
++ * the form had no picker (keep what it has), or null when it named something that is not a label.
++ */
++function fromForm(body) {
++ const b = body || {};
++ if (b.labels === undefined) return b.labels_form ? [] : undefined;
++ return contentLabels.parseLabels([].concat(b.labels));
++}
++
++module.exports = { NAMES, HINTS, inForce, suggestedOf, ownerAttr, attrs, chips, picker, fromForm };
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 635f1c8..602ce6d 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -64,7 +64,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
--${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
: html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex c08b2e1..97e4f36 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -37,7 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const rules = require('../drive/rules');
--const contentLabels = require('../labels');
++const labelsUi = require('../render/labels-ui');
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
@@@ -534,10 +534,13 @@ function cardPlaceholder(o, icon) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');
-- return html`<article class="ovv-card">
--<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>
++ const labels = o.labels || [];
++ const media = html`<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>`;
++ return html`<article class="ovv-card"${labelsUi.attrs(labels, { owner: o.owner || o.channel })}>
++${labels.length ? html`<div class="ovv-card-cover" data-ov-labels-media>${media}</div>` : media}
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
++${labelsUi.chips(labels)}
${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
</div>
</article>`;
@@@ -549,6 +552,7 @@ function feedCard(item, { views = false } = {}) {
href,
kind: item.kind,
badge: item.ai ? 'AI clip' : null,
++ labels: labelsUi.inForce(item),
thumb: httpsUrl(item.thumbnail_url),
title: item.title || (item.kind === 'clip' ? 'Clip' : 'Stream recording'),
channel: item.channel && item.channel.username ? item.channel.username : null,
@@@ -563,6 +567,8 @@ function feedCard(item, { views = false } = {}) {
function vodCard(v) {
return card({
href: `/v/${v.id}`,
++ labels: labelsUi.inForce(v),
++ owner: v.username || null,
thumb: httpsUrl(v.thumbnail_url),
title: v.title || 'Stream recording',
duration: v.duration_seconds != null ? durationLabel(v.duration_seconds) : null,
@@@ -577,6 +583,8 @@ function clipCard(c) {
href: `/c/${c.id}`,
kind: 'clip',
badge: c.auto_generated ? 'AI clip' : null,
++ labels: labelsUi.inForce(c),
++ owner: c.username || null,
thumb: httpsUrl(c.thumbnail_url),
title: c.title || 'Clip',
duration: dur != null ? durationLabel(dur) : null,
@@@ -730,11 +738,12 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
-- return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, delete</span></summary>
++ return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels, delete</span></summary>
<form class="ovv-owner-form" method="post" action="${action}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
${visibilityChoices(item.visibility || 'public')}
++${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
</form>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
@@@ -917,6 +926,7 @@ ${visibilityChoices(item.visibility || 'public')}
const started = s.started_at ? new Date(`${String(s.started_at).replace(' ', 'T')}${/[zZ]|[+-]\d\d:?\d\d$/.test(String(s.started_at)) ? '' : 'Z'}`) : null;
return card({
href: `${livePublic}/@${s.username}`, live: true, badge: 'Live',
++ labels: labelsUi.inForce(s),
thumb: LIVE_THUMB.test(String(s.thumbnail_url || '')) ? httpsUrl(`${livePublic}${s.thumbnail_url}`) : `https://openvibe.media/live/@${s.username}/frame.jpg?w=640`,
title: s.title || `${s.display_name || s.username} is live`,
channel: s.username, channelHref: `${livePublic}/@${s.username}`,
@@@ -1263,14 +1273,16 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js'],
++ ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
--<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
++<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(vod), { owner: vod.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
++${labelsUi.chips(labelsUi.inForce(vod))}
${what}
<div class="ovv-head-row">
<div class="ovv-byline">
@@@ -1385,15 +1397,17 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js'],
++ ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
${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" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
++ : html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<div class="ovv-head">
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
++${labelsUi.chips(labelsUi.inForce(clip))}
${what}
${clipCredit}
<div class="ovv-head-row">
@@@ -1423,8 +1437,9 @@ ${up.html}
if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };
if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };
if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };
-- // L1: the creator labels the recording. Repeated form fields arrive as an array; absent keeps what it has.
-- const labels = b.labels === undefined ? undefined : contentLabels.parseLabels([].concat(b.labels));
++ // L1: the creator labels the recording. Repeated form fields arrive as an array; a form without the picker keeps
++ // what it has, and the picker with nothing ticked clears them (labels_form).
++ const labels = labelsUi.fromForm(b);
if (labels === null) return { error: 'Choose the labels from the list.' };
return { title, description, visibility, labels };
}
@@@ -1500,7 +1515,8 @@ ${up.html}
try {
if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));
if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));
-- if (f.labels !== undefined) await asPerson(req, res, (token) => live.setClipLabels(token, id, f.labels));
++ const sameLabels = (x, y) => x.length === y.length && x.every((l) => y.includes(l));
++ if (f.labels !== undefined && !sameLabels(f.labels, labelsUi.inForce(current))) await asPerson(req, res, (token) => live.setClipLabels(token, id, f.labels));
} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
live.forget('clip', id);
return res.redirect(303, `${back}?saved=1`);
ddiff --git a/test/labels-ui.test.js b/test/labels-ui.test.js
nnew file mode 100644
iindex 0000000..6ecb8db
---- /dev/null
++++ b/test/labels-ui.test.js
@@@ -0,0 +1,216 @@
++'use strict';
++/**
++ * Content labels on the pages (L1, server/render/labels-ui.js): openvibe.video's cards and watch pages and
++ * OpenVibe.Pics' tiles and picture pages carry data-ov-labels (Shared's ov-labels.js applies each viewer's choice),
++ * the maker is named so they always see their own, a thing's own page is "opened" (a hide covers it instead of
++ * emptying the page), the labels show in words, and the owners' edit forms set them: the AI's suggestions are marked
++ * and never ticked, and ticking nothing clears them.
++ * node test/labels-ui.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++const ui = require('../server/render/labels-ui');
++
++// ── The module ──────────────────────────────────────────────────────────────────────────────────────────────────
++assert.deepStrictEqual(ui.inForce({ content_labels: { labels: ['nudity', '18+', 'gore'], suggested: ['language'] } }), ['nudity', '18+'], 'the labels in force, known ones only');
++assert.deepStrictEqual(ui.inForce({ content_labels: '{"labels":["spoilers"],"suggested":[]}' }), ['spoilers'], 'a jsonb string');
++assert.deepStrictEqual(ui.inForce({ content_labels: ['graphic'] }), ['graphic'], 'a bare list');
++assert.deepStrictEqual(ui.inForce({ labels: ['flashing'] }), ['flashing'], 'a wire item\'s labels');
++assert.deepStrictEqual(ui.inForce({ title: 'x' }), [], 'none');
++assert.deepStrictEqual(ui.inForce(null), []);
++assert.deepStrictEqual(ui.suggestedOf({ content_labels: { labels: [], suggested: ['language'] } }), ['language']);
++
++assert.strictEqual(ui.ownerAttr('kim'), '@kim', 'a username is named with @');
++assert.strictEqual(ui.ownerAttr('@kim'), '@kim');
++assert.strictEqual(ui.ownerAttr('user:usr_01ABC'), 'usr_01ABC', 'a stored owner gives its subject');
++assert.strictEqual(ui.ownerAttr('usr_01ABC'), 'usr_01ABC', 'a bare subject');
++assert.strictEqual(ui.ownerAttr('anon:abc'), null, 'an anonymous upload has no owner to name');
++assert.strictEqual(ui.ownerAttr('bad name'), null);
++assert.strictEqual(ui.ownerAttr(null), null);
++
++assert.strictEqual(String(ui.attrs([])), '', 'nothing for no labels');
++assert.strictEqual(String(ui.attrs(['nudity', '18+'], { owner: 'kim' })), ' data-ov-labels="nudity,18+" data-ov-labels-owner="@kim"');
++assert.strictEqual(String(ui.attrs(['18+'], { opened: true })), ' data-ov-labels="18+" data-ov-labels-opened', 'its own page');
++
++const chips = String(ui.chips(['language', '18+']));
++assert.ok(chips.startsWith('<ul class="ovk-labels" aria-label="Content labels">'), chips);
++assert.ok(chips.includes('<li class="ovk-label" data-label="language">Strong language</li>'), 'in words');
++assert.ok(chips.includes('<li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), '18+ in the warning colour');
++assert.strictEqual(String(ui.chips([])), '');
++
++const pick = String(ui.picker({ content_labels: { labels: ['nudity'], suggested: ['flashing', 'nudity'] } }, { what: 'the clip', id: 'lx' }));
++assert.strictEqual((pick.match(/<input type="checkbox" name="labels" value="/g) || []).length, 6, 'one toggle per label');
++assert.ok(pick.includes('value="nudity" checked>'), 'a label in force is ticked');
++assert.ok(!pick.includes('value="flashing" checked'), 'a suggestion is never ticked for the creator');
++assert.strictEqual((pick.match(/class="ovl-sug">Suggested</g) || []).length, 1, 'only a suggestion not already in force is marked');
++assert.ok(pick.includes('<input type="hidden" name="labels_form" value="1">'), 'the form says the picker was there');
++assert.ok(pick.includes('never takes the clip down') && pick.includes('aria-describedby="lx-help"') && pick.includes('id="lx-help"'), 'the help names the thing and is tied to the fieldset');
++
++assert.strictEqual(ui.fromForm({ title: 'x' }), undefined, 'no picker: keep what it has');
++assert.deepStrictEqual(ui.fromForm({ labels_form: '1' }), [], 'the picker with nothing ticked clears them');
++assert.deepStrictEqual(ui.fromForm({ labels_form: '1', labels: 'language' }), ['language'], 'one ticked arrives as a string');
++assert.deepStrictEqual(ui.fromForm({ labels_form: '1', labels: ['18+', 'nudity', '18+'] }), ['18+', 'nudity']);
++assert.strictEqual(ui.fromForm({ labels_form: '1', labels: ['gore'] }), null, 'not a label');
++
++// ── The pages ───────────────────────────────────────────────────────────────────────────────────────────────────
++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; }
++};
++
++/** A Live that keeps a VOD and a clip with their labels, and records what it is asked to change. */
++function startLive() {
++ const vods = {
++ 301: { id: 301, title: 'Kim labelled', duration_seconds: 45, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/301', visibility: 'public', username: 'kim', display_name: 'Kim',
++ content_labels: { labels: ['nudity', '18+'], suggested: ['flashing'] } },
++ };
++ const clips = {
++ 401: { id: 401, vod_id: 301, title: 'Kim clip', start_time: 5, end_time: 15, duration_seconds: 10, playback_url: 'https://openvibe.media/c/401', visibility: 'public', username: 'kim', display_name: 'Kim',
++ content_labels: { labels: ['spoilers'], suggested: [] } },
++ };
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://127.0.0.1');
++ const who = usernameOf(req.headers.authorization);
++ const json = (status, body) => { const t = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(t); };
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const body = raw ? JSON.parse(raw) : null;
++ if (req.method !== 'GET') calls.push({ method: req.method, path: url.pathname, who, body });
++ const m = /^\/api\/(vods|clips)\/(\d+)(\/title|\/visibility|\/labels)?$/.exec(url.pathname);
++ if (m) {
++ const store = m[1] === 'vods' ? vods : clips;
++ const item = store[m[2]];
++ if (!item) return json(404, { error: 'not found' });
++ if (req.method === 'GET') return json(200, m[1] === 'vods' ? { vod: item, clips: Object.values(clips) } : { clip: item });
++ if (who !== item.username) return json(403, { error: 'Not authorized' });
++ const { labels, ...rest } = body || {};
++ Object.assign(item, rest);
++ if (labels !== undefined) item.content_labels = { labels, suggested: item.content_labels.suggested.filter((l) => !labels.includes(l)) };
++ return json(200, m[1] === 'vods' ? { vod: item } : { message: 'ok' });
++ }
++ if (url.pathname === '/api/vods' || /^\/api\/users\/[^/]+\/vods$/.test(url.pathname)) return json(200, { vods: Object.values(vods), hasMore: false, total: 1 });
++ if (url.pathname === '/api/clips' || /^\/api\/users\/[^/]+\/clips$/.test(url.pathname)) return json(200, { clips: Object.values(clips) });
++ 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}`, vods, clips, calls, close: () => new Promise((r) => server.close(r)) })));
++}
++
++/** A request to the app with a Host header (Node's fetch drops Host); form may be pairs, to repeat a field. */
++function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same = true } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const h = { Host: host };
++ if (cookie) h.cookie = cookie;
++ if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';
++ let data = body;
++ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (type) h['content-type'] = type;
++ if (data != null) h['content-length'] = String(Buffer.byteLength(data));
++ 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 != null) 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 kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const kimCookie = t.signIn(kim);
++ const samCookie = t.signIn(sam);
++ const video = (path, o) => hreq(t.base, 'openvibe.video', path, o);
++ const pics = (path, o) => hreq(t.base, 'openvibe.pics', path, o);
++ try {
++ await check('a recording\'s own page: labelled, its maker named, opened, the labels in words', async () => {
++ const r = await video('/v/301');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(r.text.includes('<div class="ovv-player-wrap" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim" data-ov-labels-opened>'), 'the player is the labelled thing');
++ assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');
++ assert.ok(!r.text.includes('Flashing lights'), 'a suggestion is never shown to viewers');
++ assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
++ });
++
++ await check('cards: the picture in a cover outside the hidden link, the chips under the title', async () => {
++ const r = await video('/@kim');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(r.text.includes('<article class="ovv-card" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim">'), 'the card is labelled');
++ assert.ok(/<div class="ovv-card-cover" data-ov-labels-media><a class="ovv-card-media" href="\/v\/301" tabindex="-1" aria-hidden="true">/.test(r.text), 'the cover wraps the link, so Show is never inside aria-hidden');
++ assert.ok(r.text.includes('<article class="ovv-card" data-ov-labels="spoilers" data-ov-labels-owner="@kim">'), 'the clip card too');
++ const page = await video('/v/301');
++ assert.ok(page.text.includes('<div class="ovv-card-cover" data-ov-labels-media><a class="ovv-card-media" href="/c/401"'), 'the clips beside the player');
++ });
++
++ await check('the owner\'s form: in force ticked, the AI\'s suggestion marked and not ticked', async () => {
++ const r = await video('/v/301', { cookie: kimCookie });
++ assert.ok(r.text.includes('who can see it, labels, delete'), 'the panel says it holds the labels');
++ assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(r.text), 'the picker\'s sheet, on the owner\'s page only');
++ assert.ok(!(await video('/v/301')).text.includes('css/labels.css'), 'a viewer\'s page does not load it');
++ assert.ok(r.text.includes('value="nudity" checked>') && r.text.includes('value="18+" checked>'), 'in force');
++ assert.ok(/value="flashing"><span class="ovl-card"><b>Flashing lights<\/b><small>[^<]+<\/small><em class="ovl-sug">Suggested<\/em>/.test(r.text), 'the suggestion, unticked and marked');
++ assert.ok(!(await video('/v/301', { cookie: samCookie })).text.includes('name="labels"'), 'not for someone else');
++ });
++
++ await check('saving the recording sends the labels; ticking nothing clears them; a stray value is refused', async () => {
++ let r = await video('/v/301/manage', { method: 'POST', cookie: kimCookie, form: [['title', 'Kim labelled'], ['visibility', 'public'], ['labels_form', '1'], ['labels', '18+'], ['labels', 'flashing']] });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.deepStrictEqual(live.calls.pop().body.labels, ['18+', 'flashing']);
++ assert.deepStrictEqual(live.vods[301].content_labels, { labels: ['18+', 'flashing'], suggested: [] }, 'the confirmed suggestion left the suggestions');
++ r = await video('/v/301/manage', { method: 'POST', cookie: kimCookie, form: [['title', 'Kim labelled'], ['visibility', 'public'], ['labels_form', '1']] });
++ assert.strictEqual(r.status, 303);
++ assert.deepStrictEqual(live.calls.pop().body.labels, [], 'nothing ticked is no labels');
++ const n = live.calls.length;
++ r = await video('/v/301/manage', { method: 'POST', cookie: kimCookie, form: [['title', 'Kim labelled'], ['visibility', 'public'], ['labels_form', '1'], ['labels', 'gore']] });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(live.calls.length, n, 'Live was never called');
++ });
++
++ await check('a clip\'s labels are sent only when they changed', async () => {
++ const n = live.calls.length;
++ let r = await video('/c/401/manage', { method: 'POST', cookie: kimCookie, form: [['title', 'Kim clip'], ['visibility', 'public'], ['labels_form', '1'], ['labels', 'spoilers']] });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.strictEqual(live.calls.length, n, 'nothing changed, nothing sent');
++ r = await video('/c/401/manage', { method: 'POST', cookie: kimCookie, form: [['title', 'Kim clip'], ['visibility', 'public'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', 'language']] });
++ assert.strictEqual(r.status, 303);
++ assert.deepStrictEqual(live.calls.slice(n).map((c) => [c.method, c.path, c.body]), [['PUT', '/api/clips/401/labels', { labels: ['spoilers', 'language'] }]]);
++ });
++
++ await check('Pics: the picture\'s page and its tile carry the labels; the uploader sets them from the form', async () => {
++ const jpeg = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#335' } }).jpeg().toBuffer();
++ const up = await pics('/api/v1/images', { method: 'POST', cookie: kimCookie, body: jpeg, type: 'image/jpeg' });
++ assert.strictEqual(up.status, 201, up.text);
++ const slug = JSON.parse(up.text).slug;
++ let r = await pics(`/i/${slug}`);
++ assert.ok(!r.text.includes('data-ov-labels'), 'no labels, nothing marked');
++ r = await pics(`/i/${slug}`, { cookie: kimCookie });
++ assert.ok(r.text.includes('<legend>Content labels</legend>') && r.text.includes('never takes the picture down'), 'the uploader\'s form has the picker');
++ assert.ok(r.text.includes('css/labels.css') && !(await pics(`/i/${slug}`)).text.includes('css/labels.css'), 'the sheet for the uploader only');
++ r = await pics(`/i/${slug}/edit`, { method: 'POST', cookie: kimCookie, form: [['title', 'Beach'], ['description', ''], ['alt', ''], ['labels_form', '1'], ['labels', 'nudity']] });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ r = await pics(`/i/${slug}`);
++ assert.ok(r.text.includes(`<figure class="ovp-figure" data-ov-labels="nudity" data-ov-labels-owner="${kim.subject}" data-ov-labels-opened>`), 'the figure: labelled, its uploader by subject, opened');
++ assert.ok(/<h1 class="ovp-title">Beach<\/h1>\s*<ul class="ovk-labels"[^>]*><li class="ovk-label" data-label="nudity">Nudity<\/li><\/ul>/.test(r.text), 'the chips under the title');
++ r = await pics('/mine', { cookie: kimCookie });
++ assert.ok(r.text.includes(`<div class="ovp-tile-cover" data-ov-labels="nudity" data-ov-labels-owner="${kim.subject}"><a class="ovp-tile" href="/i/${slug}">`), 'the tile in its cover');
++ r = await pics(`/i/${slug}/edit`, { method: 'POST', cookie: kimCookie, form: [['title', 'Beach'], ['labels_form', '1'], ['labels', 'gore']] });
++ assert.strictEqual(r.status, 422, 'not a label');
++ r = await pics(`/i/${slug}/edit`, { method: 'POST', cookie: kimCookie, form: [['title', 'Beach'], ['labels_form', '1']] });
++ assert.strictEqual(r.status, 303);
++ assert.ok(!(await pics(`/i/${slug}`)).text.includes('data-ov-labels'), 'ticking nothing cleared them');
++ });
++ } finally {
++ await t.close();
++ await live.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 23ed1c5..08eeb61 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,7 +21,7 @@ const BUDGETS = {
jsRawKB: 401, // 400.5 with openvibe-shared 4.80.0: 33 more solid glyphs in ov-glyphs.js (2026-11-01); 393.9 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 393: 393: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 with Shared 4.15.0 (2026-10-27: the compact footer opens into the whole one, the pulse line); 264.3 with Shared 4.13.0; 239.8
jsBrotliKB: 98, // 97.6 with openvibe-shared 4.80.0 (2026-11-01); 96.1 with openvibe-shared 4.77.0 (2026-11-01); 96: 96: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 Shared 4.15.0; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 36, // 34.9 with 2026-11-01: phone table cards (2026-11-01); 33.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 29 openvibe-shared 4.26.1 (hero visual); 23.6 with Shared 4.13.0 (2026-10-26: account links); measured 16.6 (shared 4.1.8: four steps two by two); was 16.5 at 15.0
++ cssRawKB: 37, // 36.3 (2026-11-02: content label chips on cards, watch pages and pictures, L1; the owners' picker is its own sheet, css/labels.css); 34.9 with 2026-11-01: phone table cards (2026-11-01); 33.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 29 openvibe-shared 4.26.1 (hero visual); 23.6 with Shared 4.13.0 (2026-10-26: account links); measured 16.6 (shared 4.1.8: four steps two by two); was 16.5 at 15.0
cssBrotliKB: 8, // 7.5 with 2026-11-01: phone table cards (2026-11-01); 7.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 5.4 with Shared 4.13.0 (2026-10-26: account links); 3.7; 4.2 with the drive previews (10-15)
externalFiles: 0, // 0
};