openvibers/openvibe.live Commits
Automatic clips: only good moments, cut on sentences (clip score + trim) (#442)
Changes
@@@ -213,6 +213,7 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image- **Flavoured picks** — `findBestMoment(vod, { flavor, avoid })`: `paste` asks for a frame that is striking on its own (a face, a gag, something odd on screen); `clip` asks for a beat that plays out over 25 s (a line, a reaction, a sound, chat exploding). The prompt lists the already-used timestamps and the model is told to stay away from them; if it still lands next to one, the objective signals (viewer clips, chat spikes, richest scene notes) pick a free spot.- The paste job no longer clips the paste's own moment; it asks for a **second, different beat** from the same VOD for the clip. The clip backfill avoids every offset a paste used.- **Live pastes** — the stream-memory vision call now also answers "is this frame screenshot-worthy?" with a caption (no extra call). When it says yes, `stream-memory-job` posts the frame as an image paste right away (≤ 1 per stream per 90 min, dark-frame and registry checks, setting `ai_live_pastes_enabled`, default on). These pastes carry `metadata.live = true` and no VOD link.++- **Only good live clips, cut on sentences** — a live chat spike is not enough on its own. `server/ai/clip-score.js` scores the moment from 0 to 1: the spike (how far above the window's average and how big), how many people reacted, what was heard (laughter, cheering, a bang; music and speech alone count for nothing), how much was said, and the AI's yes or no (a no is a veto; with the AI off its part is neutral and both bars rise by 0.1). Below **0.40** no clip is cut, the log says why ("a weak chat spike (10 messages, 3.1× the average)"). From 0.40 the clip is made **unlisted**: the streamer can find it and make it public. Only **0.65** and up is cut **public**. `server/ai/clip-trim.js` then moves the fixed 16 s before / 9 s after window onto the speech rows: the start goes back to where the sentence being spoken began (so the setup is in), at most 25 s before the moment; the end goes on to where the sentence ends, at most 20 s after, never past the recording's flushed edge. A snap that would make the clip shorter than 12 s or longer than 45 s is not taken. Both are pure, with their cases in `test/clip-score-trim.test.js`.- **Fewer tokens** — VOD showcase scores are cached for 7 days (`home_hero_moments.rankCache`); a run only scores VODs it has never scored. Every `llm.complete` call keeps its `kind` so `ai_usage` shows exactly where the budget goes (`moment_vod_rank`, `moment_pick`, `moment_frame`, `stream_memory`, `auto_clip_confirm`).## AI-inferred category@@@ -24,6 +24,8 @@ const recorder = require('../streaming/recorder');const registry = require('./moment-registry');const clipPolicy = require('./clip-policy');const dedupe = require('./clip-dedupe');++const clipScore = require('./clip-score');++const clipTrim = require('./clip-trim');// ── Tunables ────────────────────────────────────────────────const CHECK_INTERVAL_MS = 90 * 1000;@@@ -32,8 +34,6 @@ const MIN_SPACING_MIN = 12;const WINDOW_SEC = 150; // chunk of time considered each checkconst BUCKET_SEC = 15;const SPIKE_MIN_MSGS = 6; // a spike bucket must have at least this many messages--const SPIKE_MULT = 2.5; // …and be this many× the window average--const SPIKE_MULT_NOAI = 4; // stricter when we can't get AI agreementconst CLIP_PRE = 16, CLIP_POST = 9; // seconds cut before/after the momentconst CLIP_LOG_SETTING = 'auto_clip_log'; // rolling log of auto-clips (caps + dedup)@@@ -76,7 +76,7 @@ async function _confirmLiveMoment(stream) {const memories = (await db.getStreamMemories(stream.id) || []).filter(m => m.description).slice(-5);const transcript = (await db.getStreamTranscriptSegments(stream.id) || []).slice(-14);const chat = await chatReads.recentChatText(stream.id, WINDOW_SEC, 40) || [];-- if (!await _aiOn()) return { clip: null, transcript }; // caller decides via the stricter no-AI threshold++ if (!await _aiOn()) return { clip: null, transcript, sounds: [] }; // the score treats the AI as neutral and raises its bars// Sound events are a strong clip signal on their own — a burst of gunfire, an explosion or laughter is exactly// the kind of thing viewers clip, and it is often the reason chat spiked in the first place.let sounds = [];@@@ -94,9 +94,9 @@ async function _confirmLiveMoment(stream) {sounds: sounds.map(e => ({ t: Math.max(0, Number(e.start_sec) || 0), label: String(e.label || '').slice(0, 120), confidence: Number(e.confidence || 0) })),chat: chat.slice(-30).map(c => _clean(c, 100)).filter(Boolean),}, { target: { service: 'live', type: 'stream', id: String(stream.id) }, meter: { kind: 'auto_clip_confirm', role: 'legacy', ownerUserId: stream.user_id } });-- if (!j) return { clip: false, transcript };-- return { clip: j.clip === true, title: _clean(j.title, 80), desc: _clean(j.desc, 400), transcript };-- } catch { return { clip: false, transcript }; }++ if (!j) return { clip: false, transcript, sounds };++ return { clip: j.clip === true, title: _clean(j.title, 80), desc: _clean(j.desc, 400), transcript, sounds };++ } catch { return { clip: false, transcript, sounds }; }}/** What was said around a stream second: the registry uses it so a static camera alone is not a repeat. */@@@ -129,15 +129,10 @@ async function _checkLiveStream(stream) {const peak = buckets.reduce((mx, b) => (b.count > mx.count ? b : mx), buckets[0]);if (peak.count < SPIKE_MIN_MSGS) return;-- // AI agreement (or a much stronger spike when AI is unavailable).++ // Below the old floor the AI is not even asked (it costs a call). Above it, one score decides (server/ai/clip-score.js).++ if (peak.count < Math.max(SPIKE_MIN_MSGS, 1.5 * avg)) return;const verdict = await _confirmLiveMoment(stream);-- if (verdict.clip === true) {-- if (peak.count < Math.max(SPIKE_MIN_MSGS, SPIKE_MULT * avg)) return;-- } else if (verdict.clip === null) { // AI off → stricter chat-only gate-- if (peak.count < Math.max(SPIKE_MIN_MSGS, SPIKE_MULT_NOAI * avg)) return;-- } else {-- return; // AI said not clip-worthy-- }++ if (verdict.clip === false) return; // the AI's no is a veto// Locate the spike in recording time: how long ago it happened vs the live edge.const nowEpoch = Math.floor(Date.now() / 1000);@@@ -145,8 +140,7 @@ async function _checkLiveStream(stream) {const recEdge = Math.max(0, (Date.now() - rec.startedAt) / 1000);const momentOffset = Math.max(1, recEdge - secondsAgo);const start = Math.max(0, momentOffset - CLIP_PRE);-- const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));-- if (end - start < 5) return; // not enough flushed footage around the moment yet++ if (Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5)) - start < 5) return; // not enough flushed footage around the moment yet// A moment the AI describes as sexual content is never clipped (server/ai/explicit.js).if (require('./explicit').isExplicit(verdict.desc, verdict.title)) { console.log(`[AutoClip] stream ${streamId}: skipped a moment the AI describes as sexual content`); return; }@@@ -156,18 +150,27 @@ async function _checkLiveStream(stream) {let streamSec = momentOffset; // transcripts are in stream time; momentOffset is in the VOD's timetry { const part = await require('../streaming/stream-history').partForVod(rec.vodId); if (part && Number.isFinite(Number(part.at))) streamSec = momentOffset + Number(part.at); } catch { /* one-part recording */ }const speech = _speechNear(verdict.transcript, streamSec);++ // The score: no clip beats a bad clip; a fair one is kept unlisted for the streamer; a clear one is public.++ const near = _speechNear(verdict.transcript, streamSec, 20);++ const rated = clipScore.score({ spike: { peak: peak.count, avg }, sounds: verdict.sounds, speechWords: near ? near.split(/\s+/).length : 0, ai: { clip: verdict.clip } });++ if (rated.verdict === 'skip') { console.log(`[AutoClip] stream ${streamId}: skipped a weak moment (score ${rated.score}${rated.reasons.length ? `: ${rated.reasons.join('; ')}` : ''})`); return; }const why = await registry.usedReason({ vod_id: rec.vodId, stream_id: streamId, offset: momentOffset, desc: verdict.desc, title, speech });if (why) { console.log(`[AutoClip] stream ${streamId}: ${why}`); return; }++ // Start and end on sentences (server/ai/clip-trim.js): rows are in stream time, the cut in recording time.++ const shift = streamSec - momentOffset;++ const cut = clipTrim.trim({ moment: streamSec, segments: verdict.transcript, pre: CLIP_PRE, post: CLIP_POST, limit: Math.max(0, recEdge - 0.5) + shift });++ const clipStart = Math.max(0, cut.start - shift), clipEnd = cut.end - shift;const clip = await media.createClip({-- vod_id: rec.vodId, start_s: start, end_s: end,++ vod_id: rec.vodId, start_s: clipStart, end_s: clipEnd,title, user_id: stream.user_id,stream_id: streamId, auto_generated: true, description: db.noLooks(verdict.desc || ''),++ visibility: rated.verdict === 'public' ? 'public' : 'unlisted',}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });if (clip) {// deduplicated: Media handed back a clip someone already cut of this window. It stays in// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).-- await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title) });-- console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs`);++ await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted' });++ console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score} → ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);}} catch (e) {console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++/**++ * How good an automatic clip would be, before it is cut (owner 2026-10-29: "some of the clips suck … needs to be more++ * intelligent"). The live cutter used to cut whenever chat crossed a spike ratio and the AI said yes, so a moment that++ * only just cleared the bar became a public clip like any other. This module turns what the cutter already gathers into++ * one score from 0 to 1, with its reasons, and a verdict:++ *++ * skip — below the minimum bar: no clip at all ("no clip beats a bad clip");++ * unlisted — worth keeping, not worth the public feed: the streamer can still find it and make it public;++ * public — a clear moment.++ *++ * score({ spike: { peak, avg }, chatters, sounds: [{ label, confidence }], speechWords, ai: { clip } })++ * → { score, verdict, parts, reasons }++ *++ * The AI's "no" is a veto. With the AI off (ai.clip === null) its part is neutral and both bars rise, as the cutter's++ * old no-AI rule required a stronger spike. Pure: no I/O, so the bars can be tuned against labelled moments.++ */++++// keep 0.40 is where the old rule's borderline clips land (a 2.5× spike the AI agreed with): they are kept, unlisted;++// public needs a clear moment. Calibrate both against C01's production audit.++const BARS = Object.freeze({ keep: 0.4, public: 0.65, noAiRaise: 0.1 });++const WEIGHTS = Object.freeze({ spike: 0.35, breadth: 0.15, sound: 0.15, speech: 0.1, ai: 0.25 });++// Sounds people clip for: laughter, a crowd, a bang. Speech and music alone are not a moment.++const STRONG_SOUND = /laugh|cheer|applause|clap|crowd|scream|shout|explosion|gunshot|gunfire|boom|crash|siren|whistle|gasp/i;++++const clamp = (v) => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);++const ramp = (v, lo, hi) => clamp((Number(v) - lo) / (hi - lo));++++function score({ spike = {}, chatters = null, sounds = [], speechWords = 0, ai = {} } = {}) {++ const peak = Math.max(0, Number(spike.peak) || 0);++ const avg = Math.max(0, Number(spike.avg) || 0);++ const ratio = peak / Math.max(1, avg);++ // A spike is strong when it is far above the window's average AND big in absolute terms (6 messages in a quiet++ // room is a ratio of 6 but a weak signal; 40 in a busy one at 2× is a real one).++ const spikePart = 0.6 * ramp(ratio, 1.5, 6) + 0.4 * ramp(peak, 6, 30);++ // How many different people reacted: one excited viewer is not a moment. Unknown → it follows the spike.++ const breadthPart = chatters == null ? spikePart : ramp(chatters, 2, 10);++ const loud = (sounds || []).filter((s) => s && STRONG_SOUND.test(String(s.label || '')));++ const soundPart = loud.length ? clamp(Math.max(...loud.map((s) => Number(s.confidence) || 0))) : 0;++ const speechPart = ramp(speechWords, 0, 40);++ const aiPart = ai.clip === true ? 1 : ai.clip === false ? 0 : 0.5;++ const parts = { spike: spikePart, breadth: breadthPart, sound: soundPart, speech: speechPart, ai: aiPart };++ const total = Object.keys(WEIGHTS).reduce((sum, k) => sum + WEIGHTS[k] * parts[k], 0);++ const raise = ai.clip == null ? BARS.noAiRaise : 0;++ const reasons = [];++ if (ai.clip === false) reasons.push('the AI said it is not a moment');++ if (spikePart < 0.3) reasons.push(`a weak chat spike (${peak} messages, ${ratio.toFixed(1)}× the average)`);++ if (chatters != null && chatters < 3) reasons.push(`only ${chatters} people reacted`);++ if (loud.length) reasons.push(`heard: ${loud.map((s) => s.label).slice(0, 3).join(', ')}`);++ let verdict;++ if (ai.clip === false) verdict = 'skip';++ else if (total >= BARS.public + raise) verdict = 'public';++ else if (total >= BARS.keep + raise) verdict = 'unlisted';++ else verdict = 'skip';++ return { score: Math.round(total * 1000) / 1000, verdict, parts, reasons };++}++++module.exports = { score, BARS, WEIGHTS, STRONG_SOUND };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * Where an automatic clip starts and ends. The cutter used a fixed 16 s before and 9 s after the moment, so clips began++ * and ended mid-sentence and often lost the setup that made the payoff land. Speech rows on the timeline are already++ * sentences (server/ai/transcribe.js ends a row at . ? !, a 1.5 s pause or 20 s), so a clip that starts where a row++ * starts and ends where a row ends starts and ends on a sentence.++ *++ * trim({ moment, segments: [{ start, end, text }], pre, post, min, max, maxLead, maxTail, limit })++ * → { start, end, snapped }++ *++ * - start: the start of the row being spoken at (moment - pre), or, if that second is silent, of the first row after it++ * and before the moment; a row that began more than maxLead before the moment++ * (a long monologue) is not followed back — the fixed start is kept.++ * - end: the end of the row being spoken at (moment + post), within maxTail after the moment and before `limit` (the++ * flushed edge of a live recording).++ * - Without speech near the moment, or with rows that would make the clip shorter than `min` or longer than `max`,++ * the fixed window stands (clamped), so trimming never makes a clip worse than before.++ * All times are in one clock (the caller converts between stream and recording time). Pure.++ */++++const DEFAULTS = Object.freeze({ pre: 16, post: 9, min: 12, max: 45, maxLead: 25, maxTail: 20 });++++function rowEnd(rows, i) {++ const r = rows[i];++ if (r.end != null && Number.isFinite(Number(r.end))) return Number(r.end);++ const next = rows[i + 1];++ return next ? Number(next.start) : Number(r.start) + 6; // no end recorded: until the next row, or a short sentence++}++++function trim({ moment, segments = [], limit = Infinity, ...o } = {}) {++ const k = { ...DEFAULTS, ...o };++ const m = Number(moment);++ const fixedStart = Math.max(0, m - k.pre);++ const fixedEnd = Math.min(m + k.post, limit);++ const fixed = { start: fixedStart, end: fixedEnd, snapped: false };++ if (!Number.isFinite(m)) return fixed;++ const rows = (segments || [])++ .filter((r) => r && Number.isFinite(Number(r.start)) && String(r.text || '').trim())++ .map((r) => ({ start: Number(r.start), end: r.end, text: r.text }))++ .sort((a, b) => a.start - b.start);++ if (!rows.length) return fixed;++ const at = (t) => { let idx = -1; for (let i = 0; i < rows.length; i++) { if (rows[i].start <= t && rowEnd(rows, i) > t) idx = i; } return idx; };++++ let start = fixedStart, end = fixedEnd, snapped = false;++ const si = at(fixedStart);++ if (si >= 0 && rows[si].start >= m - k.maxLead) { start = Math.max(0, rows[si].start); snapped = true; }++ else if (si < 0) {++ // The fixed start is in silence: begin at the first sentence that starts between it and the moment.++ const next = rows.find((r) => r.start > fixedStart && r.start < m);++ if (next) { start = next.start; snapped = true; }++ }++ const ei = at(Math.min(m + k.post, limit - 0.01));++ if (ei >= 0) {++ const e = Math.min(rowEnd(rows, ei), limit);++ if (e <= m + k.maxTail && e > start) { end = e; snapped = true; }++ }++ const len = end - start;++ if (len < k.min || len > k.max) return fixed;++ return { start: Math.round(start * 100) / 100, end: Math.round(end * 100) / 100, snapped };++}++++module.exports = { trim, DEFAULTS };@@@ -67,9 +67,10 @@ media.createClip = async (body) => { created.push(body); return { id: 900 + creaconst recorder = require('../server/streaming/recorder');recorder.getActiveRecording = () => ({ vodId: 60, startedAt: Date.now() - 100_000 });const chatReads = require('../server/chat/chat-reads');++// A spike clip-score keeps (40 messages; 10 scores as a weak moment and is skipped before the registry is asked).chatReads.liveChatBuckets = async () => [{ count: 1, tsEpoch: Math.floor(Date.now() / 1000) },-- { count: 1 }, { count: 10, tsEpoch: Math.floor(Date.now() / 1000) }, { count: 1 },++ { count: 1 }, { count: 40, tsEpoch: Math.floor(Date.now() / 1000) }, { count: 1 },];chatReads.recentChatText = async () => [];const ai = require('../server/ai/ai-analysis');nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++// server/ai/clip-score.js and server/ai/clip-trim.js: an automatic clip is cut only when the moment scores above a bar++// (public above a higher one, unlisted between), and starts and ends on sentences.++const assert = require('assert');++const { score, BARS } = require('../server/ai/clip-score');++const { trim } = require('../server/ai/clip-trim');++++// ── Scoring ──++{++ const strong = score({ spike: { peak: 34, avg: 5 }, chatters: 12, sounds: [{ label: 'Laughter', confidence: 0.9 }], speechWords: 35, ai: { clip: true } });++ assert.strictEqual(strong.verdict, 'public', JSON.stringify(strong));++ assert.ok(strong.score >= BARS.public && strong.reasons.some((r) => /Laughter/.test(r)), 'a big, broad spike with laughter is public, and says what it heard');++++ const fair = score({ spike: { peak: 14, avg: 5 }, chatters: 5, sounds: [], speechWords: 20, ai: { clip: true } });++ assert.strictEqual(fair.verdict, 'unlisted', `a fair moment is kept unlisted (${fair.score})`);++++ const weak = score({ spike: { peak: 7, avg: 4 }, chatters: 2, sounds: [], speechWords: 3, ai: { clip: true } });++ assert.strictEqual(weak.verdict, 'skip', `the AI saying yes does not save a weak moment (${weak.score})`);++ assert.ok(weak.reasons.some((r) => /weak chat spike/.test(r)) && weak.reasons.some((r) => /only 2 people/.test(r)), 'it says why');++++ const vetoed = score({ spike: { peak: 60, avg: 5 }, chatters: 20, sounds: [{ label: 'Cheering', confidence: 1 }], speechWords: 40, ai: { clip: false } });++ assert.strictEqual(vetoed.verdict, 'skip', "the AI's no is a veto, however loud the room");++++ const aiOff = score({ spike: { peak: 14, avg: 5 }, chatters: 5, speechWords: 20, ai: { clip: null } });++ const aiOn = score({ spike: { peak: 14, avg: 5 }, chatters: 5, speechWords: 20, ai: { clip: true } });++ assert.ok(aiOff.score < aiOn.score && aiOff.verdict === 'skip', 'with the AI off its part is neutral and the bars rise');++++ const music = score({ spike: { peak: 20, avg: 5 }, sounds: [{ label: 'Music', confidence: 1 }, { label: 'Speech', confidence: 1 }], ai: { clip: true } });++ assert.strictEqual(music.parts.sound, 0, 'music and speech are not a moment by themselves');++ assert.strictEqual(score({ spike: { peak: 20, avg: 5 }, ai: { clip: true } }).parts.breadth, score({ spike: { peak: 20, avg: 5 }, ai: { clip: true } }).parts.spike, 'unknown chatters follow the spike');++ assert.strictEqual(score().verdict, 'skip', 'nothing is nothing');++}++++// ── Trimming ──++{++ const rows = [++ { start: 100, end: 106.5, text: 'So I said we should try the bridge.' },++ { start: 106.5, end: 112, text: 'Everyone told me it would not hold.' },++ { start: 112, end: 117.4, text: 'And then it held, and we all screamed!' },++ { start: 118, end: 124, text: 'Look at chat right now.' },++ ];++ // The moment is the scream at 115: the fixed window is 99..124; snapped to rows it is 100 (the setup) .. 124.++ const t = trim({ moment: 115, segments: rows });++ assert.deepStrictEqual([t.start, t.end, t.snapped], [100, 124, true], JSON.stringify(t));++ // A row without an end runs to the next row's start.++ const noEnd = trim({ moment: 115, segments: rows.map((r) => ({ ...r, end: null })) });++ assert.strictEqual(noEnd.start, 100);++ // The live edge caps the end.++ const edge = trim({ moment: 115, segments: rows, limit: 119 });++ assert.ok(edge.end <= 119, `never past the flushed edge (${edge.end})`);++ // No speech near the moment: the fixed window, unchanged.++ assert.deepStrictEqual(trim({ moment: 300, segments: rows }), { start: 284, end: 309, snapped: false });++ assert.deepStrictEqual(trim({ moment: 300, segments: [] }), { start: 284, end: 309, snapped: false });++ // A monologue row that began long before the moment is not followed back (more than maxLead).++ const mono = trim({ moment: 150, segments: [{ start: 100, end: 160, text: 'A very long uninterrupted rant about everything at once.' }] });++ assert.strictEqual(mono.start, 134, 'the fixed start stays when the row began too early');++ // A snapped window outside min..max falls back to the fixed one.++ const long = trim({ moment: 115, segments: rows, max: 20 });++ assert.deepStrictEqual([long.start, long.end, long.snapped], [99, 124, false], 'a snap that makes the clip too long is not taken');++ assert.strictEqual(trim({ moment: 5, segments: rows }).start, 0, 'never before the start of the recording');++}++++console.log('clip score and trim: all checks passed');
@@ -213,6 +213,7 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image
@@ -213,6 +213,7 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image
- **Flavoured picks** — `findBestMoment(vod, { flavor, avoid })`: `paste` asks for a frame that is striking on its own (a face, a gag, something odd on screen); `clip` asks for a beat that plays out over 25 s (a line, a reaction, a sound, chat exploding). The prompt lists the already-used timestamps and the model is told to stay away from them; if it still lands next to one, the objective signals (viewer clips, chat spikes, richest scene notes) pick a free spot.
- **Flavoured picks** — `findBestMoment(vod, { flavor, avoid })`: `paste` asks for a frame that is striking on its own (a face, a gag, something odd on screen); `clip` asks for a beat that plays out over 25 s (a line, a reaction, a sound, chat exploding). The prompt lists the already-used timestamps and the model is told to stay away from them; if it still lands next to one, the objective signals (viewer clips, chat spikes, richest scene notes) pick a free spot.
- The paste job no longer clips the paste's own moment; it asks for a **second, different beat** from the same VOD for the clip. The clip backfill avoids every offset a paste used.
- The paste job no longer clips the paste's own moment; it asks for a **second, different beat** from the same VOD for the clip. The clip backfill avoids every offset a paste used.
- **Live pastes** — the stream-memory vision call now also answers "is this frame screenshot-worthy?" with a caption (no extra call). When it says yes, `stream-memory-job` posts the frame as an image paste right away (≤ 1 per stream per 90 min, dark-frame and registry checks, setting `ai_live_pastes_enabled`, default on). These pastes carry `metadata.live = true` and no VOD link.
- **Live pastes** — the stream-memory vision call now also answers "is this frame screenshot-worthy?" with a caption (no extra call). When it says yes, `stream-memory-job` posts the frame as an image paste right away (≤ 1 per stream per 90 min, dark-frame and registry checks, setting `ai_live_pastes_enabled`, default on). These pastes carry `metadata.live = true` and no VOD link.
+- **Only good live clips, cut on sentences** — a live chat spike is not enough on its own. `server/ai/clip-score.js` scores the moment from 0 to 1: the spike (how far above the window's average and how big), how many people reacted, what was heard (laughter, cheering, a bang; music and speech alone count for nothing), how much was said, and the AI's yes or no (a no is a veto; with the AI off its part is neutral and both bars rise by 0.1). Below **0.40** no clip is cut, the log says why ("a weak chat spike (10 messages, 3.1× the average)"). From 0.40 the clip is made **unlisted**: the streamer can find it and make it public. Only **0.65** and up is cut **public**. `server/ai/clip-trim.js` then moves the fixed 16 s before / 9 s after window onto the speech rows: the start goes back to where the sentence being spoken began (so the setup is in), at most 25 s before the moment; the end goes on to where the sentence ends, at most 20 s after, never past the recording's flushed edge. A snap that would make the clip shorter than 12 s or longer than 45 s is not taken. Both are pure, with their cases in `test/clip-score-trim.test.js`.
- **Fewer tokens** — VOD showcase scores are cached for 7 days (`home_hero_moments.rankCache`); a run only scores VODs it has never scored. Every `llm.complete` call keeps its `kind` so `ai_usage` shows exactly where the budget goes (`moment_vod_rank`, `moment_pick`, `moment_frame`, `stream_memory`, `auto_clip_confirm`).
- **Fewer tokens** — VOD showcase scores are cached for 7 days (`home_hero_moments.rankCache`); a run only scores VODs it has never scored. Every `llm.complete` call keeps its `kind` so `ai_usage` shows exactly where the budget goes (`moment_vod_rank`, `moment_pick`, `moment_frame`, `stream_memory`, `auto_clip_confirm`).
## AI-inferred category
## AI-inferred category
@@ -24,6 +24,8 @@ const recorder = require('../streaming/recorder');
@@ -24,6 +24,8 @@ const recorder = require('../streaming/recorder');
const registry = require('./moment-registry');
const registry = require('./moment-registry');
const clipPolicy = require('./clip-policy');
const clipPolicy = require('./clip-policy');
const dedupe = require('./clip-dedupe');
const dedupe = require('./clip-dedupe');
+const clipScore = require('./clip-score');
+const clipTrim = require('./clip-trim');
// ── Tunables ────────────────────────────────────────────────
// ── Tunables ────────────────────────────────────────────────
const CHECK_INTERVAL_MS = 90 * 1000;
const CHECK_INTERVAL_MS = 90 * 1000;
@@ -32,8 +34,6 @@ const MIN_SPACING_MIN = 12;
@@ -32,8 +34,6 @@ const MIN_SPACING_MIN = 12;
const WINDOW_SEC = 150; // chunk of time considered each check
const WINDOW_SEC = 150; // chunk of time considered each check
const BUCKET_SEC = 15;
const BUCKET_SEC = 15;
const SPIKE_MIN_MSGS = 6; // a spike bucket must have at least this many messages
const SPIKE_MIN_MSGS = 6; // a spike bucket must have at least this many messages
-const SPIKE_MULT = 2.5; // …and be this many× the window average
-const SPIKE_MULT_NOAI = 4; // stricter when we can't get AI agreement
const CLIP_PRE = 16, CLIP_POST = 9; // seconds cut before/after the moment
const CLIP_PRE = 16, CLIP_POST = 9; // seconds cut before/after the moment
const CLIP_LOG_SETTING = 'auto_clip_log'; // rolling log of auto-clips (caps + dedup)
const CLIP_LOG_SETTING = 'auto_clip_log'; // rolling log of auto-clips (caps + dedup)
@@ -76,7 +76,7 @@ async function _confirmLiveMoment(stream) {
@@ -76,7 +76,7 @@ async function _confirmLiveMoment(stream) {
const memories = (await db.getStreamMemories(stream.id) || []).filter(m => m.description).slice(-5);
const memories = (await db.getStreamMemories(stream.id) || []).filter(m => m.description).slice(-5);
const transcript = (await db.getStreamTranscriptSegments(stream.id) || []).slice(-14);
const transcript = (await db.getStreamTranscriptSegments(stream.id) || []).slice(-14);
const chat = await chatReads.recentChatText(stream.id, WINDOW_SEC, 40) || [];
const chat = await chatReads.recentChatText(stream.id, WINDOW_SEC, 40) || [];
- if (!await _aiOn()) return { clip: null, transcript }; // caller decides via the stricter no-AI threshold
+ if (!await _aiOn()) return { clip: null, transcript, sounds: [] }; // the score treats the AI as neutral and raises its bars
// Sound events are a strong clip signal on their own — a burst of gunfire, an explosion or laughter is exactly
// Sound events are a strong clip signal on their own — a burst of gunfire, an explosion or laughter is exactly
// the kind of thing viewers clip, and it is often the reason chat spiked in the first place.
// the kind of thing viewers clip, and it is often the reason chat spiked in the first place.
let sounds = [];
let sounds = [];
@@ -94,9 +94,9 @@ async function _confirmLiveMoment(stream) {
@@ -94,9 +94,9 @@ async function _confirmLiveMoment(stream) {
sounds: sounds.map(e => ({ t: Math.max(0, Number(e.start_sec) || 0), label: String(e.label || '').slice(0, 120), confidence: Number(e.confidence || 0) })),
sounds: sounds.map(e => ({ t: Math.max(0, Number(e.start_sec) || 0), label: String(e.label || '').slice(0, 120), confidence: Number(e.confidence || 0) })),
chat: chat.slice(-30).map(c => _clean(c, 100)).filter(Boolean),
chat: chat.slice(-30).map(c => _clean(c, 100)).filter(Boolean),
}, { target: { service: 'live', type: 'stream', id: String(stream.id) }, meter: { kind: 'auto_clip_confirm', role: 'legacy', ownerUserId: stream.user_id } });
}, { target: { service: 'live', type: 'stream', id: String(stream.id) }, meter: { kind: 'auto_clip_confirm', role: 'legacy', ownerUserId: stream.user_id } });
- if (!j) return { clip: false, transcript };
- return { clip: j.clip === true, title: _clean(j.title, 80), desc: _clean(j.desc, 400), transcript };
- } catch { return { clip: false, transcript }; }
+ if (!j) return { clip: false, transcript, sounds };
+ return { clip: j.clip === true, title: _clean(j.title, 80), desc: _clean(j.desc, 400), transcript, sounds };
+ } catch { return { clip: false, transcript, sounds }; }
}
}
/** What was said around a stream second: the registry uses it so a static camera alone is not a repeat. */
/** What was said around a stream second: the registry uses it so a static camera alone is not a repeat. */
@@ -129,15 +129,10 @@ async function _checkLiveStream(stream) {
@@ -129,15 +129,10 @@ async function _checkLiveStream(stream) {
const peak = buckets.reduce((mx, b) => (b.count > mx.count ? b : mx), buckets[0]);
const peak = buckets.reduce((mx, b) => (b.count > mx.count ? b : mx), buckets[0]);
if (peak.count < SPIKE_MIN_MSGS) return;
if (peak.count < SPIKE_MIN_MSGS) return;
- // AI agreement (or a much stronger spike when AI is unavailable).
+ // Below the old floor the AI is not even asked (it costs a call). Above it, one score decides (server/ai/clip-score.js).
+ if (peak.count < Math.max(SPIKE_MIN_MSGS, 1.5 * avg)) return;
const verdict = await _confirmLiveMoment(stream);
const verdict = await _confirmLiveMoment(stream);
- if (verdict.clip === true) {
- if (peak.count < Math.max(SPIKE_MIN_MSGS, SPIKE_MULT * avg)) return;
- } else if (verdict.clip === null) { // AI off → stricter chat-only gate
- if (peak.count < Math.max(SPIKE_MIN_MSGS, SPIKE_MULT_NOAI * avg)) return;
- } else {
- return; // AI said not clip-worthy
- }
+ if (verdict.clip === false) return; // the AI's no is a veto
// Locate the spike in recording time: how long ago it happened vs the live edge.
// Locate the spike in recording time: how long ago it happened vs the live edge.
const nowEpoch = Math.floor(Date.now() / 1000);
const nowEpoch = Math.floor(Date.now() / 1000);
@@ -145,8 +140,7 @@ async function _checkLiveStream(stream) {
@@ -145,8 +140,7 @@ async function _checkLiveStream(stream) {
const recEdge = Math.max(0, (Date.now() - rec.startedAt) / 1000);
const recEdge = Math.max(0, (Date.now() - rec.startedAt) / 1000);
const momentOffset = Math.max(1, recEdge - secondsAgo);
const momentOffset = Math.max(1, recEdge - secondsAgo);
const start = Math.max(0, momentOffset - CLIP_PRE);
const start = Math.max(0, momentOffset - CLIP_PRE);
- const end = Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5));
- if (end - start < 5) return; // not enough flushed footage around the moment yet
+ if (Math.min(momentOffset + CLIP_POST, Math.max(0, recEdge - 0.5)) - start < 5) return; // not enough flushed footage around the moment yet
// A moment the AI describes as sexual content is never clipped (server/ai/explicit.js).
// A moment the AI describes as sexual content is never clipped (server/ai/explicit.js).
if (require('./explicit').isExplicit(verdict.desc, verdict.title)) { console.log(`[AutoClip] stream ${streamId}: skipped a moment the AI describes as sexual content`); return; }
if (require('./explicit').isExplicit(verdict.desc, verdict.title)) { console.log(`[AutoClip] stream ${streamId}: skipped a moment the AI describes as sexual content`); return; }
@@ -156,18 +150,27 @@ async function _checkLiveStream(stream) {
@@ -156,18 +150,27 @@ async function _checkLiveStream(stream) {
let streamSec = momentOffset; // transcripts are in stream time; momentOffset is in the VOD's time
let streamSec = momentOffset; // transcripts are in stream time; momentOffset is in the VOD's time
try { const part = await require('../streaming/stream-history').partForVod(rec.vodId); if (part && Number.isFinite(Number(part.at))) streamSec = momentOffset + Number(part.at); } catch { /* one-part recording */ }
try { const part = await require('../streaming/stream-history').partForVod(rec.vodId); if (part && Number.isFinite(Number(part.at))) streamSec = momentOffset + Number(part.at); } catch { /* one-part recording */ }
const speech = _speechNear(verdict.transcript, streamSec);
const speech = _speechNear(verdict.transcript, streamSec);
+ // The score: no clip beats a bad clip; a fair one is kept unlisted for the streamer; a clear one is public.
+ const near = _speechNear(verdict.transcript, streamSec, 20);
+ const rated = clipScore.score({ spike: { peak: peak.count, avg }, sounds: verdict.sounds, speechWords: near ? near.split(/\s+/).length : 0, ai: { clip: verdict.clip } });
+ if (rated.verdict === 'skip') { console.log(`[AutoClip] stream ${streamId}: skipped a weak moment (score ${rated.score}${rated.reasons.length ? `: ${rated.reasons.join('; ')}` : ''})`); return; }
const why = await registry.usedReason({ vod_id: rec.vodId, stream_id: streamId, offset: momentOffset, desc: verdict.desc, title, speech });
const why = await registry.usedReason({ vod_id: rec.vodId, stream_id: streamId, offset: momentOffset, desc: verdict.desc, title, speech });
if (why) { console.log(`[AutoClip] stream ${streamId}: ${why}`); return; }
if (why) { console.log(`[AutoClip] stream ${streamId}: ${why}`); return; }
+ // Start and end on sentences (server/ai/clip-trim.js): rows are in stream time, the cut in recording time.
+ const shift = streamSec - momentOffset;
+ const cut = clipTrim.trim({ moment: streamSec, segments: verdict.transcript, pre: CLIP_PRE, post: CLIP_POST, limit: Math.max(0, recEdge - 0.5) + shift });
+ const clipStart = Math.max(0, cut.start - shift), clipEnd = cut.end - shift;
const clip = await media.createClip({
const clip = await media.createClip({
- vod_id: rec.vodId, start_s: start, end_s: end,
+ vod_id: rec.vodId, start_s: clipStart, end_s: clipEnd,
title, user_id: stream.user_id,
title, user_id: stream.user_id,
stream_id: streamId, auto_generated: true, description: db.noLooks(verdict.desc || ''),
stream_id: streamId, auto_generated: true, description: db.noLooks(verdict.desc || ''),
+ visibility: rated.verdict === 'public' ? 'public' : 'unlisted',
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
if (clip) {
if (clip) {
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
- await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title) });
- console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs`);
+ await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted' });
+ console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score} → ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);
}
}
} catch (e) {
} catch (e) {
console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);
console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * How good an automatic clip would be, before it is cut (owner 2026-10-29: "some of the clips suck … needs to be more
+ * intelligent"). The live cutter used to cut whenever chat crossed a spike ratio and the AI said yes, so a moment that
+ * only just cleared the bar became a public clip like any other. This module turns what the cutter already gathers into
+ * one score from 0 to 1, with its reasons, and a verdict:
+ *
+ * skip — below the minimum bar: no clip at all ("no clip beats a bad clip");
+ * unlisted — worth keeping, not worth the public feed: the streamer can still find it and make it public;
+ * public — a clear moment.
+ *
+ * score({ spike: { peak, avg }, chatters, sounds: [{ label, confidence }], speechWords, ai: { clip } })
+ * → { score, verdict, parts, reasons }
+ *
+ * The AI's "no" is a veto. With the AI off (ai.clip === null) its part is neutral and both bars rise, as the cutter's
+ * old no-AI rule required a stronger spike. Pure: no I/O, so the bars can be tuned against labelled moments.
+ */
+
+// keep 0.40 is where the old rule's borderline clips land (a 2.5× spike the AI agreed with): they are kept, unlisted;
+// public needs a clear moment. Calibrate both against C01's production audit.
+const BARS = Object.freeze({ keep: 0.4, public: 0.65, noAiRaise: 0.1 });
+const WEIGHTS = Object.freeze({ spike: 0.35, breadth: 0.15, sound: 0.15, speech: 0.1, ai: 0.25 });
+// Sounds people clip for: laughter, a crowd, a bang. Speech and music alone are not a moment.
+const STRONG_SOUND = /laugh|cheer|applause|clap|crowd|scream|shout|explosion|gunshot|gunfire|boom|crash|siren|whistle|gasp/i;
+
+const clamp = (v) => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
+const ramp = (v, lo, hi) => clamp((Number(v) - lo) / (hi - lo));
+
+function score({ spike = {}, chatters = null, sounds = [], speechWords = 0, ai = {} } = {}) {
+ const peak = Math.max(0, Number(spike.peak) || 0);
+ const avg = Math.max(0, Number(spike.avg) || 0);
+ const ratio = peak / Math.max(1, avg);
+ // A spike is strong when it is far above the window's average AND big in absolute terms (6 messages in a quiet
+ // room is a ratio of 6 but a weak signal; 40 in a busy one at 2× is a real one).
+ const spikePart = 0.6 * ramp(ratio, 1.5, 6) + 0.4 * ramp(peak, 6, 30);
+ // How many different people reacted: one excited viewer is not a moment. Unknown → it follows the spike.
+ const breadthPart = chatters == null ? spikePart : ramp(chatters, 2, 10);
+ const loud = (sounds || []).filter((s) => s && STRONG_SOUND.test(String(s.label || '')));
+ const soundPart = loud.length ? clamp(Math.max(...loud.map((s) => Number(s.confidence) || 0))) : 0;
+ const speechPart = ramp(speechWords, 0, 40);
+ const aiPart = ai.clip === true ? 1 : ai.clip === false ? 0 : 0.5;
+ const parts = { spike: spikePart, breadth: breadthPart, sound: soundPart, speech: speechPart, ai: aiPart };
+ const total = Object.keys(WEIGHTS).reduce((sum, k) => sum + WEIGHTS[k] * parts[k], 0);
+ const raise = ai.clip == null ? BARS.noAiRaise : 0;
+ const reasons = [];
+ if (ai.clip === false) reasons.push('the AI said it is not a moment');
+ if (spikePart < 0.3) reasons.push(`a weak chat spike (${peak} messages, ${ratio.toFixed(1)}× the average)`);
+ if (chatters != null && chatters < 3) reasons.push(`only ${chatters} people reacted`);
+ if (loud.length) reasons.push(`heard: ${loud.map((s) => s.label).slice(0, 3).join(', ')}`);
+ let verdict;
+ if (ai.clip === false) verdict = 'skip';
+ else if (total >= BARS.public + raise) verdict = 'public';
+ else if (total >= BARS.keep + raise) verdict = 'unlisted';
+ else verdict = 'skip';
+ return { score: Math.round(total * 1000) / 1000, verdict, parts, reasons };
+}
+
+module.exports = { score, BARS, WEIGHTS, STRONG_SOUND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * Where an automatic clip starts and ends. The cutter used a fixed 16 s before and 9 s after the moment, so clips began
+ * and ended mid-sentence and often lost the setup that made the payoff land. Speech rows on the timeline are already
+ * sentences (server/ai/transcribe.js ends a row at . ? !, a 1.5 s pause or 20 s), so a clip that starts where a row
+ * starts and ends where a row ends starts and ends on a sentence.
+ *
+ * trim({ moment, segments: [{ start, end, text }], pre, post, min, max, maxLead, maxTail, limit })
+ * → { start, end, snapped }
+ *
+ * - start: the start of the row being spoken at (moment - pre), or, if that second is silent, of the first row after it
+ * and before the moment; a row that began more than maxLead before the moment
+ * (a long monologue) is not followed back — the fixed start is kept.
+ * - end: the end of the row being spoken at (moment + post), within maxTail after the moment and before `limit` (the
+ * flushed edge of a live recording).
+ * - Without speech near the moment, or with rows that would make the clip shorter than `min` or longer than `max`,
+ * the fixed window stands (clamped), so trimming never makes a clip worse than before.
+ * All times are in one clock (the caller converts between stream and recording time). Pure.
+ */
+
+const DEFAULTS = Object.freeze({ pre: 16, post: 9, min: 12, max: 45, maxLead: 25, maxTail: 20 });
+
+function rowEnd(rows, i) {
+ const r = rows[i];
+ if (r.end != null && Number.isFinite(Number(r.end))) return Number(r.end);
+ const next = rows[i + 1];
+ return next ? Number(next.start) : Number(r.start) + 6; // no end recorded: until the next row, or a short sentence
+}
+
+function trim({ moment, segments = [], limit = Infinity, ...o } = {}) {
+ const k = { ...DEFAULTS, ...o };
+ const m = Number(moment);
+ const fixedStart = Math.max(0, m - k.pre);
+ const fixedEnd = Math.min(m + k.post, limit);
+ const fixed = { start: fixedStart, end: fixedEnd, snapped: false };
+ if (!Number.isFinite(m)) return fixed;
+ const rows = (segments || [])
+ .filter((r) => r && Number.isFinite(Number(r.start)) && String(r.text || '').trim())
+ .map((r) => ({ start: Number(r.start), end: r.end, text: r.text }))
+ .sort((a, b) => a.start - b.start);
+ if (!rows.length) return fixed;
+ const at = (t) => { let idx = -1; for (let i = 0; i < rows.length; i++) { if (rows[i].start <= t && rowEnd(rows, i) > t) idx = i; } return idx; };
+
+ let start = fixedStart, end = fixedEnd, snapped = false;
+ const si = at(fixedStart);
+ if (si >= 0 && rows[si].start >= m - k.maxLead) { start = Math.max(0, rows[si].start); snapped = true; }
+ else if (si < 0) {
+ // The fixed start is in silence: begin at the first sentence that starts between it and the moment.
+ const next = rows.find((r) => r.start > fixedStart && r.start < m);
+ if (next) { start = next.start; snapped = true; }
+ }
+ const ei = at(Math.min(m + k.post, limit - 0.01));
+ if (ei >= 0) {
+ const e = Math.min(rowEnd(rows, ei), limit);
+ if (e <= m + k.maxTail && e > start) { end = e; snapped = true; }
+ }
+ const len = end - start;
+ if (len < k.min || len > k.max) return fixed;
+ return { start: Math.round(start * 100) / 100, end: Math.round(end * 100) / 100, snapped };
+}
+
+module.exports = { trim, DEFAULTS };
@@ -67,9 +67,10 @@ media.createClip = async (body) => { created.push(body); return { id: 900 + crea
@@ -67,9 +67,10 @@ media.createClip = async (body) => { created.push(body); return { id: 900 + crea
const recorder = require('../server/streaming/recorder');
const recorder = require('../server/streaming/recorder');
recorder.getActiveRecording = () => ({ vodId: 60, startedAt: Date.now() - 100_000 });
recorder.getActiveRecording = () => ({ vodId: 60, startedAt: Date.now() - 100_000 });
const chatReads = require('../server/chat/chat-reads');
const chatReads = require('../server/chat/chat-reads');
+// A spike clip-score keeps (40 messages; 10 scores as a weak moment and is skipped before the registry is asked).
chatReads.liveChatBuckets = async () => [
chatReads.liveChatBuckets = async () => [
{ count: 1, tsEpoch: Math.floor(Date.now() / 1000) },
{ count: 1, tsEpoch: Math.floor(Date.now() / 1000) },
- { count: 1 }, { count: 10, tsEpoch: Math.floor(Date.now() / 1000) }, { count: 1 },
+ { count: 1 }, { count: 40, tsEpoch: Math.floor(Date.now() / 1000) }, { count: 1 },
];
];
chatReads.recentChatText = async () => [];
chatReads.recentChatText = async () => [];
const ai = require('../server/ai/ai-analysis');
const ai = require('../server/ai/ai-analysis');
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+// server/ai/clip-score.js and server/ai/clip-trim.js: an automatic clip is cut only when the moment scores above a bar
+// (public above a higher one, unlisted between), and starts and ends on sentences.
+const assert = require('assert');
+const { score, BARS } = require('../server/ai/clip-score');
+const { trim } = require('../server/ai/clip-trim');
+
+// ── Scoring ──
+{
+ const strong = score({ spike: { peak: 34, avg: 5 }, chatters: 12, sounds: [{ label: 'Laughter', confidence: 0.9 }], speechWords: 35, ai: { clip: true } });
+ assert.strictEqual(strong.verdict, 'public', JSON.stringify(strong));
+ assert.ok(strong.score >= BARS.public && strong.reasons.some((r) => /Laughter/.test(r)), 'a big, broad spike with laughter is public, and says what it heard');
+
+ const fair = score({ spike: { peak: 14, avg: 5 }, chatters: 5, sounds: [], speechWords: 20, ai: { clip: true } });
+ assert.strictEqual(fair.verdict, 'unlisted', `a fair moment is kept unlisted (${fair.score})`);
+
+ const weak = score({ spike: { peak: 7, avg: 4 }, chatters: 2, sounds: [], speechWords: 3, ai: { clip: true } });
+ assert.strictEqual(weak.verdict, 'skip', `the AI saying yes does not save a weak moment (${weak.score})`);
+ assert.ok(weak.reasons.some((r) => /weak chat spike/.test(r)) && weak.reasons.some((r) => /only 2 people/.test(r)), 'it says why');
+
+ const vetoed = score({ spike: { peak: 60, avg: 5 }, chatters: 20, sounds: [{ label: 'Cheering', confidence: 1 }], speechWords: 40, ai: { clip: false } });
+ assert.strictEqual(vetoed.verdict, 'skip', "the AI's no is a veto, however loud the room");
+
+ const aiOff = score({ spike: { peak: 14, avg: 5 }, chatters: 5, speechWords: 20, ai: { clip: null } });
+ const aiOn = score({ spike: { peak: 14, avg: 5 }, chatters: 5, speechWords: 20, ai: { clip: true } });
+ assert.ok(aiOff.score < aiOn.score && aiOff.verdict === 'skip', 'with the AI off its part is neutral and the bars rise');
+
+ const music = score({ spike: { peak: 20, avg: 5 }, sounds: [{ label: 'Music', confidence: 1 }, { label: 'Speech', confidence: 1 }], ai: { clip: true } });
+ assert.strictEqual(music.parts.sound, 0, 'music and speech are not a moment by themselves');
+ assert.strictEqual(score({ spike: { peak: 20, avg: 5 }, ai: { clip: true } }).parts.breadth, score({ spike: { peak: 20, avg: 5 }, ai: { clip: true } }).parts.spike, 'unknown chatters follow the spike');
+ assert.strictEqual(score().verdict, 'skip', 'nothing is nothing');
+}
+
+// ── Trimming ──
+{
+ const rows = [
+ { start: 100, end: 106.5, text: 'So I said we should try the bridge.' },
+ { start: 106.5, end: 112, text: 'Everyone told me it would not hold.' },
+ { start: 112, end: 117.4, text: 'And then it held, and we all screamed!' },
+ { start: 118, end: 124, text: 'Look at chat right now.' },
+ ];
+ // The moment is the scream at 115: the fixed window is 99..124; snapped to rows it is 100 (the setup) .. 124.
+ const t = trim({ moment: 115, segments: rows });
+ assert.deepStrictEqual([t.start, t.end, t.snapped], [100, 124, true], JSON.stringify(t));
+ // A row without an end runs to the next row's start.
+ const noEnd = trim({ moment: 115, segments: rows.map((r) => ({ ...r, end: null })) });
+ assert.strictEqual(noEnd.start, 100);
+ // The live edge caps the end.
+ const edge = trim({ moment: 115, segments: rows, limit: 119 });
+ assert.ok(edge.end <= 119, `never past the flushed edge (${edge.end})`);
+ // No speech near the moment: the fixed window, unchanged.
+ assert.deepStrictEqual(trim({ moment: 300, segments: rows }), { start: 284, end: 309, snapped: false });
+ assert.deepStrictEqual(trim({ moment: 300, segments: [] }), { start: 284, end: 309, snapped: false });
+ // A monologue row that began long before the moment is not followed back (more than maxLead).
+ const mono = trim({ moment: 150, segments: [{ start: 100, end: 160, text: 'A very long uninterrupted rant about everything at once.' }] });
+ assert.strictEqual(mono.start, 134, 'the fixed start stays when the row began too early');
+ // A snapped window outside min..max falls back to the fixed one.
+ const long = trim({ moment: 115, segments: rows, max: 20 });
+ assert.deepStrictEqual([long.start, long.end, long.snapped], [99, 124, false], 'a snap that makes the clip too long is not taken');
+ assert.strictEqual(trim({ moment: 5, segments: rows }).start, 0, 'never before the start of the recording');
+}
+
+console.log('clip score and trim: all checks passed');