openvibers/openvibe.live Commits
Auto-clips sort AI text into three review categories and report the hard line (#618)
Changes
@@@ -79,7 +79,7 @@ The media subsystem lives in **OpenVibe.Media**:- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables it once held do not exist on PostgreSQL (`migrations/0002_live.sql` leaves them out; SQLite-era migration 007 dropped them).- **Recordings, clips, thumbnails, files**: OpenVibe.Media; their watch pages are OpenVibe.Video's. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).- **Pastes and comments**: OpenVibe.Community.--- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest.++- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest. The AI's own clip/moment text is sorted by `server/ai/explicit.js` into exactly three categories: `fine` (nudity is allowed), `sex_acts` (never public; the clip is private and kept for its owner) and `minor_sexual` (always blocked, private even when a person owns it, and a report record is made through OpenVibe.Vision with `VISION_URL` + Live's service principal). The moments job, both auto-clip paths, both clip overview passes and clip evaluation use the same classifier.- **Search, events, tips, VIP, money ledger**: OpenVibe.Search, Events, Tips, VIP and Billing.## Depends on@@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.<!-- versions:start -->--- openvibe-contracts: v0.289.1++- openvibe-contracts: v0.294.0- openvibe-sdk: v0.82.0- openvibe-shared: v4.114.0<!-- versions:end -->@@@ -5,7 +5,7 @@"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step","port": 3000,"domain": "openvibe.live (www, ingest, whip)",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.294.0","sdk": "openvibe-sdk v0.82.0","shared": "openvibe-shared v4.114.0","capabilities": [@@@ -30,7 +30,7 @@"channel points and Vibes; The Rundown (/rundown: what streamers talked about, from their speech; replaced the Arena 2026-10-19); after-show recaps; the Star of OpenVibe","live, VOD and clip transcripts by Parakeet TDT 0.6B v3 on the server for English and 24 European languages (whisper for others)","per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",-- "channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"++ "channel \u2194 OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"],"updated": "2026-11-06"}@@@ -23,7 +23,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0","pg": "^8.23.1",@@@ -2840,9 +2840,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.294.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",++ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -42,7 +42,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0","pg": "^8.23.1",@@@ -1,50 +1,99 @@'use strict';/**-- * AI text that says a frame or clip shows sex acts or pornography. Describing people is fine, and so is nudity: the-- * owner, 2026-10-31, "dick is art. Penis is art. Vagina is art. Titties is art… stop being against art on my-- * platform." What stays is sex itself: on 2026-10-23 an AI auto-clip of a stream that showed sexual acts went public-- * on openvibe.media and Live's home with its description (clip 483). Porn brings age-verification and record-keeping-- * duties that an adults-only section would have to meet; until there is one, the AI does not promote it.++ * AI text that says a frame or clip shows sex acts or pornography, or anything sexual involving a minor.++ * Describing people is fine, and so is nudity: the owner, 2026-10-31, "dick is art. Penis is art. Vagina is++ * art. Titties is art… stop being against art on my platform." What stays is sex itself: on 2026-10-23 an AI++ * auto-clip of a stream that showed sexual acts went public on openvibe.media and Live's home with its++ * description (clip 483). Porn brings age-verification and record-keeping duties that an adults-only section++ * would have to meet; until there is one, the AI does not promote it.*-- * So: a moment whose AI text is explicit is never picked, posted or clipped (ai-moments-job.js, auto-clip-job.js),-- * and an AI-made clip whose overview turns out explicit goes private (guardAutoClip). People's own clips are theirs-- * to publish: only the AI's are changed. The owner decides about the stream itself.++ * VIS-6's decision (2026-11-06) gives every AI text exactly three categories:++ *++ * fine allowed, including nudity++ * sex_acts never public: the clip is made private and kept for its owner++ * minor_sexual always blocked: private even for the owner's page, and an operator report record is made++ * through OpenVibe.Vision. Nothing is reported to an external authority from here.++ *++ * A moment whose text is sex_acts or minor_sexual is never picked, posted or clipped (ai-moments-job.js,++ * auto-clip-job.js), and an AI-made clip whose overview is one of them goes private (guardAutoClip). A++ * category-3 clip is blocked even when it is not AI-made. People's own clips stay theirs otherwise.*/const media = require('../media-client');--// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words are--// not in it (a nude body is not a sex act).++// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words++// are not in it (a nude body is not a sex act).const EXPLICIT = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|adult\s+(?:films?|videos?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*)\b/i;// The hard line that nudity being art never moves: a child and a bare body in the same text. Not a judgement of the// words alone ("kids in swimsuits at the beach" is not this); a child together with nudity or genitals is treated as--// explicit, so the moment is never picked and an AI clip of it goes private for the owner to review.++// sexual content involving a minor, so the moment is never picked and the clip is quarantined for the operator.const MINOR = /\b(?:child(?:ren)?|kids?|minors?|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage)\b/i;const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;const minorNude = (t) => MINOR.test(t) && NUDE.test(t);--/** True when any of the texts describes sex acts or pornography, or puts a child and nudity together. */++const CATEGORIES = Object.freeze(['fine', 'sex_acts', 'minor_sexual']);++const PLAIN = Object.freeze({++ fine: 'Fine — OK to show, including nudity',++ sex_acts: 'Sex acts or porn — never public; kept for its owner',++ minor_sexual: 'Sexual content involving a minor — always blocked and reported to the operator',++});++const ACTION = Object.freeze({ fine: 'allow', sex_acts: 'hide', minor_sexual: 'quarantine_and_report' });++++/** Sort one or more AI texts into exactly one category. */++function classifyText(...texts) {++ const all = texts.filter((t) => t != null).map(String).join('\n').trim();++ if (!all) return { category: 'fine', reason: 'there is no text to judge' };++ if (minorNude(all)) return { category: 'minor_sexual', reason: 'the text puts a child together with nudity' };++ if (EXPLICIT.test(all)) return { category: 'sex_acts', reason: 'the text describes sex acts or pornography' };++ if (MINOR.test(all)) return { category: 'fine', reason: 'a child is mentioned without nudity or a sex act' };++ if (NUDE.test(all)) return { category: 'fine', reason: 'the text describes nudity, which is allowed' };++ return { category: 'fine', reason: 'the text describes neither sex acts nor a child with nudity' };++}++++/** True when any of the texts is sex_acts or minor_sexual. Kept for every caller that only needs the gate. */function isExplicit(...texts) {-- return texts.some((t) => t != null && (EXPLICIT.test(String(t)) || minorNude(String(t))));++ return classifyText(...texts).category !== 'fine';}/**-- * After a clip's AI overview is stored: an AI-made clip whose overview is explicit goes private and loses its-- * description. A clip object from an event may not say whether the AI made it, so Media is asked then (rare: only for-- * an explicit overview). → true when the clip was made private.++ * After a clip's AI overview is stored: a clip whose overview is sex_acts or minor_sexual is made private and++ * loses its description. A category-3 clip is blocked even when it is not AI-made, and Vision is asked for the++ * operator's report record (best effort; the clip is hidden even when Vision is unreachable).++ * → true when the clip was hidden by this call.*/async function guardAutoClip(clip, overview) {-- if (!clip || !clip.id || !isExplicit(overview)) return false;++ if (!clip || !clip.id) return false;++ const verdict = classifyText(overview);++ if (verdict.category === 'fine') return false;try {-- const c = clip.auto_generated === undefined ? await media.getClip(clip.id) : clip;-- if (!c || !c.auto_generated || c.visibility === 'private') return false;-- await media.updateClip(clip.id, { visibility: 'private', description: '' });-- console.warn(`[AI] Auto-clip ${clip.id} made private: its AI overview describes sex acts (the owner reviews the stream)`);-- return true;++ const c = clip.auto_generated === undefined || clip.visibility === undefined ? await media.getClip(clip.id) : clip;++ if (!c) return false;++ // A person's own clip is not touched for sex acts; the hard line is blocked whoever made it.++ if (verdict.category === 'sex_acts' && !c.auto_generated) return false;++ // The hard line's operator record is made even if hiding the clip then fails.++ if (verdict.category === 'minor_sexual') {++ try {++ const out = await require('./review-client').reportMinorSexual({++ clip: { ...c, id: c.id || clip.id }, text: overview, ownerSubject: c.owner_subject || null,++ });++ if (!out || !out.review || !out.review.report) {++ console.warn(`[AI] Clip ${clip.id}: minor-sexual content but no operator report record was made`);++ }++ } catch (e) {++ console.warn(`[AI] Clip ${clip.id}: minor-sexual content, but Vision did not record the operator report:`, e.message);++ }++ }++ let changed = false;++ if (c.visibility !== 'private') {++ await media.updateClip(clip.id, { visibility: 'private', description: '' });++ changed = true;++ }++ if (changed) console.warn(`[AI] Clip ${clip.id} made private: its AI overview is ${verdict.category} (${PLAIN[verdict.category]})`);++ return changed;} catch (e) {-- console.warn(`[AI] Auto-clip ${clip.id}: explicit overview, but it could not be made private:`, e.message);++ console.warn(`[AI] Clip ${clip.id}: ${verdict.category} overview, but it could not be made private:`, e.message);return false;}}--module.exports = { isExplicit, guardAutoClip, EXPLICIT, MINOR, NUDE };++module.exports = { isExplicit, classifyText, guardAutoClip, EXPLICIT, MINOR, NUDE, CATEGORIES, PLAIN, ACTION };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++++/**++ * Live's client for OpenVibe.Vision's unsafe-image review (VIS-6).++ *++ * Live classifies its own AI text locally (server/ai/explicit.js): nudity is fine, sex acts are not, and a++ * child with nudity or a sex act is the hard line. When that hard line fires, Live asks Vision to make the++ * operator's report record, so one queue (openvibe.vision/review) holds every category-3 object across the++ * network. The report is internal; nothing is sent to an external authority by this call.++ *++ * Env: VISION_URL (or OV_VISION_URL), OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID/SECRET. With no URL or no++ * secret the client is off and the clip is still made private; only the operator record is missing, and the++ * warning says so. A restore drill (LIVE_DRILL) never calls out.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++let tokens = null;++let tokensAt = '';++++function configNow() {++ const base = String(process.env.VISION_URL || process.env.OV_VISION_URL || '').replace(/\/+$/, '');++ const secret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ return { base, secret, network: String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '') };++}++++function serviceTokens({ base, secret, network }) {++ const at = `${network}|${secret}`;++ if (tokens && tokensAt === at) return tokens;++ tokens = createServiceTokenClient({++ tokenUrl: `${network}/oauth/token`,++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',++ clientSecret: secret,++ audience: 'openvibe.vision',++ scope: 'vision.image.review',++ });++ tokensAt = at;++ return tokens;++}++++function enabled() {++ if (require('../drill').enabled) return false;++ const c = configNow();++ return Boolean(c.base && c.secret);++}++++/** Report a category-3 text review to Vision. → the stored review/report, or null when the client is off. */++async function reportMinorSexual({ clip, text, ownerSubject = null, fetchImpl = globalThis.fetch } = {}) {++ if (!enabled()) return null;++ const c = configNow();++ const id = clip && clip.id != null ? String(clip.id) : null;++ if (!id) return null;++ const res = await fetchImpl(`${c.base}/api/v1/review`, {++ method: 'POST',++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await serviceTokens(c).authHeaders()) },++ body: JSON.stringify({++ text: String(text || '').slice(0, 4000),++ object_type: 'live.clip',++ object_id: id,++ owner_subject: ownerSubject || null,++ preview_url: clip && clip.thumbnail_url ? String(clip.thumbnail_url).slice(0, 2000) : null,++ note: 'Live auto-clip guard: sexual content involving a minor',++ }),++ signal: AbortSignal.timeout(8000),++ });++ const body = await res.json().catch(() => null);++ if (!res.ok || !body || !body.review) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);++ return body;++}++++function _reset() { tokens = null; tokensAt = ''; }++++module.exports = { enabled, reportMinorSexual, _reset };@@@ -20,7 +20,14 @@ const stub = {};const mediaPath = require.resolve('../server/media-client');require.cache[mediaPath] = { id: mediaPath, filename: mediaPath, loaded: true, exports: stub };--const { isExplicit, guardAutoClip } = require('../server/ai/explicit');++// Vision's report record, stubbed before explicit.js loads its client.++const reports = [];++const reviewPath = require.resolve('../server/ai/review-client');++require.cache[reviewPath] = { id: reviewPath, filename: reviewPath, loaded: true, exports: {++ reportMinorSexual: async (x) => { reports.push(x); return { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } }; },++ enabled: () => true,++} };++const { isExplicit, classifyText, guardAutoClip, CATEGORIES, PLAIN } = require('../server/ai/explicit');let pass = 0;const ok = (m) => { pass++; console.log(` ✓ ${m}`); };@@@ -53,14 +60,22 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };]) assert.ok(!isExplicit(t), `not explicit: ${t}`);ok('the detector: sex acts and porn, and a child with nudity; not nudity alone and not ordinary streams');++ // The three categories, in plain words, are the only labels.++ assert.deepStrictEqual(CATEGORIES, ['fine', 'sex_acts', 'minor_sexual']);++ assert.deepStrictEqual(classifyText('a nude figure drawing'), { category: 'fine', reason: 'the text describes nudity, which is allowed' });++ assert.strictEqual(classifyText('pornographic footage').category, 'sex_acts');++ assert.strictEqual(classifyText('a naked child').category, 'minor_sexual');++ assert.match(PLAIN.minor_sexual, /always blocked and reported/);++ assert.match(PLAIN.sex_acts, /never public/);++// An AI clip with an explicit overview goes private and loses its description.clips.set(1, { id: 1, auto_generated: true, visibility: 'public' });assert.strictEqual(await guardAutoClip({ id: 1, auto_generated: true, visibility: 'public' }, 'close-up footage of sexual acts'), true);assert.deepStrictEqual(calls.pop(), ['update', 1, { visibility: 'private', description: '' }]);-- // A person's own clip is theirs: nothing changes.++ // A person's own clip keeps its sex-act choice; the hard line is blocked whoever made it.calls.length = 0;assert.strictEqual(await guardAutoClip({ id: 2, auto_generated: false, visibility: 'public' }, 'close-up footage of sexual acts'), false);-- assert.strictEqual(calls.length, 0, 'no Media call for a person\'s clip');++ assert.strictEqual(calls.length, 0, 'no Media call for a person\'s sex-act clip');// An ordinary overview: nothing, not even a lookup.assert.strictEqual(await guardAutoClip({ id: 3 }, 'A clutch round in Fortnite'), false);assert.strictEqual(calls.length, 0, 'no Media call for an ordinary overview');@@@ -73,7 +88,20 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };clips.set(5, { id: 5, auto_generated: true, visibility: 'private' });assert.strictEqual(await guardAutoClip({ id: 5 }, 'explicit adult content'), false);assert.deepStrictEqual(calls.map((c) => c[0]), ['get']);-- ok('an AI-made clip with an explicit overview goes private; people\'s clips and ordinary overviews are untouched');++ // Category 3: even a person's clip is blocked, and Vision gets the operator report record.++ reports.length = 0;++ clips.set(6, { id: 6, auto_generated: false, visibility: 'public' });++ assert.strictEqual(await guardAutoClip({ id: 6 }, 'a naked child runs across the frame'), true);++ assert.deepStrictEqual(calls.pop(), ['update', 6, { visibility: 'private', description: '' }]);++ assert.strictEqual(reports.length, 1);++ assert.strictEqual(reports[0].text, 'a naked child runs across the frame');++ // Already private: still reported, nothing rewritten.++ calls.length = 0;++ clips.set(7, { id: 7, auto_generated: true, visibility: 'private' });++ assert.strictEqual(await guardAutoClip({ id: 7, auto_generated: true, visibility: 'private' }, 'two kids appear nude in the background'), false);++ assert.strictEqual(reports.length, 2);++ assert.deepStrictEqual(calls, [], 'already private is not updated again');++ ok('an AI-made clip with an explicit overview goes private; a category-3 clip is blocked and reported');// The writers use it.const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * Live's category-3 operator report goes to OpenVibe.Vision with Live's service principal: one POST, the++ * clip's id, the AI text and the note that names the hard line. Nothing else leaves the process (the clip++ * is already hidden by explicit.js whether or not Vision answers).++ */++const assert = require('assert');++const http = require('http');++++(async () => {++ const state = { token: 0, reviews: [] };++ const srv = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') { state.token++; return send(200, { access_token: 'svc-live-vision', token_type: 'Bearer', expires_in: 300 }); }++ if (req.url === '/api/v1/review') {++ state.reviews.push({ auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') });++ return send(201, { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } });++ }++ send(404, {});++ });++ });++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}`;++ process.env.VISION_URL = base;++ process.env.OV_NETWORK_INTERNAL_URL = base;++ process.env.OV_OAUTH_CLIENT_ID = 'live';++ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++ const client = require('../server/ai/review-client');++++ assert.strictEqual(client.enabled(), true);++ const out = await client.reportMinorSexual({++ clip: { id: 9, thumbnail_url: 'https://openvibe.media/t/9.jpg' },++ text: 'a naked child runs across the frame',++ ownerSubject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',++ });++ assert.strictEqual(out.review.report.id, 'vor_x');++ assert.strictEqual(state.reviews.length, 1);++ const body = state.reviews[0].body;++ assert.deepStrictEqual([body.object_type, body.object_id, body.owner_subject], ['live.clip', '9', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ']);++ assert.strictEqual(body.text, 'a naked child runs across the frame');++ assert.match(body.note, /minor/);++ assert.ok(state.reviews[0].auth.startsWith('Bearer '));++ assert.strictEqual(state.token, 1);++++ console.log('ai review client: 1 report sent with Live\'s service token');++ client._reset();++ await new Promise((r) => srv.close(r));++})().catch((e) => { console.error(e); process.exit(1); });
@@ -79,7 +79,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -79,7 +79,7 @@ The media subsystem lives in **OpenVibe.Media**:
- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables it once held do not exist on PostgreSQL (`migrations/0002_live.sql` leaves them out; SQLite-era migration 007 dropped them).
- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables it once held do not exist on PostgreSQL (`migrations/0002_live.sql` leaves them out; SQLite-era migration 007 dropped them).
- **Recordings, clips, thumbnails, files**: OpenVibe.Media; their watch pages are OpenVibe.Video's. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Recordings, clips, thumbnails, files**: OpenVibe.Media; their watch pages are OpenVibe.Video's. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Pastes and comments**: OpenVibe.Community.
- **Pastes and comments**: OpenVibe.Community.
-- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest.
+- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Live-stream captions run in Live's own speech-to-text (`server/ai/transcribe.js`). Both use NVIDIA Parakeet TDT 0.6B v3 for English and 24 European languages and whisper.cpp for the rest. The AI's own clip/moment text is sorted by `server/ai/explicit.js` into exactly three categories: `fine` (nudity is allowed), `sex_acts` (never public; the clip is private and kept for its owner) and `minor_sexual` (always blocked, private even when a person owns it, and a report record is made through OpenVibe.Vision with `VISION_URL` + Live's service principal). The moments job, both auto-clip paths, both clip overview passes and clip evaluation use the same classifier.
- **Search, events, tips, VIP, money ledger**: OpenVibe.Search, Events, Tips, VIP and Billing.
- **Search, events, tips, VIP, money ledger**: OpenVibe.Search, Events, Tips, VIP and Billing.
## Depends on
## Depends on
@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.114.0
- openvibe-shared: v4.114.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.114.0",
"shared": "openvibe-shared v4.114.0",
"capabilities": [
"capabilities": [
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"channel points and Vibes; The Rundown (/rundown: what streamers talked about, from their speech; replaced the Arena 2026-10-19); after-show recaps; the Star of OpenVibe",
"channel points and Vibes; The Rundown (/rundown: what streamers talked about, from their speech; replaced the Arena 2026-10-19); after-show recaps; the Star of OpenVibe",
"live, VOD and clip transcripts by Parakeet TDT 0.6B v3 on the server for English and 24 European languages (whisper for others)",
"live, VOD and clip transcripts by Parakeet TDT 0.6B v3 on the server for English and 24 European languages (whisper for others)",
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
- "channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
+ "channel \u2194 OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
],
],
"updated": "2026-11-06"
"updated": "2026-11-06"
}
}
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2840,9 +2840,9 @@
@@ -2840,9 +2840,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.294.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -1,50 +1,99 @@
@@ -1,50 +1,99 @@
'use strict';
'use strict';
/**
/**
- * AI text that says a frame or clip shows sex acts or pornography. Describing people is fine, and so is nudity: the
- * owner, 2026-10-31, "dick is art. Penis is art. Vagina is art. Titties is art… stop being against art on my
- * platform." What stays is sex itself: on 2026-10-23 an AI auto-clip of a stream that showed sexual acts went public
- * on openvibe.media and Live's home with its description (clip 483). Porn brings age-verification and record-keeping
- * duties that an adults-only section would have to meet; until there is one, the AI does not promote it.
+ * AI text that says a frame or clip shows sex acts or pornography, or anything sexual involving a minor.
+ * Describing people is fine, and so is nudity: the owner, 2026-10-31, "dick is art. Penis is art. Vagina is
+ * art. Titties is art… stop being against art on my platform." What stays is sex itself: on 2026-10-23 an AI
+ * auto-clip of a stream that showed sexual acts went public on openvibe.media and Live's home with its
+ * description (clip 483). Porn brings age-verification and record-keeping duties that an adults-only section
+ * would have to meet; until there is one, the AI does not promote it.
*
*
- * So: a moment whose AI text is explicit is never picked, posted or clipped (ai-moments-job.js, auto-clip-job.js),
- * and an AI-made clip whose overview turns out explicit goes private (guardAutoClip). People's own clips are theirs
- * to publish: only the AI's are changed. The owner decides about the stream itself.
+ * VIS-6's decision (2026-11-06) gives every AI text exactly three categories:
+ *
+ * fine allowed, including nudity
+ * sex_acts never public: the clip is made private and kept for its owner
+ * minor_sexual always blocked: private even for the owner's page, and an operator report record is made
+ * through OpenVibe.Vision. Nothing is reported to an external authority from here.
+ *
+ * A moment whose text is sex_acts or minor_sexual is never picked, posted or clipped (ai-moments-job.js,
+ * auto-clip-job.js), and an AI-made clip whose overview is one of them goes private (guardAutoClip). A
+ * category-3 clip is blocked even when it is not AI-made. People's own clips stay theirs otherwise.
*/
*/
const media = require('../media-client');
const media = require('../media-client');
-// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words are
-// not in it (a nude body is not a sex act).
+// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body words
+// are not in it (a nude body is not a sex act).
const EXPLICIT = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|adult\s+(?:films?|videos?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*)\b/i;
const EXPLICIT = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|adult\s+(?:films?|videos?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*)\b/i;
// The hard line that nudity being art never moves: a child and a bare body in the same text. Not a judgement of the
// The hard line that nudity being art never moves: a child and a bare body in the same text. Not a judgement of the
// words alone ("kids in swimsuits at the beach" is not this); a child together with nudity or genitals is treated as
// words alone ("kids in swimsuits at the beach" is not this); a child together with nudity or genitals is treated as
-// explicit, so the moment is never picked and an AI clip of it goes private for the owner to review.
+// sexual content involving a minor, so the moment is never picked and the clip is quarantined for the operator.
const MINOR = /\b(?:child(?:ren)?|kids?|minors?|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage)\b/i;
const MINOR = /\b(?:child(?:ren)?|kids?|minors?|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage)\b/i;
const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;
const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;
const minorNude = (t) => MINOR.test(t) && NUDE.test(t);
const minorNude = (t) => MINOR.test(t) && NUDE.test(t);
-/** True when any of the texts describes sex acts or pornography, or puts a child and nudity together. */
+const CATEGORIES = Object.freeze(['fine', 'sex_acts', 'minor_sexual']);
+const PLAIN = Object.freeze({
+ fine: 'Fine — OK to show, including nudity',
+ sex_acts: 'Sex acts or porn — never public; kept for its owner',
+ minor_sexual: 'Sexual content involving a minor — always blocked and reported to the operator',
+});
+const ACTION = Object.freeze({ fine: 'allow', sex_acts: 'hide', minor_sexual: 'quarantine_and_report' });
+
+/** Sort one or more AI texts into exactly one category. */
+function classifyText(...texts) {
+ const all = texts.filter((t) => t != null).map(String).join('\n').trim();
+ if (!all) return { category: 'fine', reason: 'there is no text to judge' };
+ if (minorNude(all)) return { category: 'minor_sexual', reason: 'the text puts a child together with nudity' };
+ if (EXPLICIT.test(all)) return { category: 'sex_acts', reason: 'the text describes sex acts or pornography' };
+ if (MINOR.test(all)) return { category: 'fine', reason: 'a child is mentioned without nudity or a sex act' };
+ if (NUDE.test(all)) return { category: 'fine', reason: 'the text describes nudity, which is allowed' };
+ return { category: 'fine', reason: 'the text describes neither sex acts nor a child with nudity' };
+}
+
+/** True when any of the texts is sex_acts or minor_sexual. Kept for every caller that only needs the gate. */
function isExplicit(...texts) {
function isExplicit(...texts) {
- return texts.some((t) => t != null && (EXPLICIT.test(String(t)) || minorNude(String(t))));
+ return classifyText(...texts).category !== 'fine';
}
}
/**
/**
- * After a clip's AI overview is stored: an AI-made clip whose overview is explicit goes private and loses its
- * description. A clip object from an event may not say whether the AI made it, so Media is asked then (rare: only for
- * an explicit overview). → true when the clip was made private.
+ * After a clip's AI overview is stored: a clip whose overview is sex_acts or minor_sexual is made private and
+ * loses its description. A category-3 clip is blocked even when it is not AI-made, and Vision is asked for the
+ * operator's report record (best effort; the clip is hidden even when Vision is unreachable).
+ * → true when the clip was hidden by this call.
*/
*/
async function guardAutoClip(clip, overview) {
async function guardAutoClip(clip, overview) {
- if (!clip || !clip.id || !isExplicit(overview)) return false;
+ if (!clip || !clip.id) return false;
+ const verdict = classifyText(overview);
+ if (verdict.category === 'fine') return false;
try {
try {
- const c = clip.auto_generated === undefined ? await media.getClip(clip.id) : clip;
- if (!c || !c.auto_generated || c.visibility === 'private') return false;
- await media.updateClip(clip.id, { visibility: 'private', description: '' });
- console.warn(`[AI] Auto-clip ${clip.id} made private: its AI overview describes sex acts (the owner reviews the stream)`);
- return true;
+ const c = clip.auto_generated === undefined || clip.visibility === undefined ? await media.getClip(clip.id) : clip;
+ if (!c) return false;
+ // A person's own clip is not touched for sex acts; the hard line is blocked whoever made it.
+ if (verdict.category === 'sex_acts' && !c.auto_generated) return false;
+ // The hard line's operator record is made even if hiding the clip then fails.
+ if (verdict.category === 'minor_sexual') {
+ try {
+ const out = await require('./review-client').reportMinorSexual({
+ clip: { ...c, id: c.id || clip.id }, text: overview, ownerSubject: c.owner_subject || null,
+ });
+ if (!out || !out.review || !out.review.report) {
+ console.warn(`[AI] Clip ${clip.id}: minor-sexual content but no operator report record was made`);
+ }
+ } catch (e) {
+ console.warn(`[AI] Clip ${clip.id}: minor-sexual content, but Vision did not record the operator report:`, e.message);
+ }
+ }
+ let changed = false;
+ if (c.visibility !== 'private') {
+ await media.updateClip(clip.id, { visibility: 'private', description: '' });
+ changed = true;
+ }
+ if (changed) console.warn(`[AI] Clip ${clip.id} made private: its AI overview is ${verdict.category} (${PLAIN[verdict.category]})`);
+ return changed;
} catch (e) {
} catch (e) {
- console.warn(`[AI] Auto-clip ${clip.id}: explicit overview, but it could not be made private:`, e.message);
+ console.warn(`[AI] Clip ${clip.id}: ${verdict.category} overview, but it could not be made private:`, e.message);
return false;
return false;
}
}
}
}
-module.exports = { isExplicit, guardAutoClip, EXPLICIT, MINOR, NUDE };
+module.exports = { isExplicit, classifyText, guardAutoClip, EXPLICIT, MINOR, NUDE, CATEGORIES, PLAIN, ACTION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+
+/**
+ * Live's client for OpenVibe.Vision's unsafe-image review (VIS-6).
+ *
+ * Live classifies its own AI text locally (server/ai/explicit.js): nudity is fine, sex acts are not, and a
+ * child with nudity or a sex act is the hard line. When that hard line fires, Live asks Vision to make the
+ * operator's report record, so one queue (openvibe.vision/review) holds every category-3 object across the
+ * network. The report is internal; nothing is sent to an external authority by this call.
+ *
+ * Env: VISION_URL (or OV_VISION_URL), OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID/SECRET. With no URL or no
+ * secret the client is off and the clip is still made private; only the operator record is missing, and the
+ * warning says so. A restore drill (LIVE_DRILL) never calls out.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+let tokens = null;
+let tokensAt = '';
+
+function configNow() {
+ const base = String(process.env.VISION_URL || process.env.OV_VISION_URL || '').replace(/\/+$/, '');
+ const secret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ return { base, secret, network: String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '') };
+}
+
+function serviceTokens({ base, secret, network }) {
+ const at = `${network}|${secret}`;
+ if (tokens && tokensAt === at) return tokens;
+ tokens = createServiceTokenClient({
+ tokenUrl: `${network}/oauth/token`,
+ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',
+ clientSecret: secret,
+ audience: 'openvibe.vision',
+ scope: 'vision.image.review',
+ });
+ tokensAt = at;
+ return tokens;
+}
+
+function enabled() {
+ if (require('../drill').enabled) return false;
+ const c = configNow();
+ return Boolean(c.base && c.secret);
+}
+
+/** Report a category-3 text review to Vision. → the stored review/report, or null when the client is off. */
+async function reportMinorSexual({ clip, text, ownerSubject = null, fetchImpl = globalThis.fetch } = {}) {
+ if (!enabled()) return null;
+ const c = configNow();
+ const id = clip && clip.id != null ? String(clip.id) : null;
+ if (!id) return null;
+ const res = await fetchImpl(`${c.base}/api/v1/review`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await serviceTokens(c).authHeaders()) },
+ body: JSON.stringify({
+ text: String(text || '').slice(0, 4000),
+ object_type: 'live.clip',
+ object_id: id,
+ owner_subject: ownerSubject || null,
+ preview_url: clip && clip.thumbnail_url ? String(clip.thumbnail_url).slice(0, 2000) : null,
+ note: 'Live auto-clip guard: sexual content involving a minor',
+ }),
+ signal: AbortSignal.timeout(8000),
+ });
+ const body = await res.json().catch(() => null);
+ if (!res.ok || !body || !body.review) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);
+ return body;
+}
+
+function _reset() { tokens = null; tokensAt = ''; }
+
+module.exports = { enabled, reportMinorSexual, _reset };
@@ -20,7 +20,14 @@ const stub = {
@@ -20,7 +20,14 @@ const stub = {
};
};
const mediaPath = require.resolve('../server/media-client');
const mediaPath = require.resolve('../server/media-client');
require.cache[mediaPath] = { id: mediaPath, filename: mediaPath, loaded: true, exports: stub };
require.cache[mediaPath] = { id: mediaPath, filename: mediaPath, loaded: true, exports: stub };
-const { isExplicit, guardAutoClip } = require('../server/ai/explicit');
+// Vision's report record, stubbed before explicit.js loads its client.
+const reports = [];
+const reviewPath = require.resolve('../server/ai/review-client');
+require.cache[reviewPath] = { id: reviewPath, filename: reviewPath, loaded: true, exports: {
+ reportMinorSexual: async (x) => { reports.push(x); return { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } }; },
+ enabled: () => true,
+} };
+const { isExplicit, classifyText, guardAutoClip, CATEGORIES, PLAIN } = require('../server/ai/explicit');
let pass = 0;
let pass = 0;
const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
@@ -53,14 +60,22 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
@@ -53,14 +60,22 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
]) assert.ok(!isExplicit(t), `not explicit: ${t}`);
]) assert.ok(!isExplicit(t), `not explicit: ${t}`);
ok('the detector: sex acts and porn, and a child with nudity; not nudity alone and not ordinary streams');
ok('the detector: sex acts and porn, and a child with nudity; not nudity alone and not ordinary streams');
+ // The three categories, in plain words, are the only labels.
+ assert.deepStrictEqual(CATEGORIES, ['fine', 'sex_acts', 'minor_sexual']);
+ assert.deepStrictEqual(classifyText('a nude figure drawing'), { category: 'fine', reason: 'the text describes nudity, which is allowed' });
+ assert.strictEqual(classifyText('pornographic footage').category, 'sex_acts');
+ assert.strictEqual(classifyText('a naked child').category, 'minor_sexual');
+ assert.match(PLAIN.minor_sexual, /always blocked and reported/);
+ assert.match(PLAIN.sex_acts, /never public/);
+
// An AI clip with an explicit overview goes private and loses its description.
// An AI clip with an explicit overview goes private and loses its description.
clips.set(1, { id: 1, auto_generated: true, visibility: 'public' });
clips.set(1, { id: 1, auto_generated: true, visibility: 'public' });
assert.strictEqual(await guardAutoClip({ id: 1, auto_generated: true, visibility: 'public' }, 'close-up footage of sexual acts'), true);
assert.strictEqual(await guardAutoClip({ id: 1, auto_generated: true, visibility: 'public' }, 'close-up footage of sexual acts'), true);
assert.deepStrictEqual(calls.pop(), ['update', 1, { visibility: 'private', description: '' }]);
assert.deepStrictEqual(calls.pop(), ['update', 1, { visibility: 'private', description: '' }]);
- // A person's own clip is theirs: nothing changes.
+ // A person's own clip keeps its sex-act choice; the hard line is blocked whoever made it.
calls.length = 0;
calls.length = 0;
assert.strictEqual(await guardAutoClip({ id: 2, auto_generated: false, visibility: 'public' }, 'close-up footage of sexual acts'), false);
assert.strictEqual(await guardAutoClip({ id: 2, auto_generated: false, visibility: 'public' }, 'close-up footage of sexual acts'), false);
- assert.strictEqual(calls.length, 0, 'no Media call for a person\'s clip');
+ assert.strictEqual(calls.length, 0, 'no Media call for a person\'s sex-act clip');
// An ordinary overview: nothing, not even a lookup.
// An ordinary overview: nothing, not even a lookup.
assert.strictEqual(await guardAutoClip({ id: 3 }, 'A clutch round in Fortnite'), false);
assert.strictEqual(await guardAutoClip({ id: 3 }, 'A clutch round in Fortnite'), false);
assert.strictEqual(calls.length, 0, 'no Media call for an ordinary overview');
assert.strictEqual(calls.length, 0, 'no Media call for an ordinary overview');
@@ -73,7 +88,20 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
@@ -73,7 +88,20 @@ const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
clips.set(5, { id: 5, auto_generated: true, visibility: 'private' });
clips.set(5, { id: 5, auto_generated: true, visibility: 'private' });
assert.strictEqual(await guardAutoClip({ id: 5 }, 'explicit adult content'), false);
assert.strictEqual(await guardAutoClip({ id: 5 }, 'explicit adult content'), false);
assert.deepStrictEqual(calls.map((c) => c[0]), ['get']);
assert.deepStrictEqual(calls.map((c) => c[0]), ['get']);
- ok('an AI-made clip with an explicit overview goes private; people\'s clips and ordinary overviews are untouched');
+ // Category 3: even a person's clip is blocked, and Vision gets the operator report record.
+ reports.length = 0;
+ clips.set(6, { id: 6, auto_generated: false, visibility: 'public' });
+ assert.strictEqual(await guardAutoClip({ id: 6 }, 'a naked child runs across the frame'), true);
+ assert.deepStrictEqual(calls.pop(), ['update', 6, { visibility: 'private', description: '' }]);
+ assert.strictEqual(reports.length, 1);
+ assert.strictEqual(reports[0].text, 'a naked child runs across the frame');
+ // Already private: still reported, nothing rewritten.
+ calls.length = 0;
+ clips.set(7, { id: 7, auto_generated: true, visibility: 'private' });
+ assert.strictEqual(await guardAutoClip({ id: 7, auto_generated: true, visibility: 'private' }, 'two kids appear nude in the background'), false);
+ assert.strictEqual(reports.length, 2);
+ assert.deepStrictEqual(calls, [], 'already private is not updated again');
+ ok('an AI-made clip with an explicit overview goes private; a category-3 clip is blocked and reported');
// The writers use it.
// The writers use it.
const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * Live's category-3 operator report goes to OpenVibe.Vision with Live's service principal: one POST, the
+ * clip's id, the AI text and the note that names the hard line. Nothing else leaves the process (the clip
+ * is already hidden by explicit.js whether or not Vision answers).
+ */
+const assert = require('assert');
+const http = require('http');
+
+(async () => {
+ const state = { token: 0, reviews: [] };
+ const srv = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') { state.token++; return send(200, { access_token: 'svc-live-vision', token_type: 'Bearer', expires_in: 300 }); }
+ if (req.url === '/api/v1/review') {
+ state.reviews.push({ auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') });
+ return send(201, { review: { id: 'vir_x', report: { id: 'vor_x', status: 'open' } } });
+ }
+ send(404, {});
+ });
+ });
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ process.env.VISION_URL = base;
+ process.env.OV_NETWORK_INTERNAL_URL = base;
+ process.env.OV_OAUTH_CLIENT_ID = 'live';
+ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+ const client = require('../server/ai/review-client');
+
+ assert.strictEqual(client.enabled(), true);
+ const out = await client.reportMinorSexual({
+ clip: { id: 9, thumbnail_url: 'https://openvibe.media/t/9.jpg' },
+ text: 'a naked child runs across the frame',
+ ownerSubject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',
+ });
+ assert.strictEqual(out.review.report.id, 'vor_x');
+ assert.strictEqual(state.reviews.length, 1);
+ const body = state.reviews[0].body;
+ assert.deepStrictEqual([body.object_type, body.object_id, body.owner_subject], ['live.clip', '9', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ']);
+ assert.strictEqual(body.text, 'a naked child runs across the frame');
+ assert.match(body.note, /minor/);
+ assert.ok(state.reviews[0].auth.startsWith('Bearer '));
+ assert.strictEqual(state.token, 1);
+
+ console.log('ai review client: 1 report sent with Live\'s service token');
+ client._reset();
+ await new Promise((r) => srv.close(r));
+})().catch((e) => { console.error(e); process.exit(1); });