/** * Pure take-slicing. * * Omni Flash has a hard ~10s-per-clip limit, so a longer talking-head video is * sliced into takes ≤ maxTakeSeconds, cutting only at sentence (segment) * boundaries — never mid-segment — so the joined reel reads cleanly. * * Algorithm (ported from the source skill, Step 5): * 1. Iterate transcript segments, accumulating into the current take while * `segment.end - takeStart <= max`. * 2. When the next segment would overrun `max`, close the current take at the * last accumulated segment's end and start a fresh take at that segment. * 3. Edge case: a single segment whose own duration exceeds `max` becomes its * own take with a HARD CUT at exactly `takeStart + max` (the only place a * cut lands mid-segment — unavoidable when one breath runs > max). * * Pure: no I/O. Deterministic for a given transcript + max. */ import type { TakeSplit, Transcript } from './types.js'; const EPSILON = 1e-9; /** * Group transcript segments into takes ≤ maxTakeSeconds at sentence boundaries. * * @param transcript segments with absolute start/end seconds (must be ordered). * @param maxTakeSeconds Omni hard limit per clip (must be > 0). * @returns ordered `TakeSplit[]` with absolute start/end + the segment indices * each take covers. Empty transcript → `[]`. */ export function planSplits(transcript: Transcript, maxTakeSeconds: number): TakeSplit[] { if (!(maxTakeSeconds > 0)) { throw new Error(`planSplits: maxTakeSeconds must be > 0, got ${maxTakeSeconds}`); } const segments = transcript.segments; if (segments.length === 0) { return []; } const splits: TakeSplit[] = []; let takeStart: number | null = null; let takeEnd = 0; let segmentIndices: number[] = []; const closeTake = (end: number): void => { if (takeStart === null) { return; } splits.push({ index: splits.length, start: takeStart, end, segmentIndices, }); takeStart = null; segmentIndices = []; }; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (takeStart === null) { // Start a new take at this segment. const segDuration = seg.end - seg.start; if (segDuration > maxTakeSeconds + EPSILON) { // A single segment longer than the cap: it must stand alone with a // HARD CUT at exactly start + max (the one place we cut mid-segment). splits.push({ index: splits.length, start: seg.start, end: seg.start + maxTakeSeconds, segmentIndices: [i], }); continue; } takeStart = seg.start; takeEnd = seg.end; segmentIndices = [i]; continue; } // We have an open take; would adding this segment overrun the cap? if (seg.end - takeStart <= maxTakeSeconds + EPSILON) { takeEnd = seg.end; segmentIndices.push(i); continue; } // Overrun: close the current take, then re-process this segment as the // start of a fresh take (handles the oversized-segment edge case too). closeTake(takeEnd); i -= 1; } closeTake(takeEnd); return splits; }