openvibers/openvibe.live Commits

A moment the AI describes as sexual content is never posted or clipped, and an AI-made clip whose overview turns out explicit goes private (clip 483 went public on openvibe.media and Live's home) (#344)

OP OpenVibers committed 20 days ago · cd02b9e4cc · 6 files, +143 −1

Changes

ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex a9f8661d..c63e02f8 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -359,6 +359,7 @@ async function generateClipOverview(clip) {
const transcript = r ? r.transcript : '';
const speech = clipOverviewSpeech(r);
try { await db.setClipAiOverview(clip.id, { overview, transcript: transcript || null, segments: speech.segments }); } catch { /* */ }
++ await require('./explicit').guardAutoClip(clip, overview);
try { if (speech.status) await db.setClipTranscriptStatus(clip.id, speech.status); } catch { /* */ }
return { overview: r ? r.overview : null, transcript };
}
ddiff --git a/server/ai/ai-media-analyze.js b/server/ai/ai-media-analyze.js
iindex 06219be0..42226abe 100644
---- a/server/ai/ai-media-analyze.js
++++ b/server/ai/ai-media-analyze.js
@@@ -70,7 +70,10 @@ async function analyze(kind, item) {
// An overview Live already has (a model's, or a person's edit) is kept: AI's fills only a missing one.
if (!state || !String(state.ai_overview || '').trim()) {
if (kind === 'vod') await db.setVodAiOverview(item.id, overview);
-- else await db.setClipAiOverview(item.id, { overview });
++ else {
++ await db.setClipAiOverview(item.id, { overview });
++ await require('./explicit').guardAutoClip(item, overview);
++ }
}
const partialSpeech = Array.isArray(out.gaps) && out.gaps.some(g => /^Speech-to-text failed/i.test(String(g)));
if (out.transcript.available && !partialSpeech) {
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 395336bc..41a77d63 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -21,6 +21,7 @@ const ai = require('./ai-analysis');
const aiService = require('./ai-service');
const media = require('../media-client');
const registry = require('./moment-registry');
++const explicit = require('./explicit');
let cfg = null; try { cfg = require('../config'); } catch { /* */ }
let thumb = null; try { thumb = require('../media-proxy/live-thumbs'); } catch { /* optional */ }
const FRAMES_TMP_DIR = path.join(os.tmpdir(), 'openvibe-live-moments');
@@@ -204,6 +205,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
if (!farFromUsed(t)) { console.log(`[AI-Moments] VOD ${vod.vod_id}: pick at ${_mmss(t)} is next to a used moment — trying the objective signals instead`); throw new Error('near used'); }
const near = _nearestMemory(ctx.memories, t);
const result = { offset: t, title: _publicTitle(j.title, vod, t, j.desc), desc: db.noLooks(_cleanText(_deJson(j.desc), 400) || (near && _deJson(near.description)) || ''), tags: _memTags(near) };
++ if (explicit.isExplicit(j.desc, j.title, near && near.description, result.desc)) { console.log(`[AI-Moments] VOD ${vod.vod_id}: the pick at ${_mmss(t)} shows sexual content — no moment from it`); return null; }
return _isEmptyScene(result.desc) ? null : result;
}
} catch { /* fall through to signal-based pick */ }
@@@ -220,6 +222,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
let offset = clamp(candidates[0].t);
const near = _nearestMemory(ctx.memories, offset);
const desc = near ? db.noLooks(_deJson(near.description)) : '';
++ if (near && explicit.isExplicit(near.description)) return null; // never a moment of sexual content (server/ai/explicit.js)
return _isEmptyScene(desc) ? null : { offset, title: _fallbackTitle(vod, offset, desc), desc, tags: _memTags(near) };
}
// True when a description says the frame is essentially empty — black/dark/loading/blank — so we
@@@ -378,6 +381,7 @@ async function tick(opts = {}) {
// The source is the OpenVibe.Media playback URL — ffmpeg range-seeks the remote
// file, so cold VODs still work.
let screenshotPath = null;
++ let sexual = false; // the vision pass says the frame shows sexual content (server/ai/explicit.js)
let momentSource = null; // resolved Media URL, for clipping
let img = `/api/thumbnails/generate/vod/${v.vod_id}`;
try {
@@@ -397,6 +401,7 @@ async function tick(opts = {}) {
if (await _aiOn() && ai.analyzeImagePaste) {
try {
const vis = await ai.analyzeImagePaste(outPath, title, 'moment_frame');
++ if (vis && explicit.isExplicit(vis.description, (vis.tags || []).join(' '))) sexual = true;
if (vis && vis.description && vis.description.length > 25) {
desc = db.noLooks(_cleanText(vis.description, 500));
if (Array.isArray(vis.tags) && vis.tags.length) tags = vis.tags.slice(0, 8);
@@@ -415,6 +420,12 @@ async function tick(opts = {}) {
console.log(`[AI-Moments] Skipped VOD ${v.vod_id} — could not extract moment frame (no local file).`);
continue;
}
++ // Never post or clip a frame of sexual content.
++ if (sexual) {
++ console.log(`[AI-Moments] Skipped VOD ${v.vod_id} — the frame shows sexual content; nothing posted or clipped.`);
++ try { require('node:fs').unlinkSync(screenshotPath); } catch { /* */ }
++ continue;
++ }
// Pixel backstop: never post/clip a black or empty frame.
if (await _frameTooDark(screenshotPath)) {
console.log(`[AI-Moments] Skipped VOD ${v.vod_id} — extracted frame is too dark/empty.`);
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex f0313ed5..44a5357f 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -133,6 +133,8 @@ async function _checkLiveStream(stream) {
const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));
if (end - start < 5) return; // not enough flushed footage around the moment yet
++ // A moment the AI describes as sexual content is never clipped (server/ai/explicit.js).
++ if (require('./explicit').isExplicit(verdict.desc, verdict.title)) { console.log(`[AutoClip] stream ${streamId}: skipped a moment the AI describes as sexual content`); return; }
const title = verdict.title ? require('./ai-moments-job').publicTitle(verdict.title, stream, momentOffset, verdict.desc) : 'Chat-Hyped Moment';
const clip = await media.createClip({
vod_id: rec.vodId, start_s: start, end_s: end,
@@@ -278,6 +280,10 @@ async function clipVodMoment(o) {
const { vod, offset, title, desc } = o || {};
if (!vod || !(offset >= 0)) return null;
if (!await _derivationOn(vod.user_id)) return null; // the channel turned AI Moments off
++ if (require('./explicit').isExplicit(desc, title)) { // never a clip of sexual content (server/ai/explicit.js)
++ console.log(`[AutoClip] skip VOD ${vod.vod_id || vod.id} — the AI describes the moment as sexual content`);
++ return null;
++ }
if (await _isDuplicateAutoClip(vod, offset, desc, title)) {
console.log(`[AutoClip] skip VOD ${vod.vod_id || vod.id} — duplicate/near-identical auto-clip already exists`);
return null;
ddiff --git a/server/ai/explicit.js b/server/ai/explicit.js
nnew file mode 100644
iindex 00000000..cf345a45
---- /dev/null
++++ b/server/ai/explicit.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * AI text that says a frame or clip shows sexual content. The no-looks rules (openvibe-sdk/no-looks) take out how a
++ * person looks; they were never meant to judge what a scene shows, and an adjective-only filter turns "a naked man …
++ * reaching toward their genitals" into "a man … reaching toward their genitals". 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 that description (clip 483).
++ *
++ * 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.
++ */
++const media = require('../media-client');
++
++// What a scene shows, not what someone talks about in passing: body parts, sex acts, pornography, nudity.
++const EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
++
++/** True when any of the texts describes sexual content. */
++function isExplicit(...texts) {
++ return texts.some((t) => t != null && EXPLICIT.test(String(t)));
++}
++
++/**
++ * 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.
++ */
++async function guardAutoClip(clip, overview) {
++ if (!clip || !clip.id || !isExplicit(overview)) 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 sexual content (the owner reviews the stream)`);
++ return true;
++ } catch (e) {
++ console.warn(`[AI] Auto-clip ${clip.id}: explicit overview, but it could not be made private:`, e.message);
++ return false;
++ }
++}
++
++module.exports = { isExplicit, guardAutoClip, EXPLICIT };
ddiff --git a/test/ai-explicit.test.js b/test/ai-explicit.test.js
nnew file mode 100644
iindex 00000000..8a4e2dbf
---- /dev/null
++++ b/test/ai-explicit.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * AI text that says a scene shows sexual content never becomes a public moment or auto-clip, and an AI-made clip whose
++ * overview turns out explicit goes private (server/ai/explicit.js). 2026-10-23: clip 483, cut by the AI from a stream
++ * that showed sexual acts, went public on openvibe.media and Live's home with a description of them.
++ *
++ * node test/ai-explicit.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++// Media, stubbed before explicit.js loads it.
++const calls = [];
++const clips = new Map();
++const stub = {
++ getClip: async (id) => { calls.push(['get', id]); return clips.get(id) || null; },
++ updateClip: async (id, fields) => { calls.push(['update', id, fields]); return { id, ...fields }; },
++};
++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');
++
++let pass = 0;
++const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
++
++(async () => {
++ // What the AI wrote about clip 483, word for word, and the same after the no-looks adjective filter.
++ for (const t of [
++ 'A naked man lying on their back with legs spread, reaching toward their genitals — heard: "I\'m gonna assume…"',
++ 'A man lying on their back with legs spread, reaching toward their genitals',
++ 'The video shows the streamer engaging in explicit adult content, with close-up footage of sexual acts on a couch',
++ 'Pornographic footage plays on the second monitor', 'The scene contains nudity', 'masturbating on camera',
++ ]) assert.ok(isExplicit(t), `explicit: ${t}`);
++ for (const t of [
++ 'A player in Fortnite is sprinting along a pink-purple sunset map', 'The streamer smokes a cigarette and smiles at the camera',
++ 'They read the news from Essex and talk about the weather', 'A boss fight in a dark purple-lit arena', '', null, undefined,
++ 'A sextant and a map on the desk', 'The chat spams PogChamp during the clutch',
++ ]) assert.ok(!isExplicit(t), `not explicit: ${t}`);
++ assert.ok(isExplicit('a fine title', null, 'the frame shows sexual activity'), 'any of several texts');
++ ok('the detector: what a scene shows (body parts, sex acts, pornography, nudity), not ordinary streams');
++
++ // 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.
++ 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');
++ // 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');
++ // An event's clip object may not say who made it: Media is asked (only for an explicit overview).
++ clips.set(4, { id: 4, auto_generated: true, visibility: 'unlisted' });
++ assert.strictEqual(await guardAutoClip({ id: 4 }, 'the streamer shows their genitals'), true);
++ assert.deepStrictEqual(calls.map((c) => c[0]), ['get', 'update']);
++ // Already private: left alone.
++ calls.length = 0;
++ 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');
++
++ // The writers use it.
++ const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
++ const moments = read('server/ai/ai-moments-job.js');
++ assert.ok(moments.includes("const explicit = require('./explicit');"));
++ assert.ok(/explicit\.isExplicit\(j\.desc, j\.title, near && near\.description, result\.desc\)\) \{[^\n]*return null; \}/.test(moments), 'the AI pick is dropped');
++ assert.ok(moments.includes('if (near && explicit.isExplicit(near.description)) return null;'), 'the signal pick is dropped');
++ assert.ok(moments.includes("if (vis && explicit.isExplicit(vis.description, (vis.tags || []).join(' '))) sexual = true;") && /if \(sexual\) \{[\s\S]{0,400}continue;/.test(moments), 'a frame the vision pass calls sexual is neither posted nor clipped');
++ const auto = read('server/ai/auto-clip-job.js');
++ assert.ok(auto.includes("if (require('./explicit').isExplicit(verdict.desc, verdict.title))"), 'the live auto-clip skips it');
++ assert.ok(auto.includes("if (require('./explicit').isExplicit(desc, title)) {"), 'a finished recording\'s auto-clip skips it');
++ assert.ok(read('server/ai/ai-analysis.js').includes("await require('./explicit').guardAutoClip(clip, overview);"), 'the clip overview pass guards');
++ assert.ok(read('server/ai/ai-media-analyze.js').includes("await require('./explicit').guardAutoClip(item, overview);"), 'the OpenVibe.AI overview pass guards');
++ ok('the moments job, both auto-clip paths and both clip overview passes use it');
++
++ console.log(`ai explicit: ${pass} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });