openvibers/openvibe.mediahub Commits

AI text may describe people; only sex acts and porn stay gated (#216)

OP OpenVibers committed 11 days ago · a0132d5773 · 11 files, +44 −26

* AI text may describe people; only sex acts and porn stay gated

* Pics: say we don't train AI on your content, and it starts off

* openvibe-sdk v0.81.0 (was v0.74.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.81.0 release.

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

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* C2 follow-ups: tests and leftovers follow the owner's 10-31 rule

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

* Merge remote-tracking branch 'origin/pin-bump/openvibe-sdk-v0.81.0' into refresh-216

Changes

ddiff --git a/README.md b/README.md
iindex d4b36ee..10849b4 100644
---- a/README.md
++++ b/README.md
@@@ -524,6 +524,6 @@ 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-sdk: v0.81.0
- openvibe-shared: v4.94.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 805df0b..61a576f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"packages": [
"openvibe-contracts v0.222.0",
-- "openvibe-sdk v0.74.0",
++ "openvibe-sdk v0.81.0",
"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.",
@@@ -43,7 +43,7 @@
"deploy files"
],
"contracts": "openvibe-contracts v0.222.0",
-- "sdk": "openvibe-sdk v0.74.0",
++ "sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.94.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
"updated": "2026-11-02"
ddiff --git a/package-lock.json b/package-lock.json
iindex b1f1e18..4102017 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
@@@ -1626,9 +1626,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.74.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
++ "version": "0.81.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
++ "integrity": "sha512-GWD+qkkKiOyer1SATAaVs8qdWR1Z4WIRvtcYQU0NijzoL9BDRmkaXzLHjwwkRezdkHmdOgtvNIU8GAFaUaS8Ow==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 3967f0c..ea8d934 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 030d411..2274309 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -281,7 +281,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
<section class="ovp-section" aria-labelledby="h-policies"><h2 id="h-policies">What we promise, in writing</h2><div class="ovp-features">
<div class="ovp-feature"><h3><a href="/exif">What happens to your picture</a></h3><p>The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.</p></div>
<div class="ovp-feature"><h3><a href="/content-policy">What you may put here</a></h3><p>What is not allowed, how a rule is enforced, and what this site does not do: no feed, no counts, no adverts.</p></div>
--<div class="ovp-feature"><h3><a href="/ai-training">Pictures and AI</a></h3><p>We do not train on the pictures you upload. One thing asks a model, and only when you ask for it.</p></div>
++<div class="ovp-feature"><h3><a href="/ai-training">Pictures and AI</a></h3><p>We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off.</p></div>
</div></section>
<section class="ovp-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovp-more" role="list">
<li><a href="https://openvibe.chat/"><strong>OpenVibe.Chat</strong><span>Send pictures in rooms and servers: they go up to OpenVibe.Pics.</span></a></li>
ddiff --git a/server/pics/policy.js b/server/pics/policy.js
iindex e8c0a41..59a0589 100644
---- a/server/pics/policy.js
++++ b/server/pics/policy.js
@@@ -102,7 +102,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
kit.notice({ kind: 'info', text: 'This page describes what the code does. A test uploads a JPEG carrying a camera name and a GPS position, and checks that the picture served back has neither.' }),
sec('The other policies', rows([
kit.row({ href: '/content-policy', title: 'What you may put here', sub: 'The content policy, and what happens when a rule is broken' }),
-- kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: 'We do not train on your uploads; here is the one thing that asks a model' }),
++ kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: "We don't train AI on your content; here is the one thing that asks a model" }),
kit.row({ href: '/terms', title: 'Terms of service', sub: 'The rules of the network, in plain language' }),
kit.row({ href: '/privacy', title: 'Privacy', sub: 'What this service collects, and why' }),
])),
@@@ -141,7 +141,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
sec('Copyright and the law', pm('A copyright owner can send a notice through the <a href="/dmca">DMCA page</a>, and we take the picture down. The rules of the network itself are in the <a href="/terms">terms of service</a>; what this service collects is in the <a href="/privacy">privacy policy</a>.')),
sec('The other policies', rows([
kit.row({ href: '/exif', title: 'What happens to your picture', sub: 'The metadata: the GPS position, the camera, the time it was taken' }),
-- kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: 'We do not train on your uploads; here is the one thing that asks a model' }),
++ kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: "We don't train AI on your content; here is the one thing that asks a model" }),
])),
].join('');
return {
@@@ -156,10 +156,10 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
const body = [
kit.head({
title: 'Pictures and AI',
-- sub: 'We do not train on the pictures you upload',
++ sub: "We don't train AI on your content",
back: { href: '/', label: 'Home' },
}),
-- sec('The short answer', pm('We do not train AI models on the pictures people upload here. Nobody at OpenVibe trains on them, and nothing about them is sold. '
++ sec('The short answer', pm("We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off. Nothing about it is sold. "
+ 'There is exactly one thing on this site that asks a model about a picture, and it only happens when you ask for it.')),
sec('The one path to a model', rows([
kit.row({ title: 'You press “Write alt text with AI”', sub: 'On a picture you own, that sends the picture’s 640-pixel thumbnail to OpenVibe.AI, which may hand it to an outside model provider, and writes back alt text, a caption and a few tags. The draft is marked as AI’s until you change it.' }),
ddiff --git a/server/video/ai-edit.js b/server/video/ai-edit.js
iindex 756f5ac..993b530 100644
---- a/server/video/ai-edit.js
++++ b/server/video/ai-edit.js
@@@ -17,9 +17,8 @@
* forUpload(row) the AI row of an upload, parsed (or null)
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--// Public AI text never describes a person (the owner's rule): the overview is shown, indexed and offered as the
--// description, so it passes the shared filter when stored and when read (rows stored before).
--const { noLooks } = require('openvibe-sdk/no-looks');
++// AI text is stored as written: describing people is fine (owner, 2026-10-31). The overview is shown, indexed and
++// offered as the description.
const MAX_ATTEMPTS = 4;
const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
@@@ -107,7 +106,7 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
.map((x) => ({ start: Number(x.start), end: Number(x.end), text: clean(x.text, 500) })).slice(0, 5000);
return save(ai.upload_id, {
status: 'done', run_id: r.id || null, error: null, next_at: null,
-- overview: out.overview && out.overview.text ? (noLooks(clean(out.overview.text, 1500)).trim() || null) : null,
++ overview: out.overview && out.overview.text ? (clean(out.overview.text, 1500).trim() || null) : null,
chapters: JSON.stringify(chaptersOf(out.highlights)), segments: JSON.stringify(segments),
highlights: JSON.stringify(highlightsOf(out.highlights)),
language: out.transcript.language ? clean(out.transcript.language, 8) : null,
@@@ -139,7 +138,7 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
enabled, sweep,
async forUpload(row) {
const ai = await s.db.maybe('SELECT * FROM video_upload_ai WHERE upload_id = $1', [row.id]);
-- return ai ? { ...ai, overview: ai.overview ? (noLooks(ai.overview).trim() || null) : ai.overview, chapters: parse(ai.chapters, []), segments: parse(ai.segments, []), highlights: parse(ai.highlights, null) } : null;
++ return ai ? { ...ai, overview: ai.overview ? (String(ai.overview).trim() || null) : ai.overview, chapters: parse(ai.chapters, []), segments: parse(ai.segments, []), highlights: parse(ai.highlights, null) } : null;
},
};
}
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 97e4f36..9f13506 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -30,7 +30,6 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice } = require('../render/html');
// A recording's or clip's AI overview comes from Live, which filters it; read through the same filter here too.
--const { noLooks } = require('openvibe-sdk/no-looks');
const frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
@@@ -1186,7 +1185,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
if (req.query.list) q.set('list', String(req.query.list));
return res.redirect(301, `/v/${rec.vod_id}${q.toString() ? `?${q}` : ''}`);
}
-- if (vod && vod.ai_overview) vod.ai_overview = noLooks(vod.ai_overview).trim() || null;
++ if (vod && vod.ai_overview) vod.ai_overview = String(vod.ai_overview).trim() || null;
const manageable = mayManage(req, 'vod', vod);
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
@@@ -1327,7 +1326,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
}
if (!data || !data.clip) return notFound(req, res);
const clip = data.clip;
-- if (clip && clip.ai_overview) clip.ai_overview = noLooks(clip.ai_overview).trim() || null;
++ if (clip && clip.ai_overview) clip.ai_overview = String(clip.ai_overview).trim() || null;
const manageable = mayManage(req, 'clip', clip);
// The comment thread, the playlist form, up next and the recording this clip was cut from are asked together.
ddiff --git a/test/no-looks-guard.test.js b/test/no-looks-guard.test.js
nnew file mode 100644
iindex 0000000..5587d93
---- /dev/null
++++ b/test/no-looks-guard.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old
++// looks filter again.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));
++ }
++})(path.join(root, 'server'));
++assert.deepStrictEqual(offenders, [], 'no looks filter in server/');
++console.log('No looks filter: AI text describes people as written.');
ddiff --git a/test/pics-policy.test.js b/test/pics-policy.test.js
iindex 26ba698..1a99c1f 100644
---- a/test/pics-policy.test.js
++++ b/test/pics-policy.test.js
@@@ -79,7 +79,8 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
await check('the AI page says we do not train, and that a promise is not a lock', async () => {
const page = (await t.get('/ai-training', { headers: PICS })).text;
-- assert.ok(page.includes('We do not train AI models on the pictures people upload here'), 'the stance is stated');
++ // The owner's opt-in wording (10-31, consent by opt-in): no training, and any change would be the person's choice, off to start.
++ assert.ok(/We don(&#39;|&#x27;|')t train AI on your content\. If that ever changes, it(&#39;|&#x27;|')ll be your choice, and it starts off\./.test(page), 'the stance is stated');
assert.ok(page.includes('A promise, not a lock'), 'it says a promise is not a lock');
assert.ok(page.includes('Write alt text with AI'), 'it names the one path to a model');
assert.ok(page.includes(`At most ${t.config.pics.aiPerDay} drafts a day`), 'the cap is the configured one');
ddiff --git a/test/video-ai.test.js b/test/video-ai.test.js
iindex 8441a68..b6cbe37 100644
---- a/test/video-ai.test.js
++++ b/test/video-ai.test.js
@@@ -119,11 +119,12 @@ const OUTPUT = {
assert.deepStrictEqual([row.title, row.visibility], ['Coast', 'private'], 'a description-only change keeps the title and visibility');
});
-- await check('an overview that describes a person is read and shown without it (openvibe-sdk/no-looks)', async () => {
-- await t.ctx.s.db.query('UPDATE video_upload_ai SET overview = $1 WHERE upload_id = $2', ['A man in a red hoodie walks along the coast at sunset.', id]);
-- assert.strictEqual((await t.ctx.videoAi.forUpload({ id })).overview, 'A person walks along the coast at sunset.', 'a row stored before the rule reads clean');
++ await check('an overview that describes a person is read and shown as written', async () => {
++ const said = 'A man in a red hoodie walks along the coast at sunset.';
++ await t.ctx.s.db.query('UPDATE video_upload_ai SET overview = $1 WHERE upload_id = $2', [said, id]);
++ assert.strictEqual((await t.ctx.videoAi.forUpload({ id })).overview, said, 'the overview is stored as written');
const view = await vreq(t.base, `/u/${id}`, { cookie: kimCookie });
-- assert.ok(view.text.includes('A person walks along the coast at sunset.') && !view.text.includes('red hoodie'), 'the page shows the clean overview');
++ assert.ok(view.text.includes(said), 'the page shows the overview as written');
await t.ctx.s.db.query('UPDATE video_upload_ai SET overview = $1 WHERE upload_id = $2', [OUTPUT.overview.text, id]);
});