openvibers/openvibe.live Commits

Clips get names: the AI names a clip nobody named, and the studio asks right after Clip (Name it / Skip) (#363)

OP OpenVibers committed 19 days ago · e93e4c1982 · 14 files, +463 −29

The studio's clip code called an undefined showToast, so a streamer's clip was made with no notice, no cut
follow-up and no way to name it. Now a clip made without a title opens a card at the top: a name box, Save name,
and Skip (the AI names it). Skip, Escape or 20 s untouched close it. A clip still named by a placeholder is titled
from its AI overview through live.stream.titles; looks and rough titles are replaced, and a person's title always
wins (migration 0019: clip_ai_state.title_status). The chat announcement waits up to 3 min for the AI title.


Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex c94179aa..3b2598f0 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -63,6 +63,18 @@ Viewers can create clips from live streams:
Clips are **unlisted by default** — the stream owner can publish or delete them from the dashboard.
++### Naming a clip
++
++Naming is optional everywhere. Right after a clip is made without a name (the phone studio's **Clip** button, or the
++dashboard's **Create Clip** with the name left empty), a card offers **Save name** or **Skip**. It closes by itself
++after 20 seconds and never covers the stream's controls. Viewers name their clip in the trim editor when its cut is
++ready.
++
++A clip left unnamed (still "Untitled Clip", "Clip from stream" or "Clip: <stream title>") is named by the AI once its
++overview exists: 3-6 words about what happens, never about what anyone looks like, and no slurs or strong profanity
++(`server/ai/clip-title.js`). A name a person gives always wins, even one given while the AI was writing. The clip's
++"clipped: …" line in chat waits up to 3 minutes for the AI's name.
++
## VOD Management
From the dashboard:
ddiff --git a/migrations/0019_clip_ai_title.sql b/migrations/0019_clip_ai_title.sql
nnew file mode 100644
iindex 00000000..a003d901
---- /dev/null
++++ b/migrations/0019_clip_ai_title.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- AI titles for untitled clips (server/ai/clip-title.js; owner, 2026-10-25: "The AI should automatically title clips that
++-- are untitled"): whether a clip's title is decided. 'titled': it carried a placeholder ("Untitled Clip", "Clip from
++-- stream") and the AI named it; 'kept': its maker's own title stays; 'failed': no usable title came (it keeps its
++-- placeholder). NULL until decided. Nullable, no default: the N-1 release neither reads nor writes it.
++ALTER TABLE clip_ai_state ADD COLUMN IF NOT EXISTS title_status text COLLATE "C";
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 16e7819d..13769964 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2692,3 +2692,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
.bcs-viewer-head > div { display: grid; }
.bcs-viewer-head small { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; }
.bcs-viewer-frame { flex: 1; width: 100%; border: 0; background: #000; }
++
++/* "Clip saved: name it" card (public/js/broadcast.js _offerClipName): at the top, clear of the studio's controls and of
++ the phone keyboard; Skip, Escape or 20 s untouched close it (the thin line shows the time left). */
++.bc-clip-name {
++ position: fixed; top: calc(12px + env(safe-area-inset-top, 0px)); left: 50%; transform: translateX(-50%); z-index: 3100;
++ width: min(420px, calc(100vw - 24px)); box-sizing: border-box; overflow: hidden;
++ display: grid; gap: 8px; padding: 14px 14px 16px; border-radius: 16px;
++ background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5);
++ border: 1px solid var(--border, rgba(255,255,255,0.12)); box-shadow: 0 16px 48px rgba(0,0,0,0.45);
++ animation: bc-clip-name-in 0.18s ease-out;
++}
++@keyframes bc-clip-name-in { from { transform: translate(-50%, -12px); opacity: 0; } }
++.bc-clip-name-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
++.bc-clip-name-head i { color: var(--accent, #60a5fa); }
++.bc-clip-name-head b { font-size: 1rem; }
++.bc-clip-name-head small { margin-left: auto; color: var(--text-secondary, #a1a1aa); font-size: 0.78rem; white-space: nowrap; }
++.bc-clip-name-input {
++ width: 100%; box-sizing: border-box; min-height: 44px; padding: 0 12px; border-radius: 10px;
++ border: 1px solid var(--border, rgba(255,255,255,0.18)); background: var(--bg-primary, #0b0b10); color: inherit;
++ font: inherit; font-size: 1rem;
++}
++.bc-clip-name-input:focus { outline: 2px solid var(--accent, #60a5fa); outline-offset: 1px; }
++.bc-clip-name-hint { margin: 0; color: var(--text-secondary, #a1a1aa); font-size: 0.82rem; line-height: 1.35; }
++.bc-clip-name-acts { display: flex; gap: 8px; justify-content: flex-end; }
++.bc-clip-name-acts button {
++ min-height: 40px; padding: 0 16px; border-radius: 999px; cursor: pointer; font: inherit; font-weight: 700; font-size: 0.9rem;
++ border: 1px solid var(--border, rgba(255,255,255,0.16)); background: var(--surface-raised, rgba(255,255,255,0.06)); color: inherit;
++}
++.bc-clip-name-acts .bc-clip-name-save { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); border-color: transparent; }
++.bc-clip-name-acts button:disabled { opacity: 0.6; cursor: default; }
++.bc-clip-name-timer {
++ position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; transform-origin: left;
++ background: var(--accent, #60a5fa); animation: bc-clip-name-time linear forwards;
++}
++.bc-clip-name.is-held .bc-clip-name-timer { display: none; }
++@keyframes bc-clip-name-time { to { transform: scaleX(0); } }
++@media (prefers-reduced-motion: reduce) {
++ .bc-clip-name { animation: none; }
++ .bc-clip-name-timer { animation: none; display: none; }
++}
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 0e1b704a..42cb807b 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -755,7 +755,7 @@ WantedBy=multi-user.target</code>
</div>
</div>
<div class="form-group" style="margin-top:10px">
-- <input type="text" id="bc-clip-title" class="form-input" placeholder="Clip title (optional)" maxlength="200">
++ <input type="text" id="bc-clip-title" class="form-input" placeholder="Name it (optional: the AI names it if you skip)" aria-label="Clip name" maxlength="200">
</div>
<div class="vod-clip-actions">
<button class="btn btn-outline" onclick="setBroadcastClipLast30()">
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 2cb48e3c..56ce1f82 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -205,8 +205,18 @@
else { try { _bcClipEnd = end; _bcClipStart = Math.max(0, end - seconds); } catch { /* the panel's own defaults stand */ } }
const title = $('bc-clip-title');
if (title) title.value = '';
-- say(`Clipping the last ${Math.min(seconds, end)} seconds…`);
-- createBroadcastClip();
++ // The Clip button spins until the clip is saved; then broadcast.js's card offers to name it (or skip, and the
++ // AI names it).
++ const b = st.root && st.root.querySelector('[data-act="clip"]');
++ if (b && b.classList.contains('is-busy')) return;
++ const look = (busy) => {
++ if (!b) return;
++ b.classList.toggle('is-busy', busy);
++ b.querySelector('i').className = `fa-solid ${busy ? 'fa-spinner fa-spin' : 'fa-scissors'}`;
++ b.querySelector('.bcs-lbl').textContent = busy ? 'Clipping' : 'Clip';
++ };
++ look(true);
++ Promise.resolve(createBroadcastClip()).catch(() => null).finally(() => look(false));
}
/** Copies the channel link. (The system share sheet takes a phone away from this tab, which pauses the camera.) */
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 52c24987..86041f82 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -5445,7 +5445,7 @@ function _updateBroadcastClipUI() {
async function createBroadcastClip() {
const ss = getActiveStreamState();
-- if (!ss) return showToast('No active stream', 'error');
++ if (!ss) { toast('No active stream', 'error'); return null; }
// Auto-retry if VOD not ready yet (up to 3 attempts, 2s apart)
if (!ss.vodId) {
@@@ -5457,15 +5457,17 @@ async function createBroadcastClip() {
if (retry?.vodId) { ss.vodId = retry.vodId; break; }
}
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }
-- if (!ss.vodId) return showToast('VOD recording not ready yet — try again in a few seconds', 'error');
++ if (!ss.vodId) { toast('VOD recording not ready yet — try again in a few seconds', 'error'); return null; }
}
const duration = _bcClipEnd - _bcClipStart;
-- if (duration <= 0) return showToast('End time must be after start time', 'error');
-- if (duration > 60) return showToast('Clip cannot exceed 60 seconds', 'error');
-- if (_bcClipStart < 0) return showToast('Invalid start time', 'error');
++ if (duration <= 0) { toast('End time must be after start time', 'error'); return null; }
++ if (duration > 60) { toast('Clip cannot exceed 60 seconds', 'error'); return null; }
++ if (_bcClipStart < 0) { toast('Invalid start time', 'error'); return null; }
-- const title = document.getElementById('bc-clip-title')?.value?.trim() || 'Untitled Clip';
++ // No title typed: the server names it after the stream for now, and the card below offers to name it (or the AI
++ // does, from what was said and shown: server/ai/clip-title.js).
++ const typed = document.getElementById('bc-clip-title')?.value?.trim() || '';
const btn = document.getElementById('bc-clip-create-btn');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }
@@@ -5478,22 +5480,25 @@ async function createBroadcastClip() {
stream_id: ss.streamData?.id,
start_time: _bcClipStart,
end_time: _bcClipEnd,
-- title,
++ title: typed,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create clip');
-- // The server cuts it (Media's clip.cut job): say when it is ready, even after a reload.
const made = data.clip || {};
++ const card = typed ? null : _offerClipName(made, duration);
++ const name = () => (card && card.named) || typed;
++ const said = (n) => (n ? `Clip "${n}"` : 'Your clip');
++ // The server cuts it (Media's clip.cut job): say when it is ready, even after a reload.
if (made.job_id && made.status === 'processing' && window.OVClipJobs) {
-- showToast(`Clip "${title}" created — cutting it now…`, 'success');
++ if (!card) toast(`${said(typed)} is saved — cutting it now…`, 'success');
window.OVClipJobs.follow(made.id, made.job_id, {
-- onReady: () => showToast(`Clip "${title}" is ready`, 'success'),
-- onFailed: (msg) => showToast(`Clip "${title}" could not be cut: ${msg}`, 'error'),
++ onReady: () => toast(`${said(name())} is ready`, 'success'),
++ onFailed: (msg) => toast(`${said(name())} could not be cut: ${msg}`, 'error'),
});
-- } else {
-- showToast(`Clip created! "${title}"`, 'success');
++ } else if (!card) {
++ toast(`${said(typed)} is saved`, 'success');
}
// Reset panel
@@@ -5504,14 +5509,90 @@ async function createBroadcastClip() {
_bcClipEnd = now;
_bcClipStart = Math.max(0, now - 30);
_updateBroadcastClipUI();
++ return made;
} catch (err) {
console.error('[Clip] Error:', err);
-- showToast(err.message || 'Failed to create clip', 'error');
++ toast(err.message || 'Failed to create clip', 'error');
++ return null;
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }
}
}
++/**
++ * Right after a clip is made without a title (the phone studio's Clip button, the panel's Create Clip with the name
++ * left empty): a card at the top offers to name it. It never blocks the stream's controls: Skip, Escape or 20 seconds
++ * untouched close it, and a clip left unnamed is named by the AI from what was said and shown. Returns the card's
++ * state ({ named }) so later notices use the name.
++ */
++const CLIP_NAME_SECONDS = 20;
++function _offerClipName(clip, seconds) {
++ const state = { named: '' };
++ if (!clip || !clip.id) return state;
++ document.getElementById('bc-clip-name')?.remove();
++
++ const make = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
++ const card = make('form', 'bc-clip-name');
++ card.id = 'bc-clip-name';
++ card.setAttribute('role', 'dialog');
++ card.setAttribute('aria-labelledby', 'bc-clip-name-t');
++ const head = make('div', 'bc-clip-name-head');
++ const ico = make('i', 'fa-solid fa-scissors');
++ ico.setAttribute('aria-hidden', 'true');
++ const title = make('b', null, 'Clip saved');
++ title.id = 'bc-clip-name-t';
++ head.append(ico, title, make('small', null, `${Math.round(seconds) || 30} s, cutting now`));
++ const input = make('input', 'bc-clip-name-input');
++ Object.assign(input, { type: 'text', maxLength: 200, placeholder: 'Name it: what happened?', autocomplete: 'off', enterKeyHint: 'done' });
++ input.setAttribute('aria-label', 'Clip name');
++ const hint = make('p', 'bc-clip-name-hint', 'Or skip it, and the AI names it from what was said and shown.');
++ const skip = make('button', 'bc-clip-name-skip', 'Skip');
++ skip.type = 'button';
++ const save = make('button', 'bc-clip-name-save', 'Save name');
++ save.type = 'submit';
++ const acts = make('div', 'bc-clip-name-acts');
++ acts.append(skip, save);
++ const timer = make('div', 'bc-clip-name-timer');
++ timer.setAttribute('aria-hidden', 'true');
++ timer.style.animationDuration = `${CLIP_NAME_SECONDS}s`;
++ card.append(head, input, hint, acts, timer);
++ document.body.appendChild(card);
++
++ let closeAt = setTimeout(() => close(), CLIP_NAME_SECONDS * 1000);
++ const hold = () => { clearTimeout(closeAt); closeAt = null; card.classList.add('is-held'); };
++ const close = () => { clearTimeout(closeAt); card.remove(); document.removeEventListener('keydown', onKey, true); };
++ const onKey = (e) => { if (e.key === 'Escape' && document.body.contains(card)) { e.stopPropagation(); close(); } };
++ document.addEventListener('keydown', onKey, true);
++ // Touching it or typing keeps it open: nobody loses a half-typed name to the timer.
++ card.addEventListener('pointerdown', hold);
++ input.addEventListener('input', hold);
++ // A keyboard and mouse: ready to type. A phone: no keyboard pops up over the stream until the box is tapped.
++ if (!window.matchMedia('(pointer: coarse)').matches) input.focus({ preventScroll: true });
++ skip.addEventListener('click', () => { close(); toast('Skipped: the AI will name it', 'info'); });
++ card.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const name = input.value.trim();
++ if (!name) { input.focus(); return; }
++ save.disabled = true;
++ save.textContent = 'Saving…';
++ try {
++ const res = await fetch(`/api/clips/${encodeURIComponent(clip.id)}/title`, {
++ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: name }),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(data.error || 'Could not save the name');
++ state.named = name;
++ close();
++ toast(`Named "${name}"`, 'success');
++ } catch (err) {
++ save.disabled = false;
++ save.textContent = 'Save name';
++ toast(err.message || 'Could not save the name', 'error');
++ }
++ });
++ return state;
++}
++
/* ── /Live Clip Creator ────────────────────────────────────── */
/* ── PiP Drag & Resize on Preview ─────────────────────────── */
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex d3a18a6a..c954b5f1 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -3217,8 +3217,8 @@ function _buildClipTitleModal(overlay, clipId) {
modal.style.cssText = 'background:var(--card-bg, #1a1a2e);border:1px solid var(--border, #333);border-radius:12px;padding:24px;max-width:400px;width:90%;text-align:center;';
modal.innerHTML = `
<h3 style="margin:0 0 8px 0"><i class="fa-solid fa-scissors"></i> Name Your Clip</h3>
-- <p style="margin:0 0 16px 0;opacity:0.7;font-size:0.9rem">Give your clip a title so others know what it is</p>
-- <input type="text" id="live-clip-title-input" class="form-input" placeholder="Enter clip title..."
++ <p style="margin:0 0 16px 0;opacity:0.7;font-size:0.9rem">Say what happened, or skip it and the AI names it from what was said and shown.</p>
++ <input type="text" id="live-clip-title-input" class="form-input" placeholder="What happened?" aria-label="Clip name"
maxlength="200" style="width:100%;box-sizing:border-box;margin-bottom:16px;font-size:1rem;padding:10px 12px">
<div style="display:flex;gap:8px;justify-content:center">
<button id="live-clip-title-save" class="btn btn-primary" style="flex:1">
@@@ -3237,12 +3237,7 @@ function _buildClipTitleModal(overlay, clipId) {
const saveTitle = async () => {
const title = input.value.trim();
-- if (!title) {
-- input.style.borderColor = '#e53e3e';
-- input.placeholder = 'Please enter a title...';
-- input.focus();
-- return;
-- }
++ if (!title) return skip(); // nothing typed: the AI names it (server/ai/clip-title.js)
try {
const token = localStorage.getItem('token');
const resp = await fetch(`/api/clips/${clipId}/title`, {
@@@ -3258,7 +3253,7 @@ function _buildClipTitleModal(overlay, clipId) {
overlay.remove();
};
-- const skip = () => overlay.remove();
++ const skip = () => { overlay.remove(); toast('Skipped: the AI will name it', 'info'); };
document.getElementById('live-clip-title-save').onclick = saveTitle;
document.getElementById('live-clip-title-skip').onclick = skip;
@@@ -3325,7 +3320,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
<label style="display:block;font-size:0.78rem;opacity:0.7;margin:6px 0 2px">End</label>
<input type="range" id="clip-trim-end" min="0" max="${dur}" step="0.1" value="${dur}" style="width:100%">
</div>
-- <input type="text" id="clip-trim-title" class="form-input" placeholder="Give your clip a title..."
++ <input type="text" id="clip-trim-title" class="form-input" placeholder="Name it (optional: the AI names it if you leave it empty)" aria-label="Clip name"
maxlength="200" style="width:100%;box-sizing:border-box;margin:10px 0 14px;font-size:1rem;padding:10px 12px">
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button id="clip-trim-skip" class="btn" style="opacity:0.7">Skip</button>
@@@ -3345,7 +3340,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
const titleInput = modal.querySelector('#clip-trim-title');
const saveBtn = modal.querySelector('#clip-trim-save');
const skipBtn = modal.querySelector('#clip-trim-skip');
-- if (defaultTitle && defaultTitle !== 'Clip from stream') titleInput.value = defaultTitle;
++ // A default name (a Clip button's, or "Clip: <stream title>") stays out of the box: empty means the AI names it.
++ if (defaultTitle && !/^(clip from stream|untitled clip|clip: .*)$/i.test(defaultTitle.trim())) titleInput.value = defaultTitle;
const MIN_LEN = 1;
const refresh = () => {
ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex c63e02f8..66f0806f 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -360,6 +360,7 @@ async function generateClipOverview(clip) {
const speech = clipOverviewSpeech(r);
try { await db.setClipAiOverview(clip.id, { overview, transcript: transcript || null, segments: speech.segments }); } catch { /* */ }
await require('./explicit').guardAutoClip(clip, overview);
++ require('./clip-title').soon(clip.id);
try { if (speech.status) await db.setClipTranscriptStatus(clip.id, speech.status); } catch { /* */ }
return { overview: r ? r.overview : null, transcript };
}
ddiff --git a/server/ai/ai-media-analyze.js b/server/ai/ai-media-analyze.js
iindex 42226abe..323a5903 100644
---- a/server/ai/ai-media-analyze.js
++++ b/server/ai/ai-media-analyze.js
@@@ -73,6 +73,7 @@ async function analyze(kind, item) {
else {
await db.setClipAiOverview(item.id, { overview });
await require('./explicit').guardAutoClip(item, overview);
++ require('./clip-title').soon(item.id);
}
}
const partialSpeech = Array.isArray(out.gaps) && out.gaps.some(g => /^Speech-to-text failed/i.test(String(g)));
ddiff --git a/server/ai/clip-title.js b/server/ai/clip-title.js
nnew file mode 100644
iindex 00000000..1acc53c0
---- /dev/null
++++ b/server/ai/clip-title.js
@@@ -0,0 +1,120 @@
++'use strict';
++/**
++ * AI titles for untitled clips (owner, 2026-10-25: "The AI should automatically title clips that are untitled";
++ * migrations/0019_clip_ai_title.sql). A clip keeps the title its maker gave it. One still named by a placeholder
++ * ("Untitled Clip", "Clip from stream", …: what the Clip buttons send when nobody types a title) is titled from its AI
++ * overview once that exists: OpenVibe.AI's live.stream.titles template (3-6 words, what happens, never what anyone looks
++ * like), then Live's rule for public AI titles (ai-moments-job publicTitle: a title naming looks is replaced by one from
++ * the overview's first clause, or the stream's title and the moment). The new title goes to OpenVibe.Media, and each
++ * clip is decided once (clip_ai_state.title_status: titled, kept or failed).
++ *
++ * isPlaceholder(title, sourceTitles) → true for a title nobody chose
++ * titleClips({ ids }) → how many clips were titled: the given ids, else the newest waiting (BATCH a run, one AI call)
++ * soon(clipId) after a clip's overview is stored (ai-media-analyze, ai-analysis): title it within seconds,
++ * so the clip's chat announcement (clip-notify) can carry the name
++ * start() the sweep every SWEEP_MS for the rest (started from server/index.js's normal boot, so never
++ * under LIVE_DRILL)
++ */
++const db = require('../db/database');
++const media = require('../media-client');
++const { frontPageOk } = require('../home/front-page-text');
++
++const BATCH = 20; // clips titled per run: live.stream.titles takes at most 20 summaries
++const LOOK = 40; // waiting clips looked at per run (one with a person's title is only marked kept)
++const SWEEP_MS = 3 * 60 * 1000;
++const PLACEHOLDERS = new Set(['', 'untitled', 'untitled clip', 'clip', 'clip from stream', 'new clip', 'my clip', 'a clip']);
++
++const norm = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().toLowerCase().replace(/[.!…]+$/, '');
++/** True for a title nobody chose: a Clip button's default, or "Clip: <stream title>" (vods.js defaultClipTitle). */
++function isPlaceholder(title, sourceTitles = []) {
++ const t = norm(title);
++ if (PLACEHOLDERS.has(t)) return true;
++ return t.startsWith('clip: ') && ['stream recording', ...sourceTitles.map(norm)].includes(t.slice(6));
++}
++
++/** What the template reads about one clip: its overview, a line of what was said, and the stream it came from. */
++function summaryOf(state, stream) {
++ let said = '';
++ try {
++ const segs = JSON.parse(state.ai_transcript_json || '[]');
++ said = (Array.isArray(segs) ? segs : []).map((s) => String((s && s.text) || '').trim()).filter(Boolean).join(' ');
++ } catch { said = ''; }
++ const parts = [
++ String(state.ai_overview || state.ai_overview_short || '').replace(/\s+/g, ' ').trim().slice(0, 600),
++ said ? `Said: "${said.replace(/\s+/g, ' ').slice(0, 220)}"` : '',
++ stream && stream.title ? `From the stream "${String(stream.title).replace(/\s+/g, ' ').slice(0, 80)}"${stream.category ? ` (${stream.category})` : ''}.` : '',
++ ].filter(Boolean);
++ return parts.join(' ').slice(0, 1000);
++}
++
++let running = false;
++async function titleClips({ ids = null } = {}) {
++ if (running) return 0;
++ const ai = require('./ai-analysis');
++ if (!(await ai.isEnabled()) || !(await ai.withinBudget())) return 0;
++ running = true;
++ try {
++ const rows = ids && ids.length
++ ? await db.all(`SELECT * FROM clip_ai_state WHERE clip_id = ANY(?) AND title_status IS NULL AND btrim(COALESCE(ai_overview, '')) <> ''`, [ids.map(Number)])
++ : await db.all(`SELECT * FROM clip_ai_state WHERE title_status IS NULL AND btrim(COALESCE(ai_overview, '')) <> '' ORDER BY clip_id DESC LIMIT ?`, [LOOK]);
++ const todo = [];
++ for (const state of rows) {
++ if (todo.length >= BATCH) break;
++ let clip = null;
++ try { clip = await media.getClip(state.clip_id); } catch (err) {
++ // Gone or not Live's to see: decided. Media away: asked again on the next sweep.
++ if (err && err.status >= 400 && err.status < 500) await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = ?", [state.clip_id]);
++ continue;
++ }
++ if (!clip) continue;
++ const stream = clip.stream_id ? await db.getStreamById(clip.stream_id).catch(() => null) : null;
++ if (!isPlaceholder(clip.title, [stream && stream.title])) { await db.run("UPDATE clip_ai_state SET title_status = 'kept' WHERE clip_id = ?", [state.clip_id]); continue; }
++ todo.push({ state, clip, stream, summary: summaryOf(state, stream) });
++ }
++ if (!todo.length) return 0;
++ // The prompt is OpenVibe.AI's versioned template live.stream.titles; Live sends one summary per clip.
++ const out = await require('./ai-service').structured('live.stream.titles', { summaries: todo.map((t) => t.summary || '(no summary)') },
++ { meter: { kind: 'clip_titles', role: 'legacy', ownerUserId: todo[0].clip.user_id } }).catch(() => null);
++ const given = new Map((out && Array.isArray(out.titles) ? out.titles : []).map((x) => [Number(x.index), x.title]));
++ const { publicTitle } = require('./ai-moments-job');
++ let titled = 0;
++ for (const [i, t] of todo.entries()) {
++ const raw = given.get(i);
++ // No answer for it: leave it waiting for the next sweep, unless the whole call failed for good.
++ if (raw == null) { if (out) await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = ?", [t.clip.id]); continue; }
++ const vod = { title: t.stream && t.stream.title, username: t.clip.username || (t.stream && t.stream.username) };
++ const at = Number(t.clip.start_time) || 0;
++ let title = String(publicTitle(raw, vod, at, t.state.ai_overview) || '').slice(0, 80).trim();
++ // Titles are public everywhere a clip shows: one with a slur or strong profanity gives way to the stream's.
++ if (title && !frontPageOk(title)) title = String(publicTitle('', vod, at, null) || '').slice(0, 80).trim();
++ // The maker may have named it while the AI was writing: their title wins.
++ let fresh = null;
++ try { fresh = await media.getClip(t.clip.id); } catch { fresh = null; }
++ if (!title || !fresh || !isPlaceholder(fresh.title, [t.stream && t.stream.title])) {
++ await db.run('UPDATE clip_ai_state SET title_status = ? WHERE clip_id = ?', [title ? 'kept' : 'failed', t.clip.id]);
++ continue;
++ }
++ try {
++ await media.updateClip(t.clip.id, { title });
++ await db.run("UPDATE clip_ai_state SET title_status = 'titled' WHERE clip_id = ?", [t.clip.id]);
++ titled++;
++ // A chat announcement waiting for the name (clip-notify) goes out on its next sweep.
++ await require('../media-proxy/clip-notify').bumpClipNotifyNow(t.clip.id);
++ } catch (err) { console.warn(`[AI clip titles] clip ${t.clip.id}: ${err && err.message}`); }
++ }
++ if (titled) console.log(`[AI clip titles] titled ${titled} clip(s)`);
++ return titled;
++ } finally {
++ running = false;
++ }
++}
++
++function soon(clipId) {
++ setTimeout(() => { titleClips({ ids: [clipId] }).catch(() => {}); }, 2000).unref?.();
++}
++
++function start() {
++ require('../utils/jobs').every('ai-clip-titles', SWEEP_MS, () => titleClips(), { initialDelayMs: 90_000, jitterMs: 20_000 });
++}
++
++module.exports = { isPlaceholder, summaryOf, titleClips, soon, start, BATCH };
ddiff --git a/server/index.js b/server/index.js
iindex 686499bc..bb8deab3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1457,6 +1457,7 @@ async function start() {
try { require('./ai/slogan-job').start(); } catch (e) { console.warn('[AI] slogan job not started:', e.message); }
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
++ try { require('./ai/clip-title').start(); } catch (e) { console.warn('[AI] clip-title job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
ddiff --git a/server/media-proxy/clip-notify.js b/server/media-proxy/clip-notify.js
iindex ecb26443..4a4746f1 100644
---- a/server/media-proxy/clip-notify.js
++++ b/server/media-proxy/clip-notify.js
@@@ -14,6 +14,9 @@ const media = require('../media-client');
const GRACE_SECONDS = 60;
const SWEEP_INTERVAL_MS = 15000;
++// A clip nobody named waits up to this long after it was made for its AI title (server/ai/clip-title.js), so the line
++// says what happened instead of "Untitled Clip".
++const TITLE_WAIT_SECONDS = 180;
async function scheduleClipNotify(clipId) {
try { await db.scheduleClipNotifyState(clipId, GRACE_SECONDS); }
@@@ -46,6 +49,8 @@ async function _sendOne(clipId) {
} catch { /* fall through and notify */ }
}
++ if (await _waitForTitle(clip, srcStream)) return;
++
const ownerId = srcStream.user_id;
const creator = clip.user_id ? await db.getUserById(clip.user_id) : null;
const creatorName = (creator && (creator.display_name || creator.username)) || 'Someone';
@@@ -78,6 +83,16 @@ async function _sendOne(clipId) {
await db.markClipNotifiedState(clipId);
}
++async function _waitForTitle(clip, srcStream, now = Date.now()) {
++ const titles = require('../ai/clip-title');
++ if (!titles.isPlaceholder(clip.title, [srcStream && srcStream.title])) return false;
++ const made = Date.parse(String(clip.created_at || '').replace(' ', 'T').replace(/(T\d\d:\d\d:\d\d(\.\d+)?)$/, '$1Z'));
++ if (!Number.isFinite(made) || now - made > TITLE_WAIT_SECONDS * 1000) return false;
++ const state = await db.getClipAiState(clip.id).catch(() => null);
++ if (state && state.title_status) return false; // decided: titled (Media's title is fresh above), kept or failed
++ try { return await require('../ai/ai-analysis').isEnabled(); } catch { return false; }
++}
++
let _sweepTimer = null;
// Clips whose announce is currently being sent. A slow Media getClip() once let
// six 15s sweep ticks stack up on the same clip and announce it six times when
@@@ -99,4 +114,4 @@ function startClipNotifySweeper() {
console.log('[ClipNotify] sweeper started (Media-backed clips)');
}
--module.exports = { scheduleClipNotify, bumpClipNotifyNow, startClipNotifySweeper };
++module.exports = { scheduleClipNotify, bumpClipNotifyNow, startClipNotifySweeper, _sendOne, _waitForTitle, TITLE_WAIT_SECONDS };
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex d14b5be8..04c2367c 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -312,6 +312,8 @@ router.put('/:id/title', requireAuth, async (req, res) => {
const title = (req.body.title || '').trim();
if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
await media.updateClip(clip.id, { title });
++ // A person named it: the AI titler (server/ai/clip-title.js) leaves it alone from now on.
++ try { await db.run("INSERT INTO clip_ai_state (clip_id, title_status) VALUES (?, 'kept') ON CONFLICT (clip_id) DO UPDATE SET title_status = 'kept'", [clip.id]); } catch { /* */ }
// If the clip's chat announcement is still pending, fire it now with this title.
try { await require('./clip-notify').bumpClipNotifyNow(clip.id); } catch { /* */ }
res.json({ message: 'Clip title updated', title });
ddiff --git a/test/clip-title.test.js b/test/clip-title.test.js
nnew file mode 100644
iindex 00000000..a5177e6a
---- /dev/null
++++ b/test/clip-title.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++// AI titles for untitled clips (server/ai/clip-title.js, migration 0019): a clip still named by a placeholder is titled
++// from its overview through live.stream.titles, once; a title a person gave is kept; a title naming looks or a rough
++// one gives way to a safe one; a person who names the clip while the AI writes wins. A clip's chat announcement waits a
++// little for the AI title (clip-notify _waitForTitle), and the title route marks a person's title kept.
++//
++// node test/clip-title.test.js
++const assert = require('assert');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++(async () => {
++ const titles = require('../server/ai/clip-title');
++
++ // ── What counts as a title nobody chose ──
++ for (const t of ['', null, 'Untitled Clip', 'untitled clip.', 'Clip from stream', ' clip ', 'New clip']) assert.ok(titles.isPlaceholder(t), `placeholder: ${t}`);
++ assert.ok(titles.isPlaceholder('Clip: Friday ranked', ['Friday ranked']), "vods.js's default for a clip without a title");
++ assert.ok(titles.isPlaceholder('Clip: Stream Recording'), 'the default when the stream had no title');
++ for (const t of ['test', 'Clip: what a save', 'Triple kill', 'Untitled Clip 2']) assert.ok(!titles.isPlaceholder(t, ['Friday ranked']), `chosen: ${t}`);
++
++ const db = require('../server/db/database');
++ await db.initDb();
++ const media = require('../server/media-client');
++ const ai = require('../server/ai/ai-analysis');
++ const aiService = require('../server/ai/ai-service');
++
++ let enabled = true;
++ ai.isEnabled = async () => enabled;
++ ai.withinBudget = async () => true;
++ db.getStreamById = async (id) => ({ id, title: 'Friday ranked', username: 'alice', category: 'Shooters', is_live: 1 });
++
++ const now = () => new Date().toISOString();
++ const clips = new Map();
++ const reads = new Map(); // clip id → titles to answer on later reads (a person renaming it mid-run)
++ const put = (id, title, extra = {}) => clips.set(id, { id, title, stream_id: 9, user_id: 7, start_time: 42, status: 'ready', created_at: now(), ...extra });
++ media.getClip = async (id) => {
++ id = Number(id);
++ if (id === 607) { const e = new Error('Not found'); e.status = 404; throw e; }
++ const later = reads.get(id);
++ if (later && later.length > 1) clips.get(id).title = later.shift();
++ else if (later && later.length === 1) { clips.get(id).title = later.shift(); reads.delete(id); }
++ return clips.has(id) ? { ...clips.get(id) } : null;
++ };
++ const updates = [];
++ media.updateClip = async (id, fields) => { updates.push([Number(id), fields.title]); clips.get(Number(id)).title = fields.title; return { id }; };
++
++ const calls = [];
++ const answers = { 'triple kill': 'Triple Kill Final Round Clutch', 'dress': 'Woman In Red Dress Dances', 'rough clutch': 'Fucking Insane Clutch', 'rename': 'Late Game Comeback', 'default': 'Shotgun Rush Through Hallway' };
++ aiService.structured = async (workflow, input, opts) => {
++ calls.push({ workflow, input, opts });
++ return { titles: input.summaries.map((s, index) => {
++ const key = Object.keys(answers).find((k) => s.toLowerCase().includes(k)) || 'default';
++ return { index, title: answers[key] };
++ }) };
++ };
++
++ const seed = async (id, overview, extra = {}) => {
++ await db.run('INSERT INTO clip_ai_state (clip_id, ai_overview, clip_notified, ai_transcript_json) VALUES (?, ?, ?, ?)',
++ [id, overview, extra.notified ?? 1, extra.transcript ? JSON.stringify([{ start: 0, end: 2, text: extra.transcript }]) : null]);
++ };
++ const status = async (id) => ((await db.getClipAiState(id)) || {}).title_status || null;
++
++ put(601, 'Untitled Clip'); await seed(601, 'The streamer lands a triple kill in the final round.', { transcript: 'no way, no way!', notified: 0 });
++ put(602, 'My epic moment'); await seed(602, 'A quiet walk through the market.');
++ put(603, 'Clip: Friday ranked'); await seed(603, 'A shotgun rush through a narrow hallway.');
++ put(604, 'Clip from stream'); await seed(604, ' ');
++ put(605, 'Clip from stream'); await seed(605, 'Someone in a red dress dances on the stage while chat cheers.');
++ put(606, 'Untitled Clip'); await seed(606, 'A rough clutch play in the last seconds.');
++ await seed(607, 'Overview of a clip Media no longer has.');
++ put(608, 'Untitled Clip'); await seed(608, 'A rename test: a late game comeback.');
++ reads.set(608, ['Untitled Clip', 'What a comeback']); // the person names it before the AI's title is saved
++
++ // ── One run titles every waiting clip with one AI call ──
++ const n = await titles.titleClips();
++ assert.strictEqual(calls.length, 1, 'one AI call for the batch');
++ assert.strictEqual(calls[0].workflow, 'live.stream.titles');
++ assert.strictEqual(calls[0].opts.meter.kind, 'clip_titles');
++ const summaries = calls[0].input.summaries;
++ assert.ok(summaries.length === 5 && summaries.every((s) => s.length <= 1000), 'one summary per placeholder clip with an overview');
++ const s601 = summaries.find((s) => s.includes('triple kill'));
++ assert.ok(s601.includes('Said: "no way, no way!"') && s601.includes('From the stream "Friday ranked" (Shooters)'), s601);
++
++ const byId = new Map(updates);
++ assert.strictEqual(byId.get(601), 'Triple Kill Final Round Clutch');
++ assert.strictEqual(byId.get(603), 'Shotgun Rush Through Hallway', '"Clip: <stream title>" is a placeholder');
++ assert.ok(byId.has(605) && !/dress|woman/i.test(byId.get(605)), `a title naming looks is replaced: ${byId.get(605)}`);
++ assert.strictEqual(byId.get(606), 'Friday ranked at 0:42', 'a rough title gives way to the stream and the moment');
++ assert.ok(!byId.has(602) && !byId.has(604) && !byId.has(607) && !byId.has(608), 'nothing else is written to Media');
++ assert.strictEqual(n, 4);
++
++ assert.deepStrictEqual(
++ [await status(601), await status(602), await status(603), await status(604), await status(605), await status(606), await status(607), await status(608)],
++ ['titled', 'kept', 'titled', null, 'titled', 'titled', 'failed', 'kept']);
++ assert.strictEqual(clips.get(608).title, 'What a comeback', "the person's name for it stands");
++ assert.ok((await db.getClipAiState(601)).clip_notify_at, 'a pending chat announcement goes out with the new title');
++
++ // ── Each clip is decided once ──
++ assert.strictEqual(await titles.titleClips(), 0);
++ assert.strictEqual(calls.length, 1, 'nothing left to ask about');
++
++ // ── A clip whose overview just landed is titled by id; nothing runs while the AI is off ──
++ put(609, 'Untitled Clip'); await seed(609, 'A speedrun skip through the castle wall.');
++ enabled = false;
++ assert.strictEqual(await titles.titleClips({ ids: [609] }), 0);
++ assert.strictEqual(calls.length, 1, 'no AI call with the AI off');
++ enabled = true;
++ assert.strictEqual(await titles.titleClips({ ids: [609] }), 1);
++ assert.strictEqual(clips.get(609).title, 'Shotgun Rush Through Hallway');
++
++ // ── The chat announcement waits for the AI title, briefly ──
++ const notify = require('../server/media-proxy/clip-notify');
++ const stream = { title: 'Friday ranked' };
++ put(610, 'Clip: Friday ranked'); await seed(610, ' ');
++ assert.strictEqual(await notify._waitForTitle(clips.get(610), stream), true, 'a fresh untitled clip waits');
++ const old = new Date(Date.now() - (notify.TITLE_WAIT_SECONDS + 5) * 1000).toISOString();
++ assert.strictEqual(await notify._waitForTitle({ ...clips.get(610), created_at: old }, stream), false, 'not past the wait');
++ assert.strictEqual(await notify._waitForTitle({ ...clips.get(610), created_at: old.replace('T', ' ').replace(/\.\d+Z$/, '') }, stream), false, "Media's SQL-style time is read too");
++ assert.strictEqual(await notify._waitForTitle(clips.get(601), stream), false, 'a titled clip goes out');
++ assert.strictEqual(await notify._waitForTitle(clips.get(602), stream), false, "a person's title goes out");
++ enabled = false;
++ assert.strictEqual(await notify._waitForTitle(clips.get(610), stream), false, 'no wait with the AI off');
++ enabled = true;
++ await db.run("UPDATE clip_ai_state SET title_status = 'failed' WHERE clip_id = 610");
++ assert.strictEqual(await notify._waitForTitle(clips.get(610), stream), false, 'no wait once the AI gave up');
++
++ // ── A person's title from the title route is kept, even with no clip_ai_state row yet ──
++ const auth = require('../server/auth/auth');
++ auth.requireAuth = (req, _res, next) => { req.user = { id: 7, username: 'alice', role: 'streamer' }; next(); };
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/clips', require('../server/media-proxy/clips'));
++ const server = require('http').createServer(app).listen(0);
++ if (!server.listening) await new Promise((r) => server.once('listening', r));
++ put(611, 'Untitled Clip');
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/clips/611/title`, {
++ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Big brain play' }),
++ });
++ server.close();
++ assert.strictEqual(res.status, 200, await res.clone().text());
++ assert.strictEqual(clips.get(611).title, 'Big brain play');
++ assert.strictEqual(await status(611), 'kept', 'the AI leaves a named clip alone');
++
++ quiet('clip titles: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });