openvibers/openvibe.mediahub Commits

Content labels on pictures and the video manage forms (L1) (#214)

OP OpenVibers committed 11 days ago · 0f2dfdfd32 · 8 files, +154 −5

* Content labels on pictures and the video manage forms (L1)

Migration 0019 adds content_labels jsonb ({ labels, suggested }, the shape of common.content-labels@1) to
pics_images. Pics' wire shape carries it on GET, the uploader sets it with PATCH /api/v1/images/:slug
(unknown label: 422 pics.labels_invalid, nothing written), and a confirmed suggestion leaves suggested. A label
never hides a picture; the viewer's Network prefs decide.

On openvibe.video the manage forms forward labels to Live: the VOD form sends labels to PUT /api/vods/:id and
the clip form calls PUT /api/clips/:id/labels, which Live forwards to Media. No markup changes: the chips and
the gate are the orchestrator's.

* Merge remote-tracking branch 'origin/main' into refresh-214

Changes

ddiff --git a/migrations/0019_pics_content_labels.sql b/migrations/0019_pics_content_labels.sql
nnew file mode 100644
iindex 0000000..552d9bd
---- /dev/null
++++ b/migrations/0019_pics_content_labels.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- Content labels on pictures (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
++--
++-- A picture's uploader labels it (PATCH /api/v1/images/:slug { labels }); the AI may suggest a label from what it
++-- sees, stored in `suggested` and never applied. A label is not a ban: OpenVibe.Pics never hides a picture because
++-- of it, and each viewer's Network label prefs decide what they see. The shape is `{ labels, suggested }`.
++--
++-- Expand only: an older release never reads the column, and the default keeps every existing picture label-free.
++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
++CREATE INDEX IF NOT EXISTS pics_images_content_labels ON pics_images USING gin (content_labels);
ddiff --git a/server/labels.js b/server/labels.js
nnew file mode 100644
iindex 0000000..d0174ba
---- /dev/null
++++ b/server/labels.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
++ *
++ * The one label list and the shape a picture stores and returns: `{ labels, suggested }`. A creator sets a label;
++ * the AI may suggest one, and a suggestion never applies itself. A label is not a ban: OpenVibe.Pics never hides a
++ * picture because of it, and the viewer's Network prefs decide what they see.
++ */
++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
++
++const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);
++
++/** A clean label list from a creator's request, or null when it is not a list of known labels. */
++function parseLabels(value) {
++ if (!Array.isArray(value)) return null;
++ const out = [];
++ for (const label of value) {
++ if (!isLabel(label)) return null;
++ if (!out.includes(label)) out.push(label);
++ }
++ return out;
++}
++
++/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */
++function labelsOf(row) {
++ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;
++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
++ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];
++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
++}
++
++/** The shape to store. */
++const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });
++
++/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */
++function confirmed(current, labels) {
++ const now = labelsOf(current);
++ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };
++}
++
++module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex 6aeb60a..ec8174f 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -28,6 +28,7 @@ const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const store = require('./store');
++const contentLabels = require('../labels');
function createPicsApi(ctx) {
const { config, s, principal, limits } = ctx;
@@@ -98,7 +99,10 @@ function createPicsApi(ctx) {
const row = await loadOwn(req, res);
if (!row) return undefined;
const b = req.body || {};
-- return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt }), { owner: true }));
++ // L1: the uploader labels the picture; a suggestion they confirm leaves `suggested`. A label never hides it.
++ const nextLabels = b.labels !== undefined ? contentLabels.parseLabels(b.labels) : undefined;
++ if (nextLabels === null) return problem(req, res, 422, 'pics.labels_invalid', `labels must be a list of: ${contentLabels.LABELS.join(', ')}`);
++ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt, labels: nextLabels }), { owner: true }));
});
r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex 0a86912..37a74cb 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -23,6 +23,7 @@
const crypto = require('crypto');
const images = require('./images');
const store = require('./store');
++const contentLabels = require('../labels');
const { isStaff } = require('../drive/staff');
const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
@@@ -130,7 +131,7 @@ function createPics({ s, media, config, log = console, events = null }) {
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
}
-- async function edit(row, { title, description, alt }) {
++ async function edit(row, { title, description, alt, labels: nextLabels }) {
// Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.
const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;
const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };
@@@ -138,6 +139,8 @@ function createPics({ s, media, config, log = console, events = null }) {
...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
...altPatch,
++ // L1: the uploader sets the labels; a suggestion they confirm leaves `suggested`. A label never hides it.
++ ...(nextLabels !== undefined ? { content_labels: contentLabels.confirmed(row.content_labels, nextLabels) } : {}),
});
}
@@@ -245,6 +248,8 @@ function createPics({ s, media, config, log = console, events = null }) {
width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
alt: row.alt || '', alt_source: row.alt_source || null,
ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
++ // L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
++ content_labels: contentLabels.labelsOf(row),
state: row.state, created_at: row.created_at, links: links(row),
...(owner ? { report_count: Number(row.report_count) } : {}),
});
ddiff --git a/server/pics/store.js b/server/pics/store.js
iindex 4296fef..d1b5526 100644
---- a/server/pics/store.js
++++ b/server/pics/store.js
@@@ -6,7 +6,7 @@
* nobody but its uploader and staff (server/pics/service.js decides who).
*/
--const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at'];
++const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at', 'content_labels'];
const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex 37bfca9..622f4dc 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -183,6 +183,7 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
++ setClipLabels: (token, id, labels) => asPerson(token, 'PUT', `/api/clips/${id}/labels`, { labels }),
deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
/** A clip of a recording, cut by Media: { vod_id, start_time, end_time, title } → { clip } (Live's rules and limits). */
createClip: (token, fields, viewerIp) => asPerson(token, 'POST', '/api/vods/clips', fields, { viewerIp }),
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 957955f..c08b2e1 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -37,6 +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');
/** 2147483648 → "2 GB" (the drive's formatBytes, without a trailing ".0"). */
const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
@@@ -1422,7 +1423,10 @@ ${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.' };
-- return { title, description, visibility };
++ // 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));
++ if (labels === null) return { error: 'Choose the labels from the list.' };
++ return { title, description, visibility, labels };
}
const fieldError = (req, res, text, back) => page(req, res, {
title: 'Not saved', cache: cache.htmlHeaders({ private: true }),
@@@ -1459,7 +1463,7 @@ ${up.html}
const f = ownerFields(req.body || {}, 'vod');
if (f.error) return fieldError(req, res, f.error, back);
try {
-- await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility }));
++ await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility, ...(f.labels !== undefined ? { labels: f.labels } : {}) }));
} catch (err) { return ownerRefused(req, res, err, back); }
live.forget('vod', id);
return res.redirect(303, `${back}?saved=1`);
@@@ -1496,6 +1500,7 @@ ${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));
} catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
live.forget('clip', id);
return res.redirect(303, `${back}?saved=1`);
ddiff --git a/test/content-labels.test.js b/test/content-labels.test.js
nnew file mode 100644
iindex 0000000..f3d40e4
---- /dev/null
++++ b/test/content-labels.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++// Content labels on pictures (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): the uploader labels a
++// picture, the AI's `suggested` stays a suggestion until confirmed, every read carries the shape, and a label never
++// hides the picture (the viewer's Network prefs decide).
++// node test/content-labels.test.js
++const assert = require('assert');
++const http = require('http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++
++const PICS = { Host: 'openvibe.pics' };
++const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };
++
++/** t.get with the Pics Host header (Node's fetch drops Host). */
++function hostClient(t) {
++ return (p, o = {}) => new Promise((resolve, reject) => {
++ const headers = { ...SAME, ...(o.headers || {}) };
++ if (o.as) headers.cookie = t.signIn(o.as);
++ let body = o.body;
++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
++ const u = new URL(t.base + p);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const text = Buffer.concat(chunks).toString('utf8');
++ resolve({ status: res.statusCode, text, json() { return JSON.parse(text); } });
++ });
++ });
++ req.on('error', reject);
++ if (body != null) req.write(body);
++ req.end();
++ });
++}
++
++(async () => {
++ const t0 = await boot();
++ const t = { ...t0, get: hostClient(t0) };
++ const photo = () => sharp({ create: { width: 64, height: 48, channels: 3, background: '#335' } }).jpeg().toBuffer();
++ try {
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const up = await t.get('/api/v1/images', { as: kim, method: 'POST', body: await photo(), headers: { ...SAME, 'content-type': 'image/jpeg' } });
++ assert.strictEqual(up.status, 201, up.text);
++ const slug = up.json().slug;
++ const api = `/api/v1/images/${slug}`;
++
++ await check('a picture is label-free by default, and the uploader sets labels', async () => {
++ let r = await t.get(api, { as: kim });
++ assert.deepStrictEqual(r.json().content_labels, { labels: [], suggested: [] });
++ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['nudity', '18+'] } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] });
++ r = await t.get(api, { as: lee });
++ assert.deepStrictEqual(r.json().content_labels, { labels: ['nudity', '18+'], suggested: [] }, 'anyone sees the labels on a public picture');
++ assert.strictEqual(r.json().state, 'active', 'a label never hides the picture');
++ });
++
++ await check('a suggestion is stored as suggested and only the confirmed one leaves', async () => {
++ await t.ctx.s.db.exec(`UPDATE pics_images SET content_labels = '{"labels":["18+"],"suggested":["nudity","language"]}' WHERE slug = $1`, [slug]);
++ let r = await t.get(api, { as: kim });
++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+'], suggested: ['nudity', 'language'] });
++ r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['18+', 'nudity'] } });
++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'confirming nudity keeps the unconfirmed language suggestion');
++ });
++
++ await check('unknown labels are refused, and only the uploader may set them', async () => {
++ let r = await t.get(api, { as: kim, method: 'PATCH', json: { labels: ['gore'] } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'pics.labels_invalid');
++ r = await t.get(api, { method: 'PATCH', json: { labels: ['nudity'] } });
++ assert.ok([401, 403].includes(r.status), 'signed out cannot label');
++ r = await t.get(api, { as: lee, method: 'PATCH', json: { labels: [] } });
++ assert.ok([403, 404].includes(r.status), 'someone else cannot label it');
++ r = await t.get(api, { as: kim });
++ assert.deepStrictEqual(r.json().content_labels, { labels: ['18+', 'nudity'], suggested: ['language'] }, 'refusals changed nothing');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });