openvibers/openvibe.mediahub Commits
Content labels on Video and Pics: chips, each viewer's choice, and the owners' pickers (#253)
Changes
@@@ -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 youupload is not kept), `/content-policy` (what is not allowed, how a rule is enforced, and what this site does not@@@ -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 -->@@@ -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"}@@@ -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",@@@ -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"},@@@ -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); }nnew file mode 100644@@@ -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; } }@@@ -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; } }@@@ -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; }@@@ -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@@@ -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" nameif (!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}`);});nnew file mode 100644@@@ -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 };@@@ -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>`;@@@ -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="ovvimage: 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-climage: 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`);nnew file mode 100644@@@ -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); });@@@ -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.8jsBrotliKB: 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.5cssFiles: 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.0cssBrotliKB: 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};
@@ -4,6 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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 —
- 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
`/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
upload is not kept), `/content-policy` (what is not allowed, how a rule is enforced, and what this site does not
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
-- openvibe-shared: v4.93.2
+- openvibe-shared: v4.94.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.74.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.",
"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": [
"works": [
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
],
],
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.74.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.",
"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"
"updated": "2026-11-02"
}
}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -1654,9 +1654,9 @@
@@ -1654,9 +1654,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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-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",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -84,3 +84,10 @@ button.sc-btn.sc-primary { border: 0; }
@@ -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[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; }
.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); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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; } }
@@ -205,3 +205,9 @@
@@ -205,3 +205,9 @@
/* The kit's rows are one line each; on a policy page the whole sentence has to show. */
/* 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-sub > span:first-child,
.ovp-policy .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }
.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; } }
@@ -572,7 +572,11 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -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--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; }
.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; } }
@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
/* 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
@@ -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-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;
.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); }
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; }
@@ -49,7 +49,7 @@ function renderPage(o) {
@@ -49,7 +49,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/pics.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.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}
${(o.sharedScripts || []).map((rel) => `<script src="${ovServe.url(rel)}" defer></script>`).join('\n')}`;
${(o.sharedScripts || []).map((rel) => `<script src="${ovServe.url(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
const account = signedIn
@@ -33,6 +33,7 @@ const { sameOrigin } = require('../http/principal');
@@ -33,6 +33,7 @@ const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
const { formatBytes } = require('../drive/rules');
const { html, raw, notice, time } = require('../render/html');
const { html, raw, notice, time } = require('../render/html');
+const labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const store = require('./store');
@@ -184,7 +185,11 @@ function createPicsRoutes(ctx) {
@@ -184,7 +185,11 @@ function createPicsRoutes(ctx) {
// ── Pages: forms are urlencoded, uploads multipart ──────
// ── Pages: forms are urlencoded, uploads multipart ──────
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
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>`);
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 } = {}) {
function uploadForm(req, { compact = false } = {}) {
@@ -337,13 +342,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -337,13 +342,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
image: hidden ? undefined : L.direct, ogType: 'article',
scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
+ ...(owner ? { localStyles: ['css/labels.css'] } : {}),
private: deleteKey ? true : undefined,
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
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>` : ''}
${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') : ''}
${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">
<article class="ovp-picture">
<h1 class="ovp-title">${title}</h1>
<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>
<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.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>` : ''}
${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">
@@ -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-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">
<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>
<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>
<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>` : ''}
${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>` : ''}
${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
@@ -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;
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const row = await ownPicture(req, res);
const row = await ownPicture(req, res);
if (!row) return undefined;
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}`);
return res.redirect(303, `/i/${row.slug}`);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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 };
@@ -64,7 +64,7 @@ function renderPage(o) {
@@ -64,7 +64,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/video.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
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`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>`;
: html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
@@ -37,7 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');
@@ -37,7 +37,7 @@ const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createChatClient } = require('./chat-client');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const { createSearchReads, CATEGORIES, isCategory } = require('./search-reads');
const rules = require('../drive/rules');
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"). */
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
@@ -534,10 +534,13 @@ function cardPlaceholder(o, icon) {
@@ -534,10 +534,13 @@ function cardPlaceholder(o, icon) {
function card(o) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
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');
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">
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
<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])}
${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
</div>
</div>
</article>`;
</article>`;
@@ -549,6 +552,7 @@ function feedCard(item, { views = false } = {}) {
@@ -549,6 +552,7 @@ function feedCard(item, { views = false } = {}) {
href,
href,
kind: item.kind,
kind: item.kind,
badge: item.ai ? 'AI clip' : null,
badge: item.ai ? 'AI clip' : null,
+ labels: labelsUi.inForce(item),
thumb: httpsUrl(item.thumbnail_url),
thumb: httpsUrl(item.thumbnail_url),
title: item.title || (item.kind === 'clip' ? 'Clip' : 'Stream recording'),
title: item.title || (item.kind === 'clip' ? 'Clip' : 'Stream recording'),
channel: item.channel && item.channel.username ? item.channel.username : null,
channel: item.channel && item.channel.username ? item.channel.username : null,
@@ -563,6 +567,8 @@ function feedCard(item, { views = false } = {}) {
@@ -563,6 +567,8 @@ function feedCard(item, { views = false } = {}) {
function vodCard(v) {
function vodCard(v) {
return card({
return card({
href: `/v/${v.id}`,
href: `/v/${v.id}`,
+ labels: labelsUi.inForce(v),
+ owner: v.username || null,
thumb: httpsUrl(v.thumbnail_url),
thumb: httpsUrl(v.thumbnail_url),
title: v.title || 'Stream recording',
title: v.title || 'Stream recording',
duration: v.duration_seconds != null ? durationLabel(v.duration_seconds) : null,
duration: v.duration_seconds != null ? durationLabel(v.duration_seconds) : null,
@@ -577,6 +583,8 @@ function clipCard(c) {
@@ -577,6 +583,8 @@ function clipCard(c) {
href: `/c/${c.id}`,
href: `/c/${c.id}`,
kind: 'clip',
kind: 'clip',
badge: c.auto_generated ? 'AI clip' : null,
badge: c.auto_generated ? 'AI clip' : null,
+ labels: labelsUi.inForce(c),
+ owner: c.username || null,
thumb: httpsUrl(c.thumbnail_url),
thumb: httpsUrl(c.thumbnail_url),
title: c.title || 'Clip',
title: c.title || 'Clip',
duration: dur != null ? durationLabel(dur) : null,
duration: dur != null ? durationLabel(dur) : null,
@@ -730,11 +738,12 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
@@ -730,11 +738,12 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
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}`;
- 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">
<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>
<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>` : ''}
${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')}
${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>
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
</form>
</form>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
@@ -917,6 +926,7 @@ ${visibilityChoices(item.visibility || 'public')}
@@ -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;
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({
return card({
href: `${livePublic}/@${s.username}`, live: true, badge: 'Live',
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`,
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`,
title: s.title || `${s.display_name || s.username} is live`,
channel: s.username, channelHref: `${livePublic}/@${s.username}`,
channel: s.username, channelHref: `${livePublic}/@${s.username}`,
@@ -1263,14 +1273,16 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
+ ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
${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">
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
+${labelsUi.chips(labelsUi.inForce(vod))}
${what}
${what}
<div class="ovv-head-row">
<div class="ovv-head-row">
<div class="ovv-byline">
<div class="ovv-byline">
@@ -1385,15 +1397,17 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -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',
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
+ ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
<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') : ''}
${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>`
${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">
<div class="ovv-head">
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
+${labelsUi.chips(labelsUi.inForce(clip))}
${what}
${what}
${clipCredit}
${clipCredit}
<div class="ovv-head-row">
<div class="ovv-head-row">
@@ -1423,8 +1437,9 @@ ${up.html}
@@ -1423,8 +1437,9 @@ ${up.html}
if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };
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 (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.' };
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.' };
if (labels === null) return { error: 'Choose the labels from the list.' };
return { title, description, visibility, labels };
return { title, description, visibility, labels };
}
}
@@ -1500,7 +1515,8 @@ ${up.html}
@@ -1500,7 +1515,8 @@ ${up.html}
try {
try {
if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));
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.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); }
} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
live.forget('clip', id);
live.forget('clip', id);
return res.redirect(303, `${back}?saved=1`);
return res.redirect(303, `${back}?saved=1`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -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); });
@@ -21,7 +21,7 @@ const BUDGETS = {
@@ -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
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
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)
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)
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
externalFiles: 0, // 0
};
};