openvibers/openvibe.live Commits

AI moments and auto clips: without an AI title, the stream's title and the time ('Fortnite! at 41:20'), not a frame caption's first words ('A Man With A Beard Is Speaking To') (#201)

OP OpenVibers committed 27 days ago · c70583f96d · 2 files, +51 −13

* AI moments and auto clips: without an AI title, the stream's title and the time ('Fortnite! at 41:20'), not a frame caption's first words ('A Man With A Beard Is Speaking To')

* Fallback titles keep a caption's clause when it reads as a title (no dangling word, nothing about looks); otherwise the stream title and time

Changes

ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 5d36d878..566908ad 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -54,14 +54,24 @@ function _deJson(desc) {
}
return s;
}
--// A specific title from a description (fallback when AI titling is unavailable) — the first
--// meaningful clause, Title-Cased, so we never fall back to a generic/duplicate stream title.
--function _titleFromDesc(desc) {
-- let s = _cleanText(desc);
-- if (!s) return 'A wild moment';
-- s = s.split(/[.!?;:]|,\s(?=(?:with|and|as|while|near|showing)\b)/i)[0].trim();
-- const out = s.split(' ').filter(Boolean).slice(0, 8).join(' ').replace(/[,\s]+$/, '').replace(/\b\w/g, ch => ch.toUpperCase());
-- return out.slice(0, 70) || 'A wild moment';
++// The title when the AI gives none. A frame caption's first clause, when it reads as a title: never cut off on 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).
++// 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|shirt|shirtless|tank top|t-shirt|beanie|cap|hat|helmet|headset|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 DANGLING = /\s+(to|in|with|a|an|of|and|the|on|at|is|are|was|from|for|by|near|while|as|or|into|onto|its|his|her|their)$/i;
++function _captionTitle(desc) {
++ let s = _cleanText(desc).split(/[.!?;:]|,\s(?=(?:with|and|as|while|near|showing|likely)\b)/i)[0].trim();
++ s = s.split(' ').filter(Boolean).slice(0, 9).join(' ').replace(/[,\s]+$/, '');
++ while (DANGLING.test(s)) s = s.replace(DANGLING, '');
++ if (s.split(' ').length < 3 || LOOKS_WORDS.test(s)) return null;
++ return s.replace(/\b\w/g, (ch) => ch.toUpperCase()).slice(0, 70);
++}
++function _fallbackTitle(vod, offset, desc) {
++ const fromCaption = desc ? _captionTitle(desc) : null;
++ if (fromCaption) return fromCaption;
++ const base = _cleanText(vod && vod.title, 60) || (vod && vod.username ? `${vod.username}'s stream` : 'Stream');
++ return `${base} at ${_mmss(offset)}`.slice(0, 80);
}
// ── Stage 1: rank VODs by their AI overview ─────────────────────────────────────────────
@@@ -182,7 +192,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
t = clamp(t);
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: _cleanTitle(j.title) || _titleFromDesc(j.desc), desc: _cleanText(_deJson(j.desc), 400) || (near && _deJson(near.description)) || '', tags: _memTags(near) };
++ const result = { offset: t, title: _cleanTitle(j.title) || _fallbackTitle(vod, t, j.desc), desc: _cleanText(_deJson(j.desc), 400) || (near && _deJson(near.description)) || '', tags: _memTags(near) };
return _isEmptyScene(result.desc) ? null : result;
}
} catch { /* fall through to signal-based pick */ }
@@@ -199,7 +209,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
let offset = clamp(candidates[0].t);
const near = _nearestMemory(ctx.memories, offset);
const desc = near ? _deJson(near.description) : '';
-- return _isEmptyScene(desc) ? null : { offset, title: _titleFromDesc(desc), desc, tags: _memTags(near) };
++ 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
// never turn "nothing" into a clip or paste.
@@@ -341,7 +351,7 @@ async function tick(opts = {}) {
if (sig) thisSigs.add(sig);
const offset = Math.floor(moment.offset || 0);
let desc = moment.desc || '';
-- let title = moment.title || _titleFromDesc(desc) || v.title;
++ let title = moment.title || _fallbackTitle(v, offset, desc);
let tags = moment.tags || [];
const vodPath = `/vod/${v.vod_id}?t=${offset}`;
@@@ -374,7 +384,7 @@ async function tick(opts = {}) {
if (vis && vis.description && vis.description.length > 25) {
desc = _cleanText(vis.description, 500);
if (Array.isArray(vis.tags) && vis.tags.length) tags = vis.tags.slice(0, 8);
-- if (!moment.title) title = _titleFromDesc(desc);
++ if (!moment.title) title = _fallbackTitle(v, offset, desc);
}
} catch { /* keep the moment desc */ }
}
@@@ -476,7 +486,7 @@ function start() {
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
}
--module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, _internals: { momentPool: _momentPool } };
++module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle } };
// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
ddiff --git a/test/moment-fallback-title.test.js b/test/moment-fallback-title.test.js
nnew file mode 100644
iindex 00000000..71154845
---- /dev/null
++++ b/test/moment-fallback-title.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++/**
++ * The title an auto clip or AI moment gets when the AI gives none (server/ai/ai-moments-job.js). A frame caption's
++ * first clause titled public clips "A Man With A Beard Is Speaking To" (cut off on a dangling word, and about how
++ * someone looks). Now: the caption's clause only when it reads as a title and says nothing about looks; otherwise
++ * the stream's own title and the moment's time.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/moment-fallback-title.test.js
++ */
++const assert = require('assert');
++console.warn = () => {};
++console.log = () => {};
++
++const { fallbackTitle, captionTitle } = require('../server/ai/ai-moments-job')._internals;
++// A clean caption keeps its words, without the dangling end.
++assert.strictEqual(captionTitle('A car racing game appears on screen with a speedometer.'), 'A Car Racing Game Appears On Screen');
++assert.strictEqual(captionTitle('A person is assembling a small robotic device on a desk.'), 'A Person Is Assembling A Small Robotic Device');
++// Looks are never a title.
++const looks = ['A man with a beard is speaking to the camera.', 'A person in a bright yellow high-visibility jacket.', 'A man with a puzzled expression sits in a chair.', 'A young boy plays a game.'];
++looks.forEach((c) => assert.strictEqual(captionTitle(c), null, c));
++const vod = { title: 'Fortnite!' };
++assert.strictEqual(fallbackTitle(vod, 2480, 'A man wearing a headset sits in a gaming chair.'), 'Fortnite! at 41:20');
++assert.strictEqual(fallbackTitle(vod, 2480, 'The Minecraft main menu is displayed with a panorama.'), 'The Minecraft Main Menu Is Displayed With A Panorama', 'a complete clause is kept whole');
++assert.strictEqual(fallbackTitle({ title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");
++assert.strictEqual(fallbackTitle({}, 0), 'Stream at 0:00');
++assert.ok(fallbackTitle({ title: 'x'.repeat(200) }, 59).length <= 80, 'at most 80 characters');
++process.stdout.write('moment fallback title: all checks passed\n');
++process.exit(0);