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)
Changes
@@@ -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=2nnew file mode 100644@@@ -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);
@@ -54,14 +54,24 @@ function _deJson(desc) {
@@ -54,14 +54,24 @@ function _deJson(desc) {
}
}
return s;
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 ─────────────────────────────────────────────
// ── Stage 1: rank VODs by their AI overview ─────────────────────────────────────────────
@@ -182,7 +192,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
@@ -182,7 +192,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
t = clamp(t);
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'); }
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 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;
return _isEmptyScene(result.desc) ? null : result;
}
}
} catch { /* fall through to signal-based pick */ }
} catch { /* fall through to signal-based pick */ }
@@ -199,7 +209,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
@@ -199,7 +209,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
let offset = clamp(candidates[0].t);
let offset = clamp(candidates[0].t);
const near = _nearestMemory(ctx.memories, offset);
const near = _nearestMemory(ctx.memories, offset);
const desc = near ? _deJson(near.description) : '';
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
// True when a description says the frame is essentially empty — black/dark/loading/blank — so we
// never turn "nothing" into a clip or paste.
// never turn "nothing" into a clip or paste.
@@ -341,7 +351,7 @@ async function tick(opts = {}) {
@@ -341,7 +351,7 @@ async function tick(opts = {}) {
if (sig) thisSigs.add(sig);
if (sig) thisSigs.add(sig);
const offset = Math.floor(moment.offset || 0);
const offset = Math.floor(moment.offset || 0);
let desc = moment.desc || '';
let desc = moment.desc || '';
- let title = moment.title || _titleFromDesc(desc) || v.title;
+ let title = moment.title || _fallbackTitle(v, offset, desc);
let tags = moment.tags || [];
let tags = moment.tags || [];
const vodPath = `/vod/${v.vod_id}?t=${offset}`;
const vodPath = `/vod/${v.vod_id}?t=${offset}`;
@@ -374,7 +384,7 @@ async function tick(opts = {}) {
@@ -374,7 +384,7 @@ async function tick(opts = {}) {
if (vis && vis.description && vis.description.length > 25) {
if (vis && vis.description && vis.description.length > 25) {
desc = _cleanText(vis.description, 500);
desc = _cleanText(vis.description, 500);
if (Array.isArray(vis.tags) && vis.tags.length) tags = vis.tags.slice(0, 8);
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 */ }
} catch { /* keep the moment desc */ }
}
}
@@ -476,7 +486,7 @@ function start() {
@@ -476,7 +486,7 @@ function start() {
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
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:
// 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
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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);