diff --git a/CLAUDE.md b/CLAUDE.md index 477cfc6..e09ec7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ segments[] text: string cut: boolean cuts: TimeCut[] [{from, to}] intra-segment ranges to skip + synthetic?: boolean true when created by a > SPEAKER split in the doc; absent on raw segments hook?: boolean when true, prepended as hook/teaser before main hookFrom?, hookTo? clip bounds within the segment (seconds) cameraCues[] explicit camera shot overrides (> CAM directives in doc) diff --git a/remotion/types/transcript.ts b/remotion/types/transcript.ts index 5d89568..0bc5fca 100644 --- a/remotion/types/transcript.ts +++ b/remotion/types/transcript.ts @@ -66,6 +66,8 @@ export type Segment = { graphics: GraphicsCue[]; /** Explicit camera cut overrides — take priority over the pacing algorithm */ cameraCues?: CameraCue[]; + /** True when this segment was created by a > SPEAKER split in the doc; absent on raw segments */ + synthetic?: boolean; /** When true, this segment is prepended to the video as a hook/teaser */ hook?: boolean; /** The specific phrase within the segment used as the hook clip */ diff --git a/scripts/edit-transcript.js b/scripts/edit-transcript.js index cdb399e..6c3a88d 100644 --- a/scripts/edit-transcript.js +++ b/scripts/edit-transcript.js @@ -1222,6 +1222,7 @@ function mergeDocIntoTranscript(transcript, docContent) { const synthSeg = { ...parentSeg, id: synthId, + synthetic: true, speaker: newSpeaker, tokens: tokensB, start: splitTime, @@ -1823,6 +1824,22 @@ function buildPrevTokensByTdtw(tokens) { return map; } +// ─── Re-injection helper ────────────────────────────────────────────────────── + +/** + * Returns segments from a prior transcript run that should be re-injected into + * the new transcript. Only synthetic segments (created by > SPEAKER splits) are + * eligible — real segments whose timestamps shifted after retranscription must + * not be duplicated. + * + * @param {import('./types/transcript').Segment[]} existingSegments + * @param {Set} matchedExistingIds IDs already matched to new sentences + * @returns {import('./types/transcript').Segment[]} + */ +export function reInjectSyntheticSegments(existingSegments, matchedExistingIds) { + return existingSegments.filter(s => s.synthetic && !matchedExistingIds.has(s.id)); +} + // ─── Main ───────────────────────────────────────────────────────────────────── async function main() { @@ -1927,7 +1944,10 @@ async function main() { // Re-inject synthetic segments (created by > SPEAKER splits) that were never // matched by findPrev — these have no corresponding raw sentence. - const syntheticSegs = existing.segments.filter(s => !matchedExistingIds.has(s.id)); + // Only segments explicitly marked synthetic are re-injected; unmarked unmatched + // segments are real sentences from a previous run whose timestamps no longer + // align after a fresh transcription/alignment pass. + const syntheticSegs = reInjectSyntheticSegments(existing.segments, matchedExistingIds); if (syntheticSegs.length > 0) { const syntheticStarts = new Set(syntheticSegs.map(s => s.start)); const merged = [...transcript.segments, ...syntheticSegs].sort((a, b) => a.start - b.start); diff --git a/scripts/edit-transcript.test.js b/scripts/edit-transcript.test.js index 945ed1f..f3c4574 100644 --- a/scripts/edit-transcript.test.js +++ b/scripts/edit-transcript.test.js @@ -6,19 +6,15 @@ import { deriveCuts, cleanCaptionText, buildSentencesVtt, - buildSentencesSrt, buildYouTubeSubtitles, - getSubClips, - getHookClips, resolvePhraseToTimeRange, - resolvePhraseToFirstTokenIndex, autoCutPauses, autoCutDisfluencies, rebalanceBoundaryTokens, buildPrevTokensByTdtw, + reInjectSyntheticSegments, WORD_DURATION_ESTIMATE, CUT_START_BIAS, - CUT_END_BIAS, isSpecialToken, isDisfluencyToken, } from './edit-transcript.js'; @@ -384,7 +380,6 @@ describe('applyTextPartsToTokens', () => { test('ignores colon-reason syntax from old docs (backwards compat)', () => { // Old format {um:filler} — reason is stripped, word still gets cut const tokens = [tok(' um', 0.1), tok(' Hello', 0.2)]; - const result = applyTextPartsToTokens('{um:filler} Hello', tokens); // "um:filler" is treated as the full span — won't match token "um" // This is acceptable: old docs with reasons just won't cut those tokens // (the important thing is it doesn't crash) @@ -1298,6 +1293,18 @@ describe('> SPEAKER split', () => { expect(synth.text).toMatch(/^Right/i); }); + test('> SPEAKER split marks the created segment with synthetic: true', () => { + const result = mergeDocIntoTranscript(makeSplitTranscript(), doc); + const synth = result.segments.find(s => s.id !== 1 && s.id !== 2 && s.speaker === 'Natasha' && s.start < 20); + expect(synth.synthetic).toBe(true); + }); + + test('real segments do not carry synthetic: true', () => { + const result = mergeDocIntoTranscript(makeSplitTranscript(), doc); + const real = result.segments.filter(s => s.id === 1 || s.id === 2); + for (const s of real) expect(s.synthetic).toBeFalsy(); + }); + test('no segment overlaps in output', () => { const result = mergeDocIntoTranscript(makeSplitTranscript(), doc); const active = result.segments.filter(s => !s.cut).sort((a, b) => a.start - b.start); @@ -1323,3 +1330,32 @@ describe('> SPEAKER split', () => { expect(synth).toBeDefined(); }); }); + +// ── reInjectSyntheticSegments ────────────────────────────────────────────────── + +describe('reInjectSyntheticSegments', () => { + const makeSeg = (id, synthetic) => ({ + id, start: id * 10, end: id * 10 + 5, speaker: 'X', text: 'hi', + cut: false, tokens: [], cuts: [], graphics: [], synthetic, + }); + + test('re-injects synthetic segments whose id is not in matchedIds', () => { + const segs = [makeSeg(1, true), makeSeg(2, true)]; + const result = reInjectSyntheticSegments(segs, new Set()); + expect(result).toHaveLength(2); + expect(result.map(s => s.id)).toEqual([1, 2]); + }); + + test('does not re-inject real segments even when id is not in matchedIds', () => { + const segs = [makeSeg(1, false), makeSeg(2, undefined)]; + const result = reInjectSyntheticSegments(segs, new Set()); + expect(result).toHaveLength(0); + }); + + test('does not re-inject synthetic segments whose id was already matched', () => { + const segs = [makeSeg(1, true), makeSeg(2, true)]; + const result = reInjectSyntheticSegments(segs, new Set([1])); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); +});