openvibers/openvibe.live Commits

Automatic clips: only good moments, cut on sentences (clip score + trim) (#442)

OP OpenVibers committed 15 days ago · 119e38be3c · 6 files, +210 −20

The live cutter 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, and every clip began 16 s before and
ended 9 s after the spike, mid-sentence. Owner 2026-10-29: "some of the clips suck".

- server/ai/clip-score.js: one score from 0 to 1, with its reasons. It weighs the spike's
  ratio and size, how many people reacted, the sounds heard (laughter, cheering, a bang),
  how much was said, and the AI's verdict (a no is a veto; with the AI off it is neutral
  and the bars rise). Below 0.40 nothing is cut and the log says why. 0.40 to 0.65 is cut
  unlisted, and only 0.65 and up is cut public. It replaces SPIKE_MULT/SPIKE_MULT_NOAI.
- server/ai/clip-trim.js: the window snaps to the speech rows. It starts where the sentence
  being spoken began (at most 25 s back) and ends where the sentence ends (at most 20 s on,
  never past the flushed edge). Otherwise it keeps the fixed window.
- auto-clip-job.js: scores and trims each live moment, and passes `visibility` to Media's
  POST /clips. The log entry carries the score and the visibility.
- test/clip-score-trim.test.js covers both. clip-dedupe.test.js's spike is raised to 40
  messages so the registry check, not the score, decides its two cases.
- docs/vods-and-clips.md describes the bars and the trim.

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 4f7bc553..ff94f668 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex de5590a0..64ca201f 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -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 check
const 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 agreement
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)
@@@ -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 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 */ }
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);
ddiff --git a/server/ai/clip-score.js b/server/ai/clip-score.js
nnew file mode 100644
iindex 00000000..bad7d465
---- /dev/null
++++ b/server/ai/clip-score.js
@@@ -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 };
ddiff --git a/server/ai/clip-trim.js b/server/ai/clip-trim.js
nnew file mode 100644
iindex 00000000..c527da01
---- /dev/null
++++ b/server/ai/clip-trim.js
@@@ -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 };
ddiff --git a/test/clip-dedupe.test.js b/test/clip-dedupe.test.js
iindex de292e49..e9d145e4 100644
---- a/test/clip-dedupe.test.js
++++ b/test/clip-dedupe.test.js
@@@ -67,9 +67,10 @@ media.createClip = async (body) => { created.push(body); return { id: 900 + crea
const 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');
ddiff --git a/test/clip-score-trim.test.js b/test/clip-score-trim.test.js
nnew file mode 100644
iindex 00000000..790e76d0
---- /dev/null
++++ b/test/clip-score-trim.test.js
@@@ -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');