Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions remotion/types/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
22 changes: 21 additions & 1 deletion scripts/edit-transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,7 @@ function mergeDocIntoTranscript(transcript, docContent) {
const synthSeg = {
...parentSeg,
id: synthId,
synthetic: true,
speaker: newSpeaker,
tokens: tokensB,
start: splitTime,
Expand Down Expand Up @@ -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<number>} 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() {
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 42 additions & 6 deletions scripts/edit-transcript.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});
Loading