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)
Changes
@@@ -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 ManagementFrom the dashboard:nnew file mode 100644@@@ -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";@@@ -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; }++}@@@ -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()">@@@ -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.) */@@@ -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 ─────────────────────────── */@@@ -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 = () => {@@@ -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 };}@@@ -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)));nnew file mode 100644@@@ -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 };@@@ -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); }@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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); });
@@ -63,6 +63,18 @@ Viewers can create clips from live streams:
@@ -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.
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
## VOD Management
From the dashboard:
From the dashboard:
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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";
@@ -2692,3 +2692,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
@@ -2692,3 +2692,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
.bcs-viewer-head > div { display: grid; }
.bcs-viewer-head > div { display: grid; }
.bcs-viewer-head small { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; }
.bcs-viewer-head small { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; }
.bcs-viewer-frame { flex: 1; width: 100%; border: 0; background: #000; }
.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; }
+}
@@ -755,7 +755,7 @@ WantedBy=multi-user.target</code>
@@ -755,7 +755,7 @@ WantedBy=multi-user.target</code>
</div>
</div>
</div>
</div>
<div class="form-group" style="margin-top:10px">
<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>
<div class="vod-clip-actions">
<div class="vod-clip-actions">
<button class="btn btn-outline" onclick="setBroadcastClipLast30()">
<button class="btn btn-outline" onclick="setBroadcastClipLast30()">
@@ -205,8 +205,18 @@
@@ -205,8 +205,18 @@
else { try { _bcClipEnd = end; _bcClipStart = Math.max(0, end - seconds); } catch { /* the panel's own defaults stand */ } }
else { try { _bcClipEnd = end; _bcClipStart = Math.max(0, end - seconds); } catch { /* the panel's own defaults stand */ } }
const title = $('bc-clip-title');
const title = $('bc-clip-title');
if (title) title.value = '';
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.) */
/** Copies the channel link. (The system share sheet takes a phone away from this tab, which pauses the camera.) */
@@ -5445,7 +5445,7 @@ function _updateBroadcastClipUI() {
@@ -5445,7 +5445,7 @@ function _updateBroadcastClipUI() {
async function createBroadcastClip() {
async function createBroadcastClip() {
const ss = getActiveStreamState();
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)
// Auto-retry if VOD not ready yet (up to 3 attempts, 2s apart)
if (!ss.vodId) {
if (!ss.vodId) {
@@ -5457,15 +5457,17 @@ async function createBroadcastClip() {
@@ -5457,15 +5457,17 @@ async function createBroadcastClip() {
if (retry?.vodId) { ss.vodId = retry.vodId; break; }
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 (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;
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');
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…'; }
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }
@@ -5478,22 +5480,25 @@ async function createBroadcastClip() {
@@ -5478,22 +5480,25 @@ async function createBroadcastClip() {
stream_id: ss.streamData?.id,
stream_id: ss.streamData?.id,
start_time: _bcClipStart,
start_time: _bcClipStart,
end_time: _bcClipEnd,
end_time: _bcClipEnd,
- title,
+ title: typed,
}),
}),
});
});
const data = await res.json();
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create clip');
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 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) {
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, {
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
// Reset panel
@@ -5504,14 +5509,90 @@ async function createBroadcastClip() {
@@ -5504,14 +5509,90 @@ async function createBroadcastClip() {
_bcClipEnd = now;
_bcClipEnd = now;
_bcClipStart = Math.max(0, now - 30);
_bcClipStart = Math.max(0, now - 30);
_updateBroadcastClipUI();
_updateBroadcastClipUI();
+ return made;
} catch (err) {
} catch (err) {
console.error('[Clip] Error:', 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 {
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-scissors"></i> Create Clip'; }
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 ────────────────────────────────────── */
/* ── /Live Clip Creator ────────────────────────────────────── */
/* ── PiP Drag & Resize on Preview ─────────────────────────── */
/* ── PiP Drag & Resize on Preview ─────────────────────────── */
@@ -3217,8 +3217,8 @@ function _buildClipTitleModal(overlay, clipId) {
@@ -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.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 = `
modal.innerHTML = `
<h3 style="margin:0 0 8px 0"><i class="fa-solid fa-scissors"></i> Name Your Clip</h3>
<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">
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">
<div style="display:flex;gap:8px;justify-content:center">
<button id="live-clip-title-save" class="btn btn-primary" style="flex:1">
<button id="live-clip-title-save" class="btn btn-primary" style="flex:1">
@@ -3237,12 +3237,7 @@ function _buildClipTitleModal(overlay, clipId) {
@@ -3237,12 +3237,7 @@ function _buildClipTitleModal(overlay, clipId) {
const saveTitle = async () => {
const saveTitle = async () => {
const title = input.value.trim();
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 {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token');
const resp = await fetch(`/api/clips/${clipId}/title`, {
const resp = await fetch(`/api/clips/${clipId}/title`, {
@@ -3258,7 +3253,7 @@ function _buildClipTitleModal(overlay, clipId) {
@@ -3258,7 +3253,7 @@ function _buildClipTitleModal(overlay, clipId) {
overlay.remove();
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-save').onclick = saveTitle;
document.getElementById('live-clip-title-skip').onclick = skip;
document.getElementById('live-clip-title-skip').onclick = skip;
@@ -3325,7 +3320,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -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>
<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%">
<input type="range" id="clip-trim-end" min="0" max="${dur}" step="0.1" value="${dur}" style="width:100%">
</div>
</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">
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">
<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>
<button id="clip-trim-skip" class="btn" style="opacity:0.7">Skip</button>
@@ -3345,7 +3340,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -3345,7 +3340,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
const titleInput = modal.querySelector('#clip-trim-title');
const titleInput = modal.querySelector('#clip-trim-title');
const saveBtn = modal.querySelector('#clip-trim-save');
const saveBtn = modal.querySelector('#clip-trim-save');
const skipBtn = modal.querySelector('#clip-trim-skip');
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 MIN_LEN = 1;
const refresh = () => {
const refresh = () => {
@@ -360,6 +360,7 @@ async function generateClipOverview(clip) {
@@ -360,6 +360,7 @@ async function generateClipOverview(clip) {
const speech = clipOverviewSpeech(r);
const speech = clipOverviewSpeech(r);
try { await db.setClipAiOverview(clip.id, { overview, transcript: transcript || null, segments: speech.segments }); } catch { /* */ }
try { await db.setClipAiOverview(clip.id, { overview, transcript: transcript || null, segments: speech.segments }); } catch { /* */ }
await require('./explicit').guardAutoClip(clip, overview);
await require('./explicit').guardAutoClip(clip, overview);
+ require('./clip-title').soon(clip.id);
try { if (speech.status) await db.setClipTranscriptStatus(clip.id, speech.status); } catch { /* */ }
try { if (speech.status) await db.setClipTranscriptStatus(clip.id, speech.status); } catch { /* */ }
return { overview: r ? r.overview : null, transcript };
return { overview: r ? r.overview : null, transcript };
}
}
@@ -73,6 +73,7 @@ async function analyze(kind, item) {
@@ -73,6 +73,7 @@ async function analyze(kind, item) {
else {
else {
await db.setClipAiOverview(item.id, { overview });
await db.setClipAiOverview(item.id, { overview });
await require('./explicit').guardAutoClip(item, 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)));
const partialSpeech = Array.isArray(out.gaps) && out.gaps.some(g => /^Speech-to-text failed/i.test(String(g)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
@@ -1457,6 +1457,7 @@ async function start() {
@@ -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/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/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/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); }
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).
// 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); }
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
@@ -14,6 +14,9 @@ const media = require('../media-client');
@@ -14,6 +14,9 @@ const media = require('../media-client');
const GRACE_SECONDS = 60;
const GRACE_SECONDS = 60;
const SWEEP_INTERVAL_MS = 15000;
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) {
async function scheduleClipNotify(clipId) {
try { await db.scheduleClipNotifyState(clipId, GRACE_SECONDS); }
try { await db.scheduleClipNotifyState(clipId, GRACE_SECONDS); }
@@ -46,6 +49,8 @@ async function _sendOne(clipId) {
@@ -46,6 +49,8 @@ async function _sendOne(clipId) {
} catch { /* fall through and notify */ }
} catch { /* fall through and notify */ }
}
}
+ if (await _waitForTitle(clip, srcStream)) return;
+
const ownerId = srcStream.user_id;
const ownerId = srcStream.user_id;
const creator = clip.user_id ? await db.getUserById(clip.user_id) : null;
const creator = clip.user_id ? await db.getUserById(clip.user_id) : null;
const creatorName = (creator && (creator.display_name || creator.username)) || 'Someone';
const creatorName = (creator && (creator.display_name || creator.username)) || 'Someone';
@@ -78,6 +83,16 @@ async function _sendOne(clipId) {
@@ -78,6 +83,16 @@ async function _sendOne(clipId) {
await db.markClipNotifiedState(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;
let _sweepTimer = null;
// Clips whose announce is currently being sent. A slow Media getClip() once let
// 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
// six 15s sweep ticks stack up on the same clip and announce it six times when
@@ -99,4 +114,4 @@ function startClipNotifySweeper() {
@@ -99,4 +114,4 @@ function startClipNotifySweeper() {
console.log('[ClipNotify] sweeper started (Media-backed clips)');
console.log('[ClipNotify] sweeper started (Media-backed clips)');
}
}
-module.exports = { scheduleClipNotify, bumpClipNotifyNow, startClipNotifySweeper };
+module.exports = { scheduleClipNotify, bumpClipNotifyNow, startClipNotifySweeper, _sendOne, _waitForTitle, TITLE_WAIT_SECONDS };
@@ -312,6 +312,8 @@ router.put('/:id/title', requireAuth, async (req, res) => {
@@ -312,6 +312,8 @@ router.put('/:id/title', requireAuth, async (req, res) => {
const title = (req.body.title || '').trim();
const title = (req.body.title || '').trim();
if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
if (!title || title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
await media.updateClip(clip.id, { title });
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.
// If the clip's chat announcement is still pending, fire it now with this title.
try { await require('./clip-notify').bumpClipNotifyNow(clip.id); } catch { /* */ }
try { await require('./clip-notify').bumpClipNotifyNow(clip.id); } catch { /* */ }
res.json({ message: 'Clip title updated', title });
res.json({ message: 'Clip title updated', title });
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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); });