openvibers/openvibe.mediahub Commits
AI text may describe people; only sex acts and porn stay gated (#216)
Changes
@@@ -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 -->@@@ -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"@@@ -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"@@@ -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"@@@ -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>@@@ -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.' }),@@@ -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 = coenabled, 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;},};}@@@ -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="ovvif (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.nnew file mode 100644@@@ -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.');@@@ -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('|'|')t train AI on your content\. If that ever changes, it('|'|')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');@@@ -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]);});
@@ -524,6 +524,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -524,6 +524,6 @@ 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.81.0
- openvibe-shared: v4.94.0
- openvibe-shared: v4.94.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
- "openvibe-sdk v0.74.0",
+ "openvibe-sdk v0.81.0",
"openvibe-shared v4.94.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.",
"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 @@
@@ -43,7 +43,7 @@
"deploy files"
"deploy files"
],
],
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
- "sdk": "openvibe-sdk v0.74.0",
+ "sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.94.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.",
"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"
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"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.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"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"
@@ -1626,9 +1626,9 @@
@@ -1626,9 +1626,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"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.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"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"
@@ -281,7 +281,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -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">
<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="/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="/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>
</div></section>
<section class="ovp-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovp-more" role="list">
<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>
<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>
@@ -102,7 +102,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
@@ -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.' }),
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([
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: '/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: '/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' }),
kit.row({ href: '/privacy', title: 'Privacy', sub: 'What this service collects, and why' }),
])),
])),
@@ -141,7 +141,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
@@ -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('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([
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: '/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('');
].join('');
return {
return {
@@ -156,10 +156,10 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
@@ -156,10 +156,10 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
const body = [
const body = [
kit.head({
kit.head({
title: 'Pictures and AI',
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' },
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.')),
+ '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([
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.' }),
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.' }),
@@ -17,9 +17,8 @@
@@ -17,9 +17,8 @@
* forUpload(row) the AI row of an upload, parsed (or null)
* forUpload(row) the AI row of an upload, parsed (or null)
*/
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
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 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; } };
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
@@ -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);
.map((x) => ({ start: Number(x.start), end: Number(x.end), text: clean(x.text, 500) })).slice(0, 5000);
return save(ai.upload_id, {
return save(ai.upload_id, {
status: 'done', run_id: r.id || null, error: null, next_at: null,
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),
chapters: JSON.stringify(chaptersOf(out.highlights)), segments: JSON.stringify(segments),
highlights: JSON.stringify(highlightsOf(out.highlights)),
highlights: JSON.stringify(highlightsOf(out.highlights)),
language: out.transcript.language ? clean(out.transcript.language, 8) : null,
language: out.transcript.language ? clean(out.transcript.language, 8) : null,
@@ -139,7 +138,7 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
@@ -139,7 +138,7 @@ function createAiEdit({ s, media, config, fetchImpl = globalThis.fetch, log = co
enabled, sweep,
enabled, sweep,
async forUpload(row) {
async forUpload(row) {
const ai = await s.db.maybe('SELECT * FROM video_upload_ai WHERE upload_id = $1', [row.id]);
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;
},
},
};
};
}
}
@@ -30,7 +30,6 @@ const { asyncRouter } = require('../http/router');
@@ -30,7 +30,6 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice } = require('../render/html');
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.
// 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 frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createLiveClient, LiveRefusedError } = require('./live-client');
@@ -1186,7 +1185,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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));
if (req.query.list) q.set('list', String(req.query.list));
return res.redirect(301, `/v/${rec.vod_id}${q.toString() ? `?${q}` : ''}`);
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 manageable = mayManage(req, 'vod', vod);
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
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
@@ -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);
if (!data || !data.clip) return notFound(req, res);
const clip = data.clip;
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);
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.
// The comment thread, the playlist form, up next and the recording this clip was cut from are asked together.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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.');
@@ -79,7 +79,8 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
@@ -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 () => {
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;
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('|'|')t train AI on your content\. If that ever changes, it('|'|')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('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('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');
assert.ok(page.includes(`At most ${t.config.pics.aiPerDay} drafts a day`), 'the cap is the configured one');
@@ -119,11 +119,12 @@ const OUTPUT = {
@@ -119,11 +119,12 @@ const OUTPUT = {
assert.deepStrictEqual([row.title, row.visibility], ['Coast', 'private'], 'a description-only change keeps the title and visibility');
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 });
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]);
await t.ctx.s.db.query('UPDATE video_upload_ai SET overview = $1 WHERE upload_id = $2', [OUTPUT.overview.text, id]);
});
});