openvibers/openvibe.live Commits
An AI clip is never more visible than its recording (item 151, C16) (#577)
Changes
@@@ -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 beforehtmlBrotliKB: 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)@@@ -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 failsnnew file mode 100644@@@ -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 };@@@ -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);nnew file mode 100644@@@ -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); });
@@ -22,7 +22,7 @@ const BUDGETS = {
@@ -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
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,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
@@ -162,11 +162,15 @@ async function _checkLiveStream(stream) {
@@ -162,11 +162,15 @@ async function _checkLiveStream(stream) {
const shift = streamSec - momentOffset;
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 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;
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({
const clip = await media.createClip({
vod_id: rec.vodId, start_s: clipStart, end_s: clipEnd,
vod_id: rec.vodId, start_s: clipStart, end_s: clipEnd,
title, user_id: stream.user_id,
title, user_id: stream.user_id,
stream_id: streamId, auto_generated: true, description: verdict.desc || '',
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; });
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
if (clip) {
if (clip) {
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
@@ -353,6 +357,11 @@ async function clipVodMoment(o) {
@@ -353,6 +357,11 @@ async function clipVodMoment(o) {
return null;
return null;
}
}
const vodId = vod.vod_id || vod.id;
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);
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
// 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
// gate before either writes auto_clip_log, so claim the slot atomically before cutting and
@@ -368,6 +377,7 @@ async function clipVodMoment(o) {
@@ -368,6 +377,7 @@ async function clipVodMoment(o) {
vod_id: vodId, start_s: start, end_s: start + CLIP_PRE + CLIP_POST,
vod_id: vodId, start_s: start, end_s: start + CLIP_PRE + CLIP_POST,
title: title || 'Standout Moment', user_id: vod.user_id,
title: title || 'Standout Moment', user_id: vod.user_id,
stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',
stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',
+ visibility,
});
});
if (clip) {
if (clip) {
archiveHeld = false; // the clip exists: keep the day reserved even if the log write fails
archiveHeld = false; // the clip exists: keep the day reserved even if the log write fails
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
@@ -121,7 +121,11 @@ async function clipMoment(stream, ev, settings, deps) {
@@ -121,7 +121,11 @@ async function clipMoment(stream, ev, settings, deps) {
const start = Math.max(0, end - CLIP_SEC);
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 });
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 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; });
.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 });
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);
const url = (deps.videoUrl || require('../web/video-url').videoUrl)('clip', clip.id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });