openvibers/openvibe.live Commits
Public AI titles: body words count as looks; a refused clip title becomes "<stream title> at mm:ss" (#364)
Changes
@@@ -59,7 +59,7 @@ function _deJson(desc) {// dangling word ("…Speaking To") and never about how someone looks (owner's rule for public AI text: "A Man With A// Beard Is Speaking To" and "A Person In A Bright Yellow High-Visibility Jacket" titled public clips, 2026-10-17).// Otherwise the stream's own title and the moment's time ("Fortnite! at 41:20").--const LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|topless|naked|nude|tank top|t-shirt|beanie|cap|hat|helmet|headset|headphones|hair|bald|blonde|expression|face|smil(e|ing)|clothing|outfit|tattoo(ed)?|young|old(er)?|teen(age)?|boy|girl|kid|child|blurred|body|skin)\b/i;++const LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|topless|naked|nude|tank top|t-shirt|beanie|cap|hat|helmet|headset|headphones|hair|bald|blonde|expression|face|smil(e|ing)|clothing|outfit|tattoo(ed)?|young|old(er)?|teen(age)?|boy|girl|kid|child|blurred|body|skin|boob(s|ies)?|tits|titties|breasts?|cleavage|nipples?|butts?|booty|thighs?|crotch|sexy|busty|curvy|thicc)\b/i;// A title also fails the shared rule (openvibe-sdk/no-looks, db.hasLooks): this list alone let "A Naked Man Lying On// Their Back With Legs" through to Live's home (clip 483, 2026-10-23): it named neither "man" nor "naked".const namesLooks = (s) => LOOKS_WORDS.test(s) || db.hasLooks(s);@@@ -4,8 +4,8 @@* migrations/0019_clip_ai_title.sql). A clip keeps the title its maker gave it. One still named by a placeholder* ("Untitled Clip", "Clip from stream", …: what the Clip buttons send when nobody types a title) is titled from its AI* overview once that exists: OpenVibe.AI's live.stream.titles template (3-6 words, what happens, never what anyone looks-- * like), then Live's rule for public AI titles (ai-moments-job publicTitle: a title naming looks is replaced by one from-- * the overview's first clause, or the stream's title and the moment). The new title goes to OpenVibe.Media, and each++ * like), then Live's rule for public AI titles (ai-moments-job publicTitle: a title naming looks or bodies, or a rough++ * one, is replaced by the stream's title and the moment). The new title goes to OpenVibe.Media, and each* clip is decided once (clip_ai_state.title_status: titled, kept or failed).** isPlaceholder(title, sourceTitles) → true for a title nobody chose@@@ -84,8 +84,9 @@ async function titleClips({ ids = null } = {}) {if (raw == null) { if (out) await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = ?", [t.clip.id]); continue; }const vod = { title: t.stream && t.stream.title, username: t.clip.username || (t.stream && t.stream.username) };const at = Number(t.clip.start_time) || 0;-- let title = String(publicTitle(raw, vod, at, t.state.ai_overview) || '').slice(0, 80).trim();-- // Titles are public everywhere a clip shows: one with a slur or strong profanity gives way to the stream's.++ // Titles are public everywhere a clip shows: one about looks or bodies, or with a slur or strong profanity,++ // gives way to "<stream title> at mm:ss" (an overview's first clause makes a poor title: "The video centers on…").++ let title = String(publicTitle(raw, vod, at, null) || '').slice(0, 80).trim();if (title && !frontPageOk(title)) title = String(publicTitle('', vod, at, null) || '').slice(0, 80).trim();// The maker may have named it while the AI was writing: their title wins.let fresh = null;@@@ -48,7 +48,7 @@ console.warn = () => {};media.updateClip = async (id, fields) => { updates.push([Number(id), fields.title]); clips.get(Number(id)).title = fields.title; return { id }; };const calls = [];-- const answers = { 'triple kill': 'Triple Kill Final Round Clutch', 'dress': 'Woman In Red Dress Dances', 'rough clutch': 'Fucking Insane Clutch', 'rename': 'Late Game Comeback', 'default': 'Shotgun Rush Through Hallway' };++ const answers = { 'triple kill': 'Triple Kill Final Round Clutch', 'dress': 'Woman In Red Dress Dances', 'painted': 'Boobs by Bobs Chalk Talk', 'rough clutch': 'Fucking Insane Clutch', 'rename': 'Late Game Comeback', 'default': 'Shotgun Rush Through Hallway' };aiService.structured = async (workflow, input, opts) => {calls.push({ workflow, input, opts });return { titles: input.summaries.map((s, index) => {@@@ -70,6 +70,7 @@ console.warn = () => {};put(605, 'Clip from stream'); await seed(605, 'Someone in a red dress dances on the stage while chat cheers.');put(606, 'Untitled Clip'); await seed(606, 'A rough clutch play in the last seconds.');await seed(607, 'Overview of a clip Media no longer has.');++ put(612, 'Clip from stream'); await seed(612, 'A streamer shows chalkboard signs for "Boobs Painted by Bobs", an art collection.');put(608, 'Untitled Clip'); await seed(608, 'A rename test: a late game comeback.');reads.set(608, ['Untitled Clip', 'What a comeback']); // the person names it before the AI's title is saved@@@ -79,17 +80,18 @@ console.warn = () => {};assert.strictEqual(calls[0].workflow, 'live.stream.titles');assert.strictEqual(calls[0].opts.meter.kind, 'clip_titles');const summaries = calls[0].input.summaries;-- assert.ok(summaries.length === 5 && summaries.every((s) => s.length <= 1000), 'one summary per placeholder clip with an overview');++ assert.ok(summaries.length === 6 && summaries.every((s) => s.length <= 1000), 'one summary per placeholder clip with an overview');const s601 = summaries.find((s) => s.includes('triple kill'));assert.ok(s601.includes('Said: "no way, no way!"') && s601.includes('From the stream "Friday ranked" (Shooters)'), s601);const byId = new Map(updates);assert.strictEqual(byId.get(601), 'Triple Kill Final Round Clutch');assert.strictEqual(byId.get(603), 'Shotgun Rush Through Hallway', '"Clip: <stream title>" is a placeholder');-- assert.ok(byId.has(605) && !/dress|woman/i.test(byId.get(605)), `a title naming looks is replaced: ${byId.get(605)}`);++ assert.strictEqual(byId.get(605), 'Friday ranked at 0:42', 'a title naming looks gives way to the stream and the moment');assert.strictEqual(byId.get(606), 'Friday ranked at 0:42', 'a rough title gives way to the stream and the moment');++ assert.strictEqual(byId.get(612), 'Friday ranked at 0:42', 'a body word gives way too (clip 488, 2026-10-25)');assert.ok(!byId.has(602) && !byId.has(604) && !byId.has(607) && !byId.has(608), 'nothing else is written to Media');-- assert.strictEqual(n, 4);++ assert.strictEqual(n, 5);assert.deepStrictEqual([await status(601), await status(602), await status(603), await status(604), await status(605), await status(606), await status(607), await status(608)],@@@ -32,6 +32,12 @@ assert.strictEqual(publicTitle('Red Beanie Moment', vod, 2480, 'A man wearing aassert.strictEqual(publicTitle('A Naked Man Lying On Their Back With Legs', vod, 2480, ''), 'Fortnite! at 41:20');assert.strictEqual(publicTitle('Woman Laughs At Chat', vod, 2480, ''), 'Fortnite! at 41:20', 'a guessed gender is a look');assert.strictEqual(captionTitle('A naked man lies on a bed near a window.'), null);++// Body words too: clip 488 was AI-titled "Boobs by Bobs Chalk Talk" from a sign in the stream (2026-10-25).++assert.strictEqual(publicTitle('Boobs by Bobs Chalk Talk', vod, 2480, ''), 'Fortnite! at 41:20');++assert.strictEqual(publicTitle('Sexy Dance Break', vod, 2480, ''), 'Fortnite! at 41:20');++assert.strictEqual(publicTitle('Thighs and Cleavage Cam', vod, 2480, ''), 'Fortnite! at 41:20');++assert.strictEqual(publicTitle('Butt Slide Fail', vod, 2480, ''), 'Fortnite! at 41:20');++assert.strictEqual(publicTitle('Kick-Ass Combo Finish', vod, 60, ''), 'Kick-Ass Combo Finish', 'not every rough word is a body word');assert.strictEqual(publicTitle('Headphones Hit a Revelation', { title: 'A little couch surfing before the store.' }, 61, ''), 'A little couch surfing before the store at 1:01');assert.strictEqual(publicTitle('', { title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");process.stdout.write('moment fallback title: all checks passed\n');
@@ -59,7 +59,7 @@ function _deJson(desc) {
@@ -59,7 +59,7 @@ function _deJson(desc) {
// dangling word ("…Speaking To") and never about how someone looks (owner's rule for public AI text: "A Man With A
// dangling word ("…Speaking To") and never about how someone looks (owner's rule for public AI text: "A Man With A
// Beard Is Speaking To" and "A Person In A Bright Yellow High-Visibility Jacket" titled public clips, 2026-10-17).
// Beard Is Speaking To" and "A Person In A Bright Yellow High-Visibility Jacket" titled public clips, 2026-10-17).
// Otherwise the stream's own title and the moment's time ("Fortnite! at 41:20").
// Otherwise the stream's own title and the moment's time ("Fortnite! at 41:20").
-const LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|topless|naked|nude|tank top|t-shirt|beanie|cap|hat|helmet|headset|headphones|hair|bald|blonde|expression|face|smil(e|ing)|clothing|outfit|tattoo(ed)?|young|old(er)?|teen(age)?|boy|girl|kid|child|blurred|body|skin)\b/i;
+const LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|topless|naked|nude|tank top|t-shirt|beanie|cap|hat|helmet|headset|headphones|hair|bald|blonde|expression|face|smil(e|ing)|clothing|outfit|tattoo(ed)?|young|old(er)?|teen(age)?|boy|girl|kid|child|blurred|body|skin|boob(s|ies)?|tits|titties|breasts?|cleavage|nipples?|butts?|booty|thighs?|crotch|sexy|busty|curvy|thicc)\b/i;
// A title also fails the shared rule (openvibe-sdk/no-looks, db.hasLooks): this list alone let "A Naked Man Lying On
// A title also fails the shared rule (openvibe-sdk/no-looks, db.hasLooks): this list alone let "A Naked Man Lying On
// Their Back With Legs" through to Live's home (clip 483, 2026-10-23): it named neither "man" nor "naked".
// Their Back With Legs" through to Live's home (clip 483, 2026-10-23): it named neither "man" nor "naked".
const namesLooks = (s) => LOOKS_WORDS.test(s) || db.hasLooks(s);
const namesLooks = (s) => LOOKS_WORDS.test(s) || db.hasLooks(s);
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
* migrations/0019_clip_ai_title.sql). A clip keeps the title its maker gave it. One still named by a placeholder
* migrations/0019_clip_ai_title.sql). A clip keeps the title its maker gave it. One still named by a placeholder
* ("Untitled Clip", "Clip from stream", …: what the Clip buttons send when nobody types a title) is titled from its AI
* ("Untitled Clip", "Clip from stream", …: what the Clip buttons send when nobody types a title) is titled from its AI
* overview once that exists: OpenVibe.AI's live.stream.titles template (3-6 words, what happens, never what anyone looks
* overview once that exists: OpenVibe.AI's live.stream.titles template (3-6 words, what happens, never what anyone looks
- * like), then Live's rule for public AI titles (ai-moments-job publicTitle: a title naming looks is replaced by one from
- * the overview's first clause, or the stream's title and the moment). The new title goes to OpenVibe.Media, and each
+ * like), then Live's rule for public AI titles (ai-moments-job publicTitle: a title naming looks or bodies, or a rough
+ * one, is replaced by the stream's title and the moment). The new title goes to OpenVibe.Media, and each
* clip is decided once (clip_ai_state.title_status: titled, kept or failed).
* clip is decided once (clip_ai_state.title_status: titled, kept or failed).
*
*
* isPlaceholder(title, sourceTitles) → true for a title nobody chose
* isPlaceholder(title, sourceTitles) → true for a title nobody chose
@@ -84,8 +84,9 @@ async function titleClips({ ids = null } = {}) {
@@ -84,8 +84,9 @@ async function titleClips({ ids = null } = {}) {
if (raw == null) { if (out) await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = ?", [t.clip.id]); continue; }
if (raw == null) { if (out) await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = ?", [t.clip.id]); continue; }
const vod = { title: t.stream && t.stream.title, username: t.clip.username || (t.stream && t.stream.username) };
const vod = { title: t.stream && t.stream.title, username: t.clip.username || (t.stream && t.stream.username) };
const at = Number(t.clip.start_time) || 0;
const at = Number(t.clip.start_time) || 0;
- let title = String(publicTitle(raw, vod, at, t.state.ai_overview) || '').slice(0, 80).trim();
- // Titles are public everywhere a clip shows: one with a slur or strong profanity gives way to the stream's.
+ // Titles are public everywhere a clip shows: one about looks or bodies, or with a slur or strong profanity,
+ // gives way to "<stream title> at mm:ss" (an overview's first clause makes a poor title: "The video centers on…").
+ let title = String(publicTitle(raw, vod, at, null) || '').slice(0, 80).trim();
if (title && !frontPageOk(title)) title = String(publicTitle('', vod, at, null) || '').slice(0, 80).trim();
if (title && !frontPageOk(title)) title = String(publicTitle('', vod, at, null) || '').slice(0, 80).trim();
// The maker may have named it while the AI was writing: their title wins.
// The maker may have named it while the AI was writing: their title wins.
let fresh = null;
let fresh = null;
@@ -48,7 +48,7 @@ console.warn = () => {};
@@ -48,7 +48,7 @@ console.warn = () => {};
media.updateClip = async (id, fields) => { updates.push([Number(id), fields.title]); clips.get(Number(id)).title = fields.title; return { id }; };
media.updateClip = async (id, fields) => { updates.push([Number(id), fields.title]); clips.get(Number(id)).title = fields.title; return { id }; };
const calls = [];
const calls = [];
- const answers = { 'triple kill': 'Triple Kill Final Round Clutch', 'dress': 'Woman In Red Dress Dances', 'rough clutch': 'Fucking Insane Clutch', 'rename': 'Late Game Comeback', 'default': 'Shotgun Rush Through Hallway' };
+ const answers = { 'triple kill': 'Triple Kill Final Round Clutch', 'dress': 'Woman In Red Dress Dances', 'painted': 'Boobs by Bobs Chalk Talk', 'rough clutch': 'Fucking Insane Clutch', 'rename': 'Late Game Comeback', 'default': 'Shotgun Rush Through Hallway' };
aiService.structured = async (workflow, input, opts) => {
aiService.structured = async (workflow, input, opts) => {
calls.push({ workflow, input, opts });
calls.push({ workflow, input, opts });
return { titles: input.summaries.map((s, index) => {
return { titles: input.summaries.map((s, index) => {
@@ -70,6 +70,7 @@ console.warn = () => {};
@@ -70,6 +70,7 @@ console.warn = () => {};
put(605, 'Clip from stream'); await seed(605, 'Someone in a red dress dances on the stage while chat cheers.');
put(605, 'Clip from stream'); await seed(605, 'Someone in a red dress dances on the stage while chat cheers.');
put(606, 'Untitled Clip'); await seed(606, 'A rough clutch play in the last seconds.');
put(606, 'Untitled Clip'); await seed(606, 'A rough clutch play in the last seconds.');
await seed(607, 'Overview of a clip Media no longer has.');
await seed(607, 'Overview of a clip Media no longer has.');
+ put(612, 'Clip from stream'); await seed(612, 'A streamer shows chalkboard signs for "Boobs Painted by Bobs", an art collection.');
put(608, 'Untitled Clip'); await seed(608, 'A rename test: a late game comeback.');
put(608, 'Untitled Clip'); await seed(608, 'A rename test: a late game comeback.');
reads.set(608, ['Untitled Clip', 'What a comeback']); // the person names it before the AI's title is saved
reads.set(608, ['Untitled Clip', 'What a comeback']); // the person names it before the AI's title is saved
@@ -79,17 +80,18 @@ console.warn = () => {};
@@ -79,17 +80,18 @@ console.warn = () => {};
assert.strictEqual(calls[0].workflow, 'live.stream.titles');
assert.strictEqual(calls[0].workflow, 'live.stream.titles');
assert.strictEqual(calls[0].opts.meter.kind, 'clip_titles');
assert.strictEqual(calls[0].opts.meter.kind, 'clip_titles');
const summaries = calls[0].input.summaries;
const summaries = calls[0].input.summaries;
- assert.ok(summaries.length === 5 && summaries.every((s) => s.length <= 1000), 'one summary per placeholder clip with an overview');
+ assert.ok(summaries.length === 6 && summaries.every((s) => s.length <= 1000), 'one summary per placeholder clip with an overview');
const s601 = summaries.find((s) => s.includes('triple kill'));
const s601 = summaries.find((s) => s.includes('triple kill'));
assert.ok(s601.includes('Said: "no way, no way!"') && s601.includes('From the stream "Friday ranked" (Shooters)'), s601);
assert.ok(s601.includes('Said: "no way, no way!"') && s601.includes('From the stream "Friday ranked" (Shooters)'), s601);
const byId = new Map(updates);
const byId = new Map(updates);
assert.strictEqual(byId.get(601), 'Triple Kill Final Round Clutch');
assert.strictEqual(byId.get(601), 'Triple Kill Final Round Clutch');
assert.strictEqual(byId.get(603), 'Shotgun Rush Through Hallway', '"Clip: <stream title>" is a placeholder');
assert.strictEqual(byId.get(603), 'Shotgun Rush Through Hallway', '"Clip: <stream title>" is a placeholder');
- assert.ok(byId.has(605) && !/dress|woman/i.test(byId.get(605)), `a title naming looks is replaced: ${byId.get(605)}`);
+ assert.strictEqual(byId.get(605), 'Friday ranked at 0:42', 'a title naming looks gives way to the stream and the moment');
assert.strictEqual(byId.get(606), 'Friday ranked at 0:42', 'a rough title gives way to the stream and the moment');
assert.strictEqual(byId.get(606), 'Friday ranked at 0:42', 'a rough title gives way to the stream and the moment');
+ assert.strictEqual(byId.get(612), 'Friday ranked at 0:42', 'a body word gives way too (clip 488, 2026-10-25)');
assert.ok(!byId.has(602) && !byId.has(604) && !byId.has(607) && !byId.has(608), 'nothing else is written to Media');
assert.ok(!byId.has(602) && !byId.has(604) && !byId.has(607) && !byId.has(608), 'nothing else is written to Media');
- assert.strictEqual(n, 4);
+ assert.strictEqual(n, 5);
assert.deepStrictEqual(
assert.deepStrictEqual(
[await status(601), await status(602), await status(603), await status(604), await status(605), await status(606), await status(607), await status(608)],
[await status(601), await status(602), await status(603), await status(604), await status(605), await status(606), await status(607), await status(608)],
@@ -32,6 +32,12 @@ assert.strictEqual(publicTitle('Red Beanie Moment', vod, 2480, 'A man wearing a
@@ -32,6 +32,12 @@ assert.strictEqual(publicTitle('Red Beanie Moment', vod, 2480, 'A man wearing a
assert.strictEqual(publicTitle('A Naked Man Lying On Their Back With Legs', vod, 2480, ''), 'Fortnite! at 41:20');
assert.strictEqual(publicTitle('A Naked Man Lying On Their Back With Legs', vod, 2480, ''), 'Fortnite! at 41:20');
assert.strictEqual(publicTitle('Woman Laughs At Chat', vod, 2480, ''), 'Fortnite! at 41:20', 'a guessed gender is a look');
assert.strictEqual(publicTitle('Woman Laughs At Chat', vod, 2480, ''), 'Fortnite! at 41:20', 'a guessed gender is a look');
assert.strictEqual(captionTitle('A naked man lies on a bed near a window.'), null);
assert.strictEqual(captionTitle('A naked man lies on a bed near a window.'), null);
+// Body words too: clip 488 was AI-titled "Boobs by Bobs Chalk Talk" from a sign in the stream (2026-10-25).
+assert.strictEqual(publicTitle('Boobs by Bobs Chalk Talk', vod, 2480, ''), 'Fortnite! at 41:20');
+assert.strictEqual(publicTitle('Sexy Dance Break', vod, 2480, ''), 'Fortnite! at 41:20');
+assert.strictEqual(publicTitle('Thighs and Cleavage Cam', vod, 2480, ''), 'Fortnite! at 41:20');
+assert.strictEqual(publicTitle('Butt Slide Fail', vod, 2480, ''), 'Fortnite! at 41:20');
+assert.strictEqual(publicTitle('Kick-Ass Combo Finish', vod, 60, ''), 'Kick-Ass Combo Finish', 'not every rough word is a body word');
assert.strictEqual(publicTitle('Headphones Hit a Revelation', { title: 'A little couch surfing before the store.' }, 61, ''), 'A little couch surfing before the store at 1:01');
assert.strictEqual(publicTitle('Headphones Hit a Revelation', { title: 'A little couch surfing before the store.' }, 61, ''), 'A little couch surfing before the store at 1:01');
assert.strictEqual(publicTitle('', { title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");
assert.strictEqual(publicTitle('', { title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");
process.stdout.write('moment fallback title: all checks passed\n');
process.stdout.write('moment fallback title: all checks passed\n');