/** * Pure per-take reel analysis. * * Derives, for a single take, the IP from the source skill's "Anatomy of the * reel" + retention principles: * - `anatomy` — where the take sits in the reel (hook / contextualize / burst / * breath / cta), from its position `index / totalTakes`. This calibrates * visual load downstream. * - `anchorWords` — the speech keywords worth animating as giant headlines: * numbers, strong verbs, capitalized entities, otherwise notable keywords. * Each carries a take-relative time so the composer can sync the reveal. * - `pauses` — gaps > 0.4s between segments WITHIN the take, take-relative. * The composer holds the frame silent during these (silence before a reveal). * * Pure: no I/O. Deterministic for a given (split, segments, totalTakes). */ import type { AnchorKind, AnchorWord, ReelAnatomy, TakeAnalysis, TakePause, TakeSplit, TranscriptSegment } from './types.js'; /** Minimum gap between segments (seconds) to count as a held pause. */ export const PAUSE_THRESHOLD_SECONDS = 0.4; // A small, deliberately conservative list of strong/action verbs. Matched // case-insensitively on a normalized token. Kept short so detection stays // predictable and testable. const STRONG_VERBS: ReadonlySet = new Set([ 'build', 'building', 'built', 'create', 'creating', 'created', 'launch', 'launching', 'launched', 'sell', 'selling', 'sold', 'grow', 'growing', 'grew', 'scale', 'scaling', 'ship', 'shipping', 'shipped', 'win', 'winning', 'won', 'transform', 'automate', 'automating', 'dominate', 'dominating', ]); // Common short keywords worth surfacing as anchors when nothing stronger hits. const KEYWORDS: ReadonlySet = new Set([ 'ai', 'time', 'money', 'revenue', 'growth', 'data', 'agent', 'agents', 'clone', 'clones', 'system', 'workflow', 'product', 'launch', ]); // A token that contains a digit (currency, percentages, plain numbers, ranges). const NUMBER_RE = /\d/; /** Strip surrounding punctuation so word-matching is stable. Keeps inner chars. */ function coreToken(raw: string): string { return raw.replace(/^[^\p{L}\p{N}$%#@*]+/u, '').replace(/[^\p{L}\p{N}$%#@*]+$/u, ''); } /** True when the token's first letter is uppercase and it is not all-caps noise. */ function isCapitalizedEntity(core: string): boolean { const first = core[0]; if (!first || first.toUpperCase() !== first || first.toLowerCase() === first) { return false; } // Require at least one more letter so single capital letters don't qualify. return /\p{L}/u.test(core.slice(1)); } function classifyAnchor(core: string, isFirstWordOfSegment: boolean): AnchorKind | null { if (!core) { return null; } if (NUMBER_RE.test(core)) { return 'number'; } const lower = core.toLowerCase(); if (STRONG_VERBS.has(lower)) { return 'verb'; } // Sentence-leading capitalization is grammar, not an entity; require the // capitalized token to NOT be the first word of its segment. if (!isFirstWordOfSegment && isCapitalizedEntity(core)) { return 'entity'; } if (KEYWORDS.has(lower)) { return 'keyword'; } return null; } /** * Map a take's position fraction (`index / totalTakes`) to its reel anatomy. * * - first take → hook * - last take → cta * - position < 0.25 → contextualize * - 0.25 ≤ position < 0.6 → burst * - otherwise → breath */ export function anatomyForPosition(index: number, totalTakes: number): ReelAnatomy { if (totalTakes <= 0) { return 'hook'; } if (index <= 0) { return 'hook'; } if (index >= totalTakes - 1) { return 'cta'; } const position = index / totalTakes; if (position < 0.25) { return 'contextualize'; } if (position < 0.6) { return 'burst'; } return 'breath'; } /** * Analyze one take: anatomy + anchor words + held pauses, all take-relative. * * @param split the take's span + the indices of the segments it covers. * @param segments the full transcript segments (absolute timing). * @param totalTakes number of takes in the reel (for anatomy positioning). */ export function analyzeTake(split: TakeSplit, segments: TranscriptSegment[], totalTakes: number): TakeAnalysis { const anatomy = anatomyForPosition(split.index, totalTakes); const anchorWords: AnchorWord[] = []; const pauses: TakePause[] = []; let prevEnd: number | null = null; for (const segIndex of split.segmentIndices) { const seg = segments[segIndex]; if (!seg) { continue; } // Pause: gap from the previous segment's end to this segment's start. if (prevEnd !== null) { const gap = seg.start - prevEnd; if (gap > PAUSE_THRESHOLD_SECONDS) { pauses.push({ start: round(prevEnd - split.start), end: round(seg.start - split.start), }); } } prevEnd = seg.end; // Anchor words: scan tokens, time them within the segment by token order. const tokens = seg.text.split(/\s+/).filter(Boolean); const segDuration = Math.max(seg.end - seg.start, 0); for (let t = 0; t < tokens.length; t++) { const core = coreToken(tokens[t]); const kind = classifyAnchor(core, t === 0); if (!kind) { continue; } const frac = tokens.length > 1 ? t / (tokens.length - 1) : 0; const absTime = seg.start + frac * segDuration; anchorWords.push({ word: core, kind, relativeTime: round(absTime - split.start), }); } } return { index: split.index, anatomy, anchorWords, pauses }; } /** Round to milliseconds to keep output stable and free of float noise. */ function round(n: number): number { return Math.round(n * 1000) / 1000; }