openvibers/openvibe.live Commits

Public AI text: no clothing, hair or face either; looks-titles fall back to stream title + time (#202)

OP OpenVibers committed 27 days ago · 681de23898 · 7 files, +66 −16

* Public AI text: clothing, hair and face are scrubbed too ('wearing a red beanie', 'in a gray tank top', 'with a long beard and plaid shirt'), and AI moment titles about looks ('Red Beanie Moment') fall back to the stream title and time

Frame notes written after the 10-16 prompt rules still named clothing in 29 of 252 rows, and four channel
descriptions generated on 10-17 copied it ('in a red shirt', 'a backward cap and sunglasses'). The write-time
scrub now removes those phrases outside quoted speech, the boot repair re-cleans older rows, and live pastes,
auto clips and paste summaries pass through it.

* No-looks scrub: 'wearing sunglasses on his head' and 'a vest over a knit top' go whole (a dry run over Community's AI pages left 'A man on his head')

* No-looks scrub: a backpack is not something someone looks like ('a person, with a backpack visible' keeps the backpack)

* No-looks scrub: a garment's detail goes with it ('a jacket with a white zipper')

* Moment titles: 'headphones' is a looks word too, and a stream title's closing period is dropped before ' at 1:01'

Changes

ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 566908ad..72cfb44e 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex 6433bca6..f0313ed5 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -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;
ddiff --git a/server/ai/backfill-job.js b/server/ai/backfill-job.js
iindex 0bf1c661..2a99def9 100644
---- a/server/ai/backfill-job.js
++++ b/server/ai/backfill-job.js
@@@ -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`);
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex 5afb3262..e47b89e3 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -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}"`);
}
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 4276d5c4..74c7a6b6 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/test/ai-text-no-looks.test.js b/test/ai-text-no-looks.test.js
iindex 18157342..0911e2bf 100644
---- a/test/ai-text-no-looks.test.js
++++ b/test/ai-text-no-looks.test.js
@@@ -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');
ddiff --git a/test/moment-fallback-title.test.js b/test/moment-fallback-title.test.js
iindex 71154845..62ae9519 100644
---- a/test/moment-fallback-title.test.js
++++ b/test/moment-fallback-title.test.js
@@@ -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({}, 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);