openvibers/openvibe.live Commits
Public AI text: no clothing, hair or face either; looks-titles fall back to stream title + time (#202)
Changes
@@@ -58,7 +58,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|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 LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|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 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();@@@ -67,10 +67,16 @@ function _captionTitle(desc) {if (s.split(' ').length < 3 || LOOKS_WORDS.test(s)) return null;return s.replace(/\b\w/g, (ch) => ch.toUpperCase()).slice(0, 70);}++// An AI-written title is used unless it is about looks ("Red Beanie Moment", "Glasses Adjustment Tension"); then the++// fallback above.++function _publicTitle(title, vod, offset, desc) {++ const t = _cleanTitle(title);++ return t && !LOOKS_WORDS.test(t) ? t : _fallbackTitle(vod, offset, desc);++}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');++ const base = _cleanText(vod && vod.title, 60).replace(/[.,;:\s]+$/, '') || (vod && vod.username ? `${vod.username}'s stream` : 'Stream');return `${base} at ${_mmss(offset)}`.slice(0, 80);}@@@ -192,7 +198,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) || _fallbackTitle(vod, t, j.desc), desc: _cleanText(_deJson(j.desc), 400) || (near && _deJson(near.description)) || '', tags: _memTags(near) };++ const result = { offset: t, title: _publicTitle(j.title, vod, t, j.desc), desc: db.noLooks(_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 */ }@@@ -208,7 +214,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {if (!candidates.length) return null;let offset = clamp(candidates[0].t);const near = _nearestMemory(ctx.memories, offset);-- const desc = near ? _deJson(near.description) : '';++ const desc = near ? db.noLooks(_deJson(near.description)) : '';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@@@ -382,7 +388,7 @@ async function tick(opts = {}) {try {const vis = await ai.analyzeImagePaste(outPath, title, 'moment_frame');if (vis && vis.description && vis.description.length > 25) {-- desc = _cleanText(vis.description, 500);++ desc = db.noLooks(_cleanText(vis.description, 500));if (Array.isArray(vis.tags) && vis.tags.length) tags = vis.tags.slice(0, 8);if (!moment.title) title = _fallbackTitle(v, offset, desc);}@@@ -486,7 +492,7 @@ function start() {setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);}--module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle } };++module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _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@@@ -133,11 +133,11 @@ async function _checkLiveStream(stream) {const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));if (end - start < 5) return; // not enough flushed footage around the moment yet-- const title = verdict.title || 'Chat-Hyped Moment';++ const title = verdict.title ? require('./ai-moments-job').publicTitle(verdict.title, stream, momentOffset, verdict.desc) : 'Chat-Hyped Moment';const clip = await media.createClip({vod_id: rec.vodId, start_s: start, end_s: end,title, user_id: stream.user_id,-- stream_id: streamId, auto_generated: true, description: verdict.desc || '',++ stream_id: streamId, auto_generated: true, description: db.noLooks(verdict.desc || ''),}).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@@@ -287,7 +287,7 @@ async function clipVodMoment(o) {const clip = await media.createClip({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 || '',++ stream_id: vod.stream_id || undefined, auto_generated: true, description: db.noLooks(desc || ''),});if (clip) await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(desc || title) });return clip || null;@@@ -126,7 +126,7 @@ async function tick() {}if (!r || !r.description) continue;await require('../pastes-client').setPasteAi(p.slug, {-- ai_summary: r.description,++ ai_summary: db.noLooks(r.description),ai_tags: JSON.stringify(r.tags || []),}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));console.log(`[AI backfill] paste ${p.slug} summarized`);@@@ -198,18 +198,21 @@ async function _maybeLivePaste(stream, image, r, offset) {try { buffer = Buffer.isBuffer(image) ? image : (typeof image === 'string' && image.startsWith('data:') ? Buffer.from(image.split(',')[1] || '', 'base64') : require('fs').readFileSync(image)); } catch { return; }if (!buffer || buffer.length < 2000) return;const username = stream.username || (await db.getUserById(stream.user_id) || {}).username || 'someone';++ // Public text: nothing about how anyone looks, in the page or its title.++ const desc = db.noLooks(r.description);++ const title = moments.publicTitle ? moments.publicTitle(r.title, { title: stream.title, username }, offset, desc) : r.title.slice(0, 80);let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }// AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.const paste = await require('../pastes-client').createPaste({slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,-- title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/@${encodeURIComponent(username)}`, language: 'text', visibility: 'public',++ title, content: `${desc}\n\n🔴 Caught live on @${username}'s stream — ${base}/@${encodeURIComponent(username)}`, language: 'text', visibility: 'public',stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),-- ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),++ ai_summary: desc, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },}, { origin: 'ai' });if (paste) {await registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });-- console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${r.title}"`);++ console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${title}"`);}}@@@ -394,9 +394,30 @@ const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);++// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",++// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted++// speech ("Heard: "I like your hair"") is what was said and stays word for word.++const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';++const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';++const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';++const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds|earphones|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?`;++const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:a|an)\\s+(?:[\\w'-]+\\s+){0,2}?(?:zipper|logo|print|pattern|design|graphic|hood|stripes?|collar|text))?`;++const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');++const _IN_WORN = new RegExp(`\\s+in\\s+(?:a|an|the|his|her|their)\\s+(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN})\\b)*`, 'gi');++const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');++function _noClothing(text) {++ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part++ .replace(/\b[\w-]+-wearing\s+/gi, '')++ .replace(_WEARING, '')++ .replace(_IN_WORN, '')++ .replace(_WITH_FACE, '')++ .replace(/\s+([,.;:!?])/g, '$1')++ .replace(/,(?=[.;:!?])/g, '')++ .replace(/ {2,}/g, ' '))).join('');++}function _noLooks(text) {if (!text || typeof text !== 'string') return text;-- return text++ return _noClothing(text)// Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer..replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m)).replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))@@@ -665,7 +686,7 @@ async function cleanupMalformedAiText() {} catch { /* */ }// Frame notes and overviews that describe how a person looks (stored before the// prompts forbade it): the person becomes "the streamer" and the card line follows.-- const looks = "~* '(shirtless|topless|bare-chested|young|teen|little|bearded|bald|blond|haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute|boy|girl|kid|child|youth)'";++ const looks = "~* '(shirtless|topless|bare-chested|young|teen|little|bearded|bald|blond|haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute|boy|girl|kid|child|youth)|\\m(wear|dressed|sporting|shirt|t-shirt|tee|tank top|hoodie|sweat|jacket|coat|vest|beanie|cap|hat|helmet|headset|headphones|sunglasses|glasses|outfit|dress|jersey|suit|robe|bandana|scar|mask|attire|apparel|beard|mustache|moustache|goatee|stubble|tattoo|hair|ponytail|braids|dreadlocks|piercing|freckles|makeup|[a-z-]+-wearing)'";for (const [table, key, col, short] of [['stream_memories', 'id', 'description', null], ['streams', 'id', 'ai_overview', 'ai_overview_short'],['vod_ai_state', 'vod_id', 'ai_overview', 'ai_overview_short'], ['clip_ai_state', 'clip_id', 'ai_overview', 'ai_overview_short'],['streamer_overviews', 'user_id', 'overview', 'overview_short']]) {@@@ -3529,7 +3550,7 @@ module.exports = {setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,-- cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,++ cleanupMalformedAiText, noLooks: _noLooks, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,@@@ -22,6 +22,18 @@ const db = require('../server/db/database');['A man is shirtless and appears to be talking to chat.', 'A man appears to be talking to chat.'],['A person watches TV while a child sits nearby.', 'A person watches TV while someone sits nearby.'],['The girl waves at the camera.', 'The person waves at the camera.'],++ // Clothing, hair and face go; the rest of the sentence stays.++ ['The streamer wearing a red beanie speaks to the camera outdoors, seemingly addressing chat.', 'The streamer speaks to the camera outdoors, seemingly addressing chat.'],++ ['The view is close to the camera while wearing a black beanie and hoodie, with trees around.', 'The view is close to the camera, with trees around.'],++ ['A man in a gray tank top sits against a plain light background.', 'A man sits against a plain light background.'],++ ['A man with a long beard and plaid shirt examines a woven item.', 'A man examines a woven item.'],++ ['A kitchen studio with a headset-wearing host, microphone, and on-screen graphics.', 'A kitchen studio with a host, microphone, and on-screen graphics.'],++ ['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],++ ['A man wearing sunglasses on his head looks down at the camera.', 'A man looks down at the camera.'],++ ['A partial view of a person wearing a gray zip-up jacket with a white zipper, seated indoors.', 'A partial view of a person, seated indoors.'],++ ['Close-up of a person wearing a yellow vest over a gray knit top, with a backpack visible.', 'Close-up of a person, with a backpack visible.'],++ ['A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.', 'A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.'],++ ['The streamer laughs — heard: "Put your headset on. I like your hair."', 'The streamer laughs — heard: "Put your headset on. I like your hair."'],];let off = 0;for (const [input, want] of cases) {@@@ -38,7 +50,9 @@ const db = require('../server/db/database');await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9000, 'A shirtless man rests his chin on his hand.']);await db.run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', ['A young boy plays Fortnite.', 'A young boy plays Fortnite.', sid]);await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9060, 'An older version of the game loads.']);++ await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9120, 'A man wearing headphones sits at a desk.']);await db.cleanupMalformedAiText();++ assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9120')).description, 'A man sits at a desk.');assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9000')).description, 'The streamer rests his chin on his hand.');assert.deepStrictEqual(Object.values(await db.get('SELECT ai_overview, ai_overview_short FROM streams WHERE id = ?', [sid])), ['The streamer plays Fortnite.', 'The streamer plays Fortnite.']);assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9060')).description, 'An older version of the game loads.', 'looks words about things stay');@@@ -24,5 +24,11 @@ assert.strictEqual(fallbackTitle(vod, 2480, 'The Minecraft main menu is displayeassert.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');++// An AI title is kept unless it is about looks.++const { publicTitle } = require('../server/ai/ai-moments-job');++assert.strictEqual(publicTitle('Boss Fight Comeback', vod, 60, ''), 'Boss Fight Comeback');++assert.strictEqual(publicTitle('Red Beanie Moment', vod, 2480, 'A man wearing a red beanie looks up.'), 'Fortnite! at 41:20');++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');process.exit(0);
@@ -58,7 +58,7 @@ function _deJson(desc) {
@@ -58,7 +58,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|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 LOOKS_WORDS = /\b(wearing|wears|dressed|beard(ed)?|mustache|glasses|sunglasses|jacket|hoodie|sweater|vest|shirt|shirtless|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 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;
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) {
function _captionTitle(desc) {
let s = _cleanText(desc).split(/[.!?;:]|,\s(?=(?:with|and|as|while|near|showing|likely)\b)/i)[0].trim();
let s = _cleanText(desc).split(/[.!?;:]|,\s(?=(?:with|and|as|while|near|showing|likely)\b)/i)[0].trim();
@@ -67,10 +67,16 @@ function _captionTitle(desc) {
@@ -67,10 +67,16 @@ function _captionTitle(desc) {
if (s.split(' ').length < 3 || LOOKS_WORDS.test(s)) return null;
if (s.split(' ').length < 3 || LOOKS_WORDS.test(s)) return null;
return s.replace(/\b\w/g, (ch) => ch.toUpperCase()).slice(0, 70);
return s.replace(/\b\w/g, (ch) => ch.toUpperCase()).slice(0, 70);
}
}
+// An AI-written title is used unless it is about looks ("Red Beanie Moment", "Glasses Adjustment Tension"); then the
+// fallback above.
+function _publicTitle(title, vod, offset, desc) {
+ const t = _cleanTitle(title);
+ return t && !LOOKS_WORDS.test(t) ? t : _fallbackTitle(vod, offset, desc);
+}
function _fallbackTitle(vod, offset, desc) {
function _fallbackTitle(vod, offset, desc) {
const fromCaption = desc ? _captionTitle(desc) : null;
const fromCaption = desc ? _captionTitle(desc) : null;
if (fromCaption) return fromCaption;
if (fromCaption) return fromCaption;
- const base = _cleanText(vod && vod.title, 60) || (vod && vod.username ? `${vod.username}'s stream` : 'Stream');
+ const base = _cleanText(vod && vod.title, 60).replace(/[.,;:\s]+$/, '') || (vod && vod.username ? `${vod.username}'s stream` : 'Stream');
return `${base} at ${_mmss(offset)}`.slice(0, 80);
return `${base} at ${_mmss(offset)}`.slice(0, 80);
}
}
@@ -192,7 +198,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
@@ -192,7 +198,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) || _fallbackTitle(vod, t, j.desc), desc: _cleanText(_deJson(j.desc), 400) || (near && _deJson(near.description)) || '', tags: _memTags(near) };
+ const result = { offset: t, title: _publicTitle(j.title, vod, t, j.desc), desc: db.noLooks(_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 */ }
@@ -208,7 +214,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
@@ -208,7 +214,7 @@ async function _findBestMoment(vod, { flavor = 'paste', avoid = [] } = {}) {
if (!candidates.length) return null;
if (!candidates.length) return null;
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 ? db.noLooks(_deJson(near.description)) : '';
return _isEmptyScene(desc) ? null : { offset, title: _fallbackTitle(vod, offset, 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
@@ -382,7 +388,7 @@ async function tick(opts = {}) {
@@ -382,7 +388,7 @@ async function tick(opts = {}) {
try {
try {
const vis = await ai.analyzeImagePaste(outPath, title, 'moment_frame');
const vis = await ai.analyzeImagePaste(outPath, title, 'moment_frame');
if (vis && vis.description && vis.description.length > 25) {
if (vis && vis.description && vis.description.length > 25) {
- desc = _cleanText(vis.description, 500);
+ desc = db.noLooks(_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 = _fallbackTitle(v, offset, desc);
if (!moment.title) title = _fallbackTitle(v, offset, desc);
}
}
@@ -486,7 +492,7 @@ function start() {
@@ -486,7 +492,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, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle } };
+module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _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
@@ -133,11 +133,11 @@ async function _checkLiveStream(stream) {
@@ -133,11 +133,11 @@ async function _checkLiveStream(stream) {
const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));
const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));
if (end - start < 5) return; // not enough flushed footage around the moment yet
if (end - start < 5) return; // not enough flushed footage around the moment yet
- const title = verdict.title || 'Chat-Hyped Moment';
+ const title = verdict.title ? require('./ai-moments-job').publicTitle(verdict.title, stream, momentOffset, verdict.desc) : 'Chat-Hyped Moment';
const clip = await media.createClip({
const clip = await media.createClip({
vod_id: rec.vodId, start_s: start, end_s: end,
vod_id: rec.vodId, start_s: start, end_s: end,
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: db.noLooks(verdict.desc || ''),
}).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
@@ -287,7 +287,7 @@ async function clipVodMoment(o) {
@@ -287,7 +287,7 @@ async function clipVodMoment(o) {
const clip = await media.createClip({
const clip = await media.createClip({
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: db.noLooks(desc || ''),
});
});
if (clip) await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(desc || title) });
if (clip) await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(desc || title) });
return clip || null;
return clip || null;
@@ -126,7 +126,7 @@ async function tick() {
@@ -126,7 +126,7 @@ async function tick() {
}
}
if (!r || !r.description) continue;
if (!r || !r.description) continue;
await require('../pastes-client').setPasteAi(p.slug, {
await require('../pastes-client').setPasteAi(p.slug, {
- ai_summary: r.description,
+ ai_summary: db.noLooks(r.description),
ai_tags: JSON.stringify(r.tags || []),
ai_tags: JSON.stringify(r.tags || []),
}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));
}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));
console.log(`[AI backfill] paste ${p.slug} summarized`);
console.log(`[AI backfill] paste ${p.slug} summarized`);
@@ -198,18 +198,21 @@ async function _maybeLivePaste(stream, image, r, offset) {
@@ -198,18 +198,21 @@ async function _maybeLivePaste(stream, image, r, offset) {
try { buffer = Buffer.isBuffer(image) ? image : (typeof image === 'string' && image.startsWith('data:') ? Buffer.from(image.split(',')[1] || '', 'base64') : require('fs').readFileSync(image)); } catch { return; }
try { buffer = Buffer.isBuffer(image) ? image : (typeof image === 'string' && image.startsWith('data:') ? Buffer.from(image.split(',')[1] || '', 'base64') : require('fs').readFileSync(image)); } catch { return; }
if (!buffer || buffer.length < 2000) return;
if (!buffer || buffer.length < 2000) return;
const username = stream.username || (await db.getUserById(stream.user_id) || {}).username || 'someone';
const username = stream.username || (await db.getUserById(stream.user_id) || {}).username || 'someone';
+ // Public text: nothing about how anyone looks, in the page or its title.
+ const desc = db.noLooks(r.description);
+ const title = moments.publicTitle ? moments.publicTitle(r.title, { title: stream.title, username }, offset, desc) : r.title.slice(0, 80);
let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }
let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }
// AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.
// AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.
const paste = await require('../pastes-client').createPaste({
const paste = await require('../pastes-client').createPaste({
slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,
slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,
- title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/@${encodeURIComponent(username)}`, language: 'text', visibility: 'public',
+ title, content: `${desc}\n\n🔴 Caught live on @${username}'s stream — ${base}/@${encodeURIComponent(username)}`, language: 'text', visibility: 'public',
stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),
stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),
- ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),
+ ai_summary: desc, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),
screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },
screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },
}, { origin: 'ai' });
}, { origin: 'ai' });
if (paste) {
if (paste) {
await registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });
await registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });
- console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${r.title}"`);
+ console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${title}"`);
}
}
}
}
@@ -394,9 +394,30 @@ const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
@@ -394,9 +394,30 @@ const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
+// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",
+// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted
+// speech ("Heard: "I like your hair"") is what was said and stays word for word.
+const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
+const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
+const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
+const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds|earphones|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?`;
+const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:a|an)\\s+(?:[\\w'-]+\\s+){0,2}?(?:zipper|logo|print|pattern|design|graphic|hood|stripes?|collar|text))?`;
+const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
+const _IN_WORN = new RegExp(`\\s+in\\s+(?:a|an|the|his|her|their)\\s+(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN})\\b)*`, 'gi');
+const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
+function _noClothing(text) {
+ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
+ .replace(/\b[\w-]+-wearing\s+/gi, '')
+ .replace(_WEARING, '')
+ .replace(_IN_WORN, '')
+ .replace(_WITH_FACE, '')
+ .replace(/\s+([,.;:!?])/g, '$1')
+ .replace(/,(?=[.;:!?])/g, '')
+ .replace(/ {2,}/g, ' '))).join('');
+}
function _noLooks(text) {
function _noLooks(text) {
if (!text || typeof text !== 'string') return text;
if (!text || typeof text !== 'string') return text;
- return text
+ return _noClothing(text)
// Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
// Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
.replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
.replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
.replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
.replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
@@ -665,7 +686,7 @@ async function cleanupMalformedAiText() {
@@ -665,7 +686,7 @@ async function cleanupMalformedAiText() {
} catch { /* */ }
} catch { /* */ }
// Frame notes and overviews that describe how a person looks (stored before the
// Frame notes and overviews that describe how a person looks (stored before the
// prompts forbade it): the person becomes "the streamer" and the card line follows.
// prompts forbade it): the person becomes "the streamer" and the card line follows.
- const looks = "~* '(shirtless|topless|bare-chested|young|teen|little|bearded|bald|blond|haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute|boy|girl|kid|child|youth)'";
+ const looks = "~* '(shirtless|topless|bare-chested|young|teen|little|bearded|bald|blond|haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute|boy|girl|kid|child|youth)|\\m(wear|dressed|sporting|shirt|t-shirt|tee|tank top|hoodie|sweat|jacket|coat|vest|beanie|cap|hat|helmet|headset|headphones|sunglasses|glasses|outfit|dress|jersey|suit|robe|bandana|scar|mask|attire|apparel|beard|mustache|moustache|goatee|stubble|tattoo|hair|ponytail|braids|dreadlocks|piercing|freckles|makeup|[a-z-]+-wearing)'";
for (const [table, key, col, short] of [['stream_memories', 'id', 'description', null], ['streams', 'id', 'ai_overview', 'ai_overview_short'],
for (const [table, key, col, short] of [['stream_memories', 'id', 'description', null], ['streams', 'id', 'ai_overview', 'ai_overview_short'],
['vod_ai_state', 'vod_id', 'ai_overview', 'ai_overview_short'], ['clip_ai_state', 'clip_id', 'ai_overview', 'ai_overview_short'],
['vod_ai_state', 'vod_id', 'ai_overview', 'ai_overview_short'], ['clip_ai_state', 'clip_id', 'ai_overview', 'ai_overview_short'],
['streamer_overviews', 'user_id', 'overview', 'overview_short']]) {
['streamer_overviews', 'user_id', 'overview', 'overview_short']]) {
@@ -3529,7 +3550,7 @@ module.exports = {
@@ -3529,7 +3550,7 @@ module.exports = {
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
- cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
+ cleanupMalformedAiText, noLooks: _noLooks, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
@@ -22,6 +22,18 @@ const db = require('../server/db/database');
@@ -22,6 +22,18 @@ const db = require('../server/db/database');
['A man is shirtless and appears to be talking to chat.', 'A man appears to be talking to chat.'],
['A man is shirtless and appears to be talking to chat.', 'A man appears to be talking to chat.'],
['A person watches TV while a child sits nearby.', 'A person watches TV while someone sits nearby.'],
['A person watches TV while a child sits nearby.', 'A person watches TV while someone sits nearby.'],
['The girl waves at the camera.', 'The person waves at the camera.'],
['The girl waves at the camera.', 'The person waves at the camera.'],
+ // Clothing, hair and face go; the rest of the sentence stays.
+ ['The streamer wearing a red beanie speaks to the camera outdoors, seemingly addressing chat.', 'The streamer speaks to the camera outdoors, seemingly addressing chat.'],
+ ['The view is close to the camera while wearing a black beanie and hoodie, with trees around.', 'The view is close to the camera, with trees around.'],
+ ['A man in a gray tank top sits against a plain light background.', 'A man sits against a plain light background.'],
+ ['A man with a long beard and plaid shirt examines a woven item.', 'A man examines a woven item.'],
+ ['A kitchen studio with a headset-wearing host, microphone, and on-screen graphics.', 'A kitchen studio with a host, microphone, and on-screen graphics.'],
+ ['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],
+ ['A man wearing sunglasses on his head looks down at the camera.', 'A man looks down at the camera.'],
+ ['A partial view of a person wearing a gray zip-up jacket with a white zipper, seated indoors.', 'A partial view of a person, seated indoors.'],
+ ['Close-up of a person wearing a yellow vest over a gray knit top, with a backpack visible.', 'Close-up of a person, with a backpack visible.'],
+ ['A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.', 'A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.'],
+ ['The streamer laughs — heard: "Put your headset on. I like your hair."', 'The streamer laughs — heard: "Put your headset on. I like your hair."'],
];
];
let off = 0;
let off = 0;
for (const [input, want] of cases) {
for (const [input, want] of cases) {
@@ -38,7 +50,9 @@ const db = require('../server/db/database');
@@ -38,7 +50,9 @@ const db = require('../server/db/database');
await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9000, 'A shirtless man rests his chin on his hand.']);
await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9000, 'A shirtless man rests his chin on his hand.']);
await db.run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', ['A young boy plays Fortnite.', 'A young boy plays Fortnite.', sid]);
await db.run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', ['A young boy plays Fortnite.', 'A young boy plays Fortnite.', sid]);
await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9060, 'An older version of the game loads.']);
await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9060, 'An older version of the game loads.']);
+ await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9120, 'A man wearing headphones sits at a desk.']);
await db.cleanupMalformedAiText();
await db.cleanupMalformedAiText();
+ assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9120')).description, 'A man sits at a desk.');
assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9000')).description, 'The streamer rests his chin on his hand.');
assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9000')).description, 'The streamer rests his chin on his hand.');
assert.deepStrictEqual(Object.values(await db.get('SELECT ai_overview, ai_overview_short FROM streams WHERE id = ?', [sid])), ['The streamer plays Fortnite.', 'The streamer plays Fortnite.']);
assert.deepStrictEqual(Object.values(await db.get('SELECT ai_overview, ai_overview_short FROM streams WHERE id = ?', [sid])), ['The streamer plays Fortnite.', 'The streamer plays Fortnite.']);
assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9060')).description, 'An older version of the game loads.', 'looks words about things stay');
assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9060')).description, 'An older version of the game loads.', 'looks words about things stay');
@@ -24,5 +24,11 @@ assert.strictEqual(fallbackTitle(vod, 2480, 'The Minecraft main menu is displaye
@@ -24,5 +24,11 @@ assert.strictEqual(fallbackTitle(vod, 2480, 'The Minecraft main menu is displaye
assert.strictEqual(fallbackTitle({ title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");
assert.strictEqual(fallbackTitle({ title: '', username: 'goosely' }, 75, ''), "goosely's stream at 1:15");
assert.strictEqual(fallbackTitle({}, 0), 'Stream at 0:00');
assert.strictEqual(fallbackTitle({}, 0), 'Stream at 0:00');
assert.ok(fallbackTitle({ title: 'x'.repeat(200) }, 59).length <= 80, 'at most 80 characters');
assert.ok(fallbackTitle({ title: 'x'.repeat(200) }, 59).length <= 80, 'at most 80 characters');
+// An AI title is kept unless it is about looks.
+const { publicTitle } = require('../server/ai/ai-moments-job');
+assert.strictEqual(publicTitle('Boss Fight Comeback', vod, 60, ''), 'Boss Fight Comeback');
+assert.strictEqual(publicTitle('Red Beanie Moment', vod, 2480, 'A man wearing a red beanie looks up.'), 'Fortnite! at 41:20');
+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');
process.stdout.write('moment fallback title: all checks passed\n');
process.exit(0);
process.exit(0);