openvibers/openvibe.live Commits

An AI clip is never more visible than its recording (item 151, C16) (#577)

OP OpenVibers committed 10 days ago · 06e6675124 · 5 files, +119 −3

* An AI clip is never more visible than its recording (item 151, C16)

Media makes a clip public when no visibility is sent and does not look at the source. The VOD-moment cutter and
Vibe's voice clip sent none, and the live cutter went by the score alone. Every AI cut now sends the lower of what
the picker wants and what the recording allows (unknown or unreadable: private), and a protected recording
(VOD 5709) is never cut.

* Budget: jsRawKB 1279 → 1280 (1279.2 with openvibe-shared 4.107.0 and the home's reveal listener fix)

Changes

ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 43b0ef21..3809f4f8 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1279, // 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsRawKB: 1280, // 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 300, // 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex c9007cb8..484d66c0 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -162,11 +162,15 @@ async function _checkLiveStream(stream) {
const shift = streamSec - momentOffset;
const cut = clipTrim.trim({ moment: streamSec, segments: verdict.transcript, pre: CLIP_PRE, post: CLIP_POST, limit: Math.max(0, recEdge - 0.5) + shift });
const clipStart = Math.max(0, cut.start - shift), clipEnd = cut.end - shift;
++ // Never more visible than the recording (server/ai/clip-publication.js, C16); a protected one is never cut.
++ const publication = require('./clip-publication');
++ if (!publication.mayCut(rec.vodId)) { console.log(`[AutoClip] stream ${streamId}: recording ${rec.vodId} is protected, not cut`); return; }
++ const visibility = publication.visibilityFor({ vodVisibility: await publication.vodVisibility({ id: rec.vodId }), wanted: rated.verdict === 'public' ? 'public' : 'unlisted' });
const clip = await media.createClip({
vod_id: rec.vodId, start_s: clipStart, end_s: clipEnd,
title, user_id: stream.user_id,
stream_id: streamId, auto_generated: true, description: verdict.desc || '',
-- visibility: rated.verdict === 'public' ? 'public' : 'unlisted',
++ visibility,
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
if (clip) {
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
@@@ -353,6 +357,11 @@ async function clipVodMoment(o) {
return null;
}
const vodId = vod.vod_id || vod.id;
++ // Never more visible than the recording (C16); a protected recording is never cut. Media would otherwise make
++ // the clip public whatever the recording is.
++ const publication = require('./clip-publication');
++ if (!publication.mayCut(vodId)) { console.log(`[AutoClip] VOD ${vodId}: protected, not cut`); return null; }
++ const visibility = publication.visibilityFor({ vodVisibility: await publication.vodVisibility(vod), wanted: 'public' });
const start = Math.max(0, Math.floor(offset) - CLIP_PRE);
// One archive clip per UTC day is shared with the AI moments job. Both may pass the pool
// gate before either writes auto_clip_log, so claim the slot atomically before cutting and
@@@ -368,6 +377,7 @@ async function clipVodMoment(o) {
vod_id: vodId, start_s: start, end_s: start + CLIP_PRE + CLIP_POST,
title: title || 'Standout Moment', user_id: vod.user_id,
stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',
++ visibility,
});
if (clip) {
archiveHeld = false; // the clip exists: keep the day reserved even if the log write fails
ddiff --git a/server/ai/clip-publication.js b/server/ai/clip-publication.js
nnew file mode 100644
iindex 00000000..bfb7a174
---- /dev/null
++++ b/server/ai/clip-publication.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * clip-publication.js — how visible an AI clip may be (item 151, C16: "gate publication and preserve protected
++ * private content").
++ *
++ * A clip is never more visible than the recording it is cut from: a private recording's clip is private, an unlisted
++ * recording's is at most unlisted, and only a public recording's clip can be public, when the picker's score says so.
++ * A recording that is protected for good (VOD 5709) is never cut. Media makes a clip public when no visibility is
++ * sent, and does not look at the source, so every AI cut sends the one this decides.
++ *
++ * visibilityFor({ vodVisibility, wanted }) → 'public' | 'unlisted' | 'private'
++ * mayCut(vodId) → false for a protected recording
++ * vodVisibility(vod) → the recording's visibility (asked from Media when the row does not say), 'private' when
++ * Media cannot tell
++ */
++const media = require('../media-client');
++
++// Protected for good (owner, 2026-10-29): never cut again, never republished. Clip 483 is kept private by
++// auto-clip-flag sync and the clip evaluation; this list is the recordings.
++const NEVER_CUT = new Set(['5709']);
++const RANK = { private: 0, unlisted: 1, public: 2 };
++
++function normalize(v, fallback = 'private') {
++ const s = String(v || '').toLowerCase();
++ return Object.prototype.hasOwnProperty.call(RANK, s) ? s : fallback;
++}
++
++/** The lower of what the picker wants and what the recording allows. */
++function visibilityFor({ vodVisibility, wanted = 'public' } = {}) {
++ const cap = normalize(vodVisibility, 'private');
++ const want = normalize(wanted, 'unlisted');
++ return RANK[want] <= RANK[cap] ? want : cap;
++}
++
++function mayCut(vodId) {
++ return !NEVER_CUT.has(String(vodId));
++}
++
++async function vodVisibility(vod) {
++ if (vod && typeof vod.visibility === 'string' && vod.visibility) return normalize(vod.visibility);
++ if (vod && typeof vod.is_public === 'boolean') return vod.is_public ? 'public' : 'private';
++ const id = vod && (vod.vod_id || vod.id);
++ if (!id) return 'private';
++ try {
++ const v = await media.getVod(id, { timeoutMs: 10_000 });
++ if (v && typeof v.visibility === 'string' && v.visibility) return normalize(v.visibility);
++ if (v && typeof v.is_public === 'boolean') return v.is_public ? 'public' : 'private';
++ } catch { /* unknown: treated as private below */ }
++ return 'private';
++}
++
++module.exports = { visibilityFor, mayCut, vodVisibility, NEVER_CUT };
ddiff --git a/server/ai/vibe-voice.js b/server/ai/vibe-voice.js
iindex 5c428614..535eab7b 100644
---- a/server/ai/vibe-voice.js
++++ b/server/ai/vibe-voice.js
@@@ -121,7 +121,11 @@ async function clipMoment(stream, ev, settings, deps) {
const start = Math.max(0, end - CLIP_SEC);
if (end - start < 5) return say(stream, 'Give me a few seconds of the stream first, then ask again.', { speak: settings.speak, deps });
const title = `${String(stream.title || 'Stream moment').slice(0, 70)} (clipped by voice)`;
-- const clip = await media.createClip({ vod_id: rec.vodId, start_s: start, end_s: end, title, user_id: stream.user_id, stream_id: stream.id, auto_generated: false, description: '' })
++ // Never more visible than the recording (server/ai/clip-publication.js): Media would make it public otherwise.
++ const publication = require('./clip-publication');
++ if (!publication.mayCut(rec.vodId)) return say(stream, 'That recording is protected, so I can\'t clip it.', { speak: settings.speak, deps });
++ const visibility = publication.visibilityFor({ vodVisibility: await publication.vodVisibility({ id: rec.vodId }), wanted: 'public' });
++ const clip = await media.createClip({ vod_id: rec.vodId, start_s: start, end_s: end, title, user_id: stream.user_id, stream_id: stream.id, auto_generated: false, description: '', visibility })
.catch((e) => { console.warn(`[Vibe] stream ${stream.id}: clip failed:`, e.message); return null; });
if (!clip) return say(stream, 'That clip didn\'t work, sorry. Try again in a moment.', { speak: settings.speak, deps });
const url = (deps.videoUrl || require('../web/video-url').videoUrl)('clip', clip.id);
ddiff --git a/test/clip-publication.test.js b/test/clip-publication.test.js
nnew file mode 100644
iindex 00000000..9d93e080
---- /dev/null
++++ b/test/clip-publication.test.js
@@@ -0,0 +1,50 @@
++/**
++ * An AI clip is never more visible than its recording (server/ai/clip-publication.js; item 151, C16). Media makes a
++ * clip public when no visibility is sent and does not look at the source, so every AI cut (live, VOD moments, Vibe's
++ * voice clip) sends the one this decides; a protected recording (VOD 5709) is never cut; an unknown one is private.
++ *
++ * node test/run.js clip-publication
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const media = require('../server/media-client');
++const vods = { 1: { id: 1, visibility: 'public' }, 2: { id: 2, visibility: 'unlisted' }, 3: { id: 3, visibility: 'private' }, 4: { id: 4, is_public: false } };
++media.getVod = async (id) => { if (String(id) === '99') throw new Error('Media down'); return vods[id] || null; };
++const pub = require('../server/ai/clip-publication');
++
++(async () => {
++ // The lower of what the picker wants and what the recording allows.
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: 'public', wanted: 'public' }), 'public');
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: 'public', wanted: 'unlisted' }), 'unlisted');
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: 'unlisted', wanted: 'public' }), 'unlisted');
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: 'private', wanted: 'public' }), 'private');
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: undefined, wanted: 'public' }), 'private', 'unknown is private');
++ assert.strictEqual(pub.visibilityFor({ vodVisibility: 'weird', wanted: 'public' }), 'private');
++
++ // The recording's visibility: from the row, else from Media, else private.
++ assert.strictEqual(await pub.vodVisibility({ id: 2, visibility: 'unlisted' }), 'unlisted');
++ assert.strictEqual(await pub.vodVisibility({ id: 3 }), 'private');
++ assert.strictEqual(await pub.vodVisibility({ id: 4 }), 'private', 'legacy is_public: false');
++ assert.strictEqual(await pub.vodVisibility({ id: 1 }), 'public');
++ assert.strictEqual(await pub.vodVisibility({ id: 99 }), 'private', 'Media down: private');
++ assert.strictEqual(await pub.vodVisibility({ id: 777 }), 'private', 'no such recording: private');
++
++ // A protected recording is never cut.
++ assert.strictEqual(pub.mayCut(5709), false);
++ assert.strictEqual(pub.mayCut('5709'), false);
++ assert.strictEqual(pub.mayCut(1), true);
++
++ // Every AI cut sends the decided visibility and asks mayCut first (live, VOD moments, Vibe's voice clip).
++ const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');
++ const job = src('auto-clip-job.js');
++ assert.strictEqual((job.match(/publication\.mayCut\(/g) || []).length, 2, 'both cutters ask mayCut');
++ assert.strictEqual((job.match(/visibility,\n/g) || []).length >= 2, true, 'both cutters send the visibility');
++ assert.doesNotMatch(job, /visibility: rated\.verdict === 'public' \? 'public' : 'unlisted',\n\s*\}\)/, 'the score alone no longer decides');
++ const voice = src('vibe-voice.js');
++ assert.match(voice, /publication\.mayCut\(rec\.vodId\)/);
++ assert.match(voice, /auto_generated: false, description: '', visibility \}/);
++ console.log('clip publication: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });