From 6a56a60bf422ef9a3b2370ac879bde5a0e0ddfd1 Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Thu, 14 May 2026 16:41:56 +0800 Subject: [PATCH 1/4] feat(hook-timing): create shared hook timing lib --- remotion/lib/hookTiming.test.ts | 275 ++++++++++++++++++++++++++++++++ remotion/lib/hookTiming.ts | 149 +++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 remotion/lib/hookTiming.test.ts create mode 100644 remotion/lib/hookTiming.ts diff --git a/remotion/lib/hookTiming.test.ts b/remotion/lib/hookTiming.test.ts new file mode 100644 index 0000000..3ff2271 --- /dev/null +++ b/remotion/lib/hookTiming.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for remotion/lib/hookTiming.ts + * + * All functions are pure (no I/O, no Remotion hooks) so tests run in the node + * Jest environment. + */ + +import { + hookClipEnd, + getHookSubClips, + buildHookSections, + HOOK_TAIL_PAD_UNBOUNDED_SECONDS, + HOOK_TAIL_PAD_BOUNDED_SECONDS, + HOOK_BRIDGE_MAX_GAP_SECONDS, +} from './hookTiming'; +import type { Segment } from '../types/transcript'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeSegment(overrides: Partial = {}): Segment { + return { + id: 1, + start: 10, + end: 15, + speaker: 'Natasha', + text: 'hello world', + cut: false, + tokens: [], + cuts: [], + graphics: [], + hook: true, + ...overrides, + }; +} + +function makeToken(text: string, t_dtw: number, t_end?: number) { + return { text, t_dtw, t_end, cut: false }; +} + +// ── hookClipEnd ─────────────────────────────────────────────────────────────── + +describe('hookClipEnd', () => { + describe('unbounded hook (no hookTo)', () => { + it('returns end + HOOK_TAIL_PAD_UNBOUNDED_SECONDS when no spoken tokens', () => { + const seg = makeSegment({ start: 10, end: 15, tokens: [] }); + expect(hookClipEnd(seg)).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + + it('uses t_end of last spoken token when t_end is past baseEnd', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [ + makeToken(' hello', 12, 13.5), + makeToken(' world', 14, 15.8), // t_end drifts past end + ], + }); + const result = hookClipEnd(seg); + // sourceEnd extended to 15.8, then + HOOK_TAIL_PAD_UNBOUNDED_SECONDS + expect(result).toBeCloseTo(15.8 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + + it('caps t_end extension at nextHookStart', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [ + makeToken(' world', 14, 17.0), // t_end way past end + ], + }); + const result = hookClipEnd(seg, 16.0); // next hook starts at 16 + // t_end capped at nextHookStart=16, then pad, then capped again + expect(result).toBeCloseTo(16.0); + }); + + it('bridges to next hook when gap is small and segment ends at tail', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [makeToken(' hello', 12)], // no t_end, no token after 15 + }); + // gap = 15.5 - 15 = 0.5 s, within HOOK_BRIDGE_MAX_GAP_SECONDS + const result = hookClipEnd(seg, 15.5); + // bridges to 15.5, then + pad, then capped at 15.5 + expect(result).toBeCloseTo(15.5); + }); + + it('does not bridge when gap exceeds HOOK_BRIDGE_MAX_GAP_SECONDS', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [makeToken(' hello', 12)], + }); + const nextHookStart = 15 + HOOK_BRIDGE_MAX_GAP_SECONDS + 0.1; + const result = hookClipEnd(seg, nextHookStart); + // no bridge; just pad, no cap needed since pad < gap + expect(result).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + + it('does not bridge when spoken tokens exist after sourceEnd', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [ + makeToken(' hello', 12), + makeToken(' world', 15.5), // token after sourceEnd → not at tail + ], + }); + const result = hookClipEnd(seg, 15.6); + // hasSpokenTokenAfterEnd = true → endsAtSegmentTail = false → no bridge + expect(result).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + }); + + describe('bounded hook (hookTo set)', () => { + it('returns hookTo + HOOK_TAIL_PAD_BOUNDED_SECONDS for a simple bounded hook', () => { + const seg = makeSegment({ start: 10, end: 15, hookFrom: 11, hookTo: 13, tokens: [] }); + expect(hookClipEnd(seg)).toBeCloseTo(13 + HOOK_TAIL_PAD_BOUNDED_SECONDS); + }); + + it('extends bounded hook to cover last spoken token t_end within window', () => { + const seg = makeSegment({ + start: 10, + end: 15, + hookFrom: 11, + hookTo: 13, + tokens: [ + makeToken(' hello', 12, 13.4), // t_end = 13.4 > hookTo = 13 + ], + }); + const result = hookClipEnd(seg); + expect(result).toBeCloseTo(13.4 + HOOK_TAIL_PAD_BOUNDED_SECONDS); + }); + + it('ignores tokens outside the hook window', () => { + const seg = makeSegment({ + start: 10, + end: 15, + hookFrom: 11, + hookTo: 13, + tokens: [ + makeToken(' early', 9), // before hookFrom + makeToken(' late', 14), // after hookTo (outside hook window) + ], + }); + // No tokens in [hookFrom=11, hookTo=13] range, so no t_end extension + const result = hookClipEnd(seg); + expect(result).toBeCloseTo(13 + HOOK_TAIL_PAD_BOUNDED_SECONDS); + }); + + it('caps result at nextHookStart', () => { + const seg = makeSegment({ + start: 10, + end: 15, + hookFrom: 11, + hookTo: 13, + tokens: [makeToken(' hi', 12, 13.5)], + }); + const result = hookClipEnd(seg, 13.3); + // After extension: 13.5, after pad: 13.52, after cap: 13.3 + expect(result).toBeCloseTo(13.3); + }); + }); + + describe('special token filtering', () => { + it('ignores Whisper marker tokens (_MUSIC_, etc.)', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [ + { text: ' _MUSIC_', t_dtw: 14, t_end: 20.0, cut: false }, + ], + }); + // _MUSIC_ is not a spoken token, so no t_end extension + const result = hookClipEnd(seg); + expect(result).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + + it('ignores empty tokens', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [ + { text: '', t_dtw: 14, t_end: 20.0, cut: false }, + { text: ' ', t_dtw: 14.5, t_end: 20.0, cut: false }, + ], + }); + const result = hookClipEnd(seg); + expect(result).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + }); +}); + +// ── getHookSubClips ──────────────────────────────────────────────────────────── + +describe('getHookSubClips', () => { + it('returns a single SubClip for an unbounded hook', () => { + const seg = makeSegment({ start: 10, end: 15, tokens: [] }); + const clips = getHookSubClips(seg); + expect(clips).toHaveLength(1); + expect(clips[0].sourceStart).toBe(10); + expect(clips[0].sourceEnd).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); + }); + + it('uses hookFrom as sourceStart when defined', () => { + const seg = makeSegment({ start: 10, end: 15, hookFrom: 12, tokens: [] }); + const clips = getHookSubClips(seg); + expect(clips[0].sourceStart).toBe(12); + }); + + it('uses hookTo as clip base end for a bounded hook', () => { + const seg = makeSegment({ start: 10, end: 15, hookFrom: 11, hookTo: 13, tokens: [] }); + const clips = getHookSubClips(seg); + expect(clips[0].sourceStart).toBe(11); + expect(clips[0].sourceEnd).toBeCloseTo(13 + HOOK_TAIL_PAD_BOUNDED_SECONDS); + }); + + it('returns sourceEnd < nextHookStart when capped', () => { + const seg = makeSegment({ start: 10, end: 15, tokens: [] }); + const clips = getHookSubClips(seg, 15.1); + expect(clips[0].sourceEnd).toBeCloseTo(15.1); + }); +}); + +// ── buildHookSections ────────────────────────────────────────────────────────── + +describe('buildHookSections', () => { + const FPS = 60; + + it('returns empty array for no hook segments', () => { + expect(buildHookSections([], FPS)).toEqual([]); + }); + + it('converts a single hook to a section', () => { + const seg = makeSegment({ start: 10, end: 15, tokens: [] }); + const sections = buildHookSections([seg], FPS); + expect(sections).toHaveLength(1); + expect(sections[0].trimBefore).toBe(Math.floor(10 * FPS)); + const expectedEnd = 15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS; + expect(sections[0].trimAfter).toBe(Math.ceil(expectedEnd * FPS)); + }); + + it('passes nextHookStart to successive hooks', () => { + const seg1 = makeSegment({ id: 1, start: 5, end: 10, tokens: [] }); + const seg2 = makeSegment({ id: 2, start: 20, end: 25, tokens: [] }); + const sections = buildHookSections([seg1, seg2], FPS); + expect(sections).toHaveLength(2); + // seg1 should be capped at seg2.start (20) + expect(sections[0].trimAfter).toBeLessThanOrEqual(Math.ceil(20 * FPS)); + }); + + it('de-overlaps adjacent sections when t_end causes overlap', () => { + // Token t_end causes seg1 sourceEnd to extend past seg2's start + const seg1 = makeSegment({ + id: 1, + start: 5, + end: 10, + tokens: [makeToken(' hi', 9, 12.0)], // t_end would push into seg2 window + }); + const seg2 = makeSegment({ id: 2, start: 11, end: 16, tokens: [] }); + const sections = buildHookSections([seg1, seg2], FPS); + // Even if there's overlap in raw sections, de-overlap pass fixes it + if (sections.length >= 2) { + expect(sections[1].trimBefore).toBeGreaterThanOrEqual(sections[0].trimAfter); + } + }); + + it('section trimAfter is always at least trimBefore + 1', () => { + const seg = makeSegment({ start: 10, end: 10.0001, tokens: [] }); + const sections = buildHookSections([seg], FPS); + for (const s of sections) { + expect(s.trimAfter).toBeGreaterThan(s.trimBefore); + } + }); +}); diff --git a/remotion/lib/hookTiming.ts b/remotion/lib/hookTiming.ts new file mode 100644 index 0000000..a2d2c85 --- /dev/null +++ b/remotion/lib/hookTiming.ts @@ -0,0 +1,149 @@ +/** + * Shared hook timing utilities — single source of truth for all hook clip + * boundary calculations. Previously duplicated across SegmentPlayer, Composition, + * ShortFormClip, and CameraPlayer with minor divergences. + * + * All consumers must import from here; local copies must not exist. + */ + +import type { Segment } from '../types/transcript'; +import { isSpokenToken } from './tokens'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Extra seconds appended to unbounded hooks (no explicit hookTo). */ +export const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; + +/** Extra seconds appended to bounded hooks (explicit hookTo). */ +export const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; + +/** Max gap (seconds) between hook end and next hook start for bridging. */ +export const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; + +// ── Core timing function ────────────────────────────────────────────────────── + +/** + * Returns the effective end time (seconds) for a hook clip. + * + * Algorithm (applied in order): + * 1. Start from `segment.hookTo ?? segment.end`. + * 2. Extend to cover `t_end` of the last spoken token within the hook window, + * capped by `nextHookStart` when provided. This prevents the audio tail of + * the final word from being clipped when Whisper places t_end past the + * nominal segment boundary. + * 3. If no spoken token follows `sourceEnd + 0.02` (segment tail), bridge to + * `nextHookStart` when the gap is ≤ `HOOK_BRIDGE_MAX_GAP_SECONDS`. + * 4. Add a tail pad (bounded or unbounded constant). + * 5. Hard-cap at `nextHookStart` to prevent source windows from overlapping, + * which would cause backward jumps in SectionGroupPlayer. + * + * @param segment The hook segment. + * @param nextHookStart Source start time (seconds) of the following hook + * segment, or undefined if this is the last hook. + */ +export function hookClipEnd(segment: Segment, nextHookStart?: number): number { + const sourceStart = segment.hookFrom ?? segment.start; + const baseEnd = segment.hookTo ?? segment.end; + const isBoundedHook = segment.hookTo !== undefined && segment.hookTo !== null; + + let sourceEnd = baseEnd; + + // Extend to cover the last spoken token's audio tail + const lastSpokenToken = segment.tokens + .filter(t => isSpokenToken(t) && t.t_dtw >= sourceStart && t.t_dtw <= baseEnd) + .sort((a, b) => (b.t_end ?? 0) - (a.t_end ?? 0))[0]; + + if (lastSpokenToken?.t_end) { + const tEnd = nextHookStart !== undefined + ? Math.min(lastSpokenToken.t_end, nextHookStart) + : lastSpokenToken.t_end; + sourceEnd = Math.max(sourceEnd, tEnd); + } + + // Bridge to the next hook when the gap is small and this hook ends at the segment tail + const hasSpokenTokenAfterEnd = segment.tokens.some( + t => isSpokenToken(t) && t.t_dtw > sourceEnd + 0.02, + ); + const endsAtSegmentTail = !hasSpokenTokenAfterEnd; + const canBridge = nextHookStart !== undefined + && nextHookStart > sourceEnd + && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; + if (endsAtSegmentTail && canBridge) { + sourceEnd = nextHookStart; + } + + // Add a small tail pad to avoid cutting off the audio abruptly + sourceEnd += isBoundedHook + ? HOOK_TAIL_PAD_BOUNDED_SECONDS + : HOOK_TAIL_PAD_UNBOUNDED_SECONDS; + + // Hard cap: never extend into the next hook's source window + if (nextHookStart !== undefined) { + sourceEnd = Math.min(sourceEnd, nextHookStart); + } + + return sourceEnd; +} + +// ── SubClip / Section types ─────────────────────────────────────────────────── + +export type SubClip = { sourceStart: number; sourceEnd: number }; +export type Section = { trimBefore: number; trimAfter: number }; + +// ── Sub-clip builder ────────────────────────────────────────────────────────── + +/** + * Returns the playable sub-clip(s) for a single hook segment. + * + * Hook clips play uninterrupted (no cuts[] applied) so that hook music stays + * in sync. The clip window is [hookFrom ?? start, hookClipEnd(segment)]. + * + * Returns a single-element array; the array form keeps the signature + * compatible with the main-content `getSubClips` pattern. + */ +export function getHookSubClips(segment: Segment, nextHookStart?: number): SubClip[] { + const sourceStart = segment.hookFrom ?? segment.start; + const sourceEnd = hookClipEnd(segment, nextHookStart); + return [{ sourceStart, sourceEnd }]; +} + +// ── Section builder ─────────────────────────────────────────────────────────── + +function toSections(clips: SubClip[], fps: number): Section[] { + return clips.map(c => { + const trimBefore = Math.floor(c.sourceStart * fps); + const trimAfter = Math.ceil(c.sourceEnd * fps); + return { + trimBefore, + trimAfter: Math.max(trimAfter, trimBefore + 1), + }; + }); +} + +/** + * Converts all hook segments into de-overlapped `Section[]` ready for + * `SectionGroupPlayer`. + * + * De-overlap pass: if a section's `trimBefore` precedes the previous section's + * `trimAfter` (caused by t_end extension or bridging across overlapping source + * ranges), advance it to avoid backward jumps. + */ +export function buildHookSections(hookSegments: Segment[], fps: number): Section[] { + const rawSections = hookSegments + .flatMap((seg, idx) => { + const next = hookSegments[idx + 1]; + const nextHookStart = next ? (next.hookFrom ?? next.start) : undefined; + return toSections(getHookSubClips(seg, nextHookStart), fps); + }); + + // De-overlap + const sections: Section[] = []; + for (const s of rawSections) { + const prev = sections[sections.length - 1]; + const trimBefore = prev ? Math.max(s.trimBefore, prev.trimAfter) : s.trimBefore; + if (trimBefore < s.trimAfter) { + sections.push({ trimBefore, trimAfter: s.trimAfter }); + } + } + return sections; +} From af2b2de14187fc8f61f79544f38c5fae14bc586e Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Thu, 14 May 2026 16:45:25 +0800 Subject: [PATCH 2/4] refactor(hook-timing): deduplicate hookClipEnd via shared lib --- remotion/Composition.tsx | 38 +----------- remotion/ShortFormClip.tsx | 40 +------------ remotion/components/CameraPlayer.tsx | 55 +++--------------- remotion/components/HookOverlay.tsx | 42 +------------- remotion/components/SegmentPlayer.tsx | 83 +++------------------------ 5 files changed, 20 insertions(+), 238 deletions(-) diff --git a/remotion/Composition.tsx b/remotion/Composition.tsx index 065e026..7960009 100644 --- a/remotion/Composition.tsx +++ b/remotion/Composition.tsx @@ -12,6 +12,7 @@ import { import { getAudioDurationInSeconds } from '@remotion/media-utils'; import React, { useState, useEffect, useMemo } from 'react'; import { SegmentPlayer, buildSections, buildMainSubClips } from './components/SegmentPlayer'; +import { hookClipEnd } from './lib/hookTiming'; import { CameraPlayer } from './components/CameraPlayer'; import { HookOverlay } from './components/HookOverlay'; import { OverlayRenderer } from './components/OverlayRenderer'; @@ -61,43 +62,6 @@ function getActiveSegments(transcript: Transcript) { const INTRO_DURATION_SECS = INTRO_DURATION_FRAMES / 60; const OUTRO_DURATION_SECS = OUTRO_DURATION_FRAMES / 60; -const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; -const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; -const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; - -/** Returns the effective end time for a hook clip, extending by 0.5 s when - * spoken tokens drift past the segment boundary. Must match getHookSubClips in - * SegmentPlayer and buildHookTimings in HookOverlay. */ -function hookClipEnd(s: Segment, nextHookStart?: number): number { - const baseEnd = s.hookTo ?? s.end; - const isBoundedHook = s.hookTo !== undefined && s.hookTo !== null; - let sourceEnd = baseEnd; - // Only extend unbounded hooks (no explicit hookTo). Must match SegmentPlayer and HookOverlay. - if (s.hookTo === undefined || s.hookTo === null) { - const latestSpokenToken = s.tokens - .filter(t => !/_[A-Z]+_/.test(t.text.trim()) && t.text.trim() !== '') - .reduce((max, t) => Math.max(max, t.t_dtw), -Infinity); - if (latestSpokenToken > baseEnd) { - const drift = latestSpokenToken - baseEnd; - const extension = Math.min(1.5, drift + 0.4); - sourceEnd = baseEnd + extension; - } - } - const hasSpokenTokenAfterEnd = s.tokens.some( - (t) => !/_[A-Z]+_/.test(t.text.trim()) - && t.text.trim() !== '' - && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridgeToNextHook = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - if (endsAtSegmentTail && canBridgeToNextHook) { - sourceEnd = nextHookStart; - } - const withPad = sourceEnd + (isBoundedHook ? HOOK_TAIL_PAD_BOUNDED_SECONDS : HOOK_TAIL_PAD_UNBOUNDED_SECONDS); - return nextHookStart !== undefined ? Math.min(withPad, nextHookStart) : withPad; -} function computeEffectiveDuration(transcript: Transcript): number { const hooks = transcript.segments.filter(s => s.hook && !s.cut); diff --git a/remotion/ShortFormClip.tsx b/remotion/ShortFormClip.tsx index 012a638..be51c3a 100644 --- a/remotion/ShortFormClip.tsx +++ b/remotion/ShortFormClip.tsx @@ -11,6 +11,7 @@ import { import { getAudioDurationInSeconds } from '@remotion/media-utils'; import React, { useState, useEffect, useMemo } from 'react'; import { SegmentPlayer, buildSections, buildMainSubClips } from './components/SegmentPlayer'; +import { hookClipEnd } from './lib/hookTiming'; import { CameraPlayer } from './components/CameraPlayer'; import { CaptionOverlay } from './components/CaptionOverlay'; import { OverlayRenderer } from './components/OverlayRenderer'; @@ -54,45 +55,6 @@ function getActiveSegments(transcript: Transcript) { }); } -const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; -const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; -const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; - -/** Returns the effective end time for a hook clip, extending by 0.5 s when - * spoken tokens drift past the segment boundary. */ -function hookClipEnd(s: Segment, nextHookStart?: number): number { - const baseEnd = s.hookTo ?? s.end; - const isBoundedHook = s.hookTo !== undefined && s.hookTo !== null; - let sourceEnd = baseEnd; - - if (s.hookTo === undefined || s.hookTo === null) { - const latestSpokenToken = s.tokens - .filter(t => !/_[A-Z]+_/.test(t.text.trim()) && t.text.trim() !== '') - .reduce((max, t) => Math.max(max, t.t_dtw), -Infinity); - if (latestSpokenToken > baseEnd) { - const drift = latestSpokenToken - baseEnd; - const extension = Math.min(1.5, drift + 0.4); - sourceEnd = baseEnd + extension; - } - } - - const hasSpokenTokenAfterEnd = s.tokens.some( - (t) => !/_[A-Z]+_/.test(t.text.trim()) - && t.text.trim() !== '' - && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridgeToNextHook = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - - if (endsAtSegmentTail && canBridgeToNextHook) { - sourceEnd = nextHookStart; - } - - const withPad = sourceEnd + (isBoundedHook ? HOOK_TAIL_PAD_BOUNDED_SECONDS : HOOK_TAIL_PAD_UNBOUNDED_SECONDS); - return nextHookStart !== undefined ? Math.min(withPad, nextHookStart) : withPad; -} function computeEffectiveDuration(transcript: Transcript): number { const hooks = transcript.segments.filter(s => s.hook && !s.cut); diff --git a/remotion/components/CameraPlayer.tsx b/remotion/components/CameraPlayer.tsx index 8b70c19..29614a1 100644 --- a/remotion/components/CameraPlayer.tsx +++ b/remotion/components/CameraPlayer.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { AbsoluteFill, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; import { SegmentPlayer, getEffectiveDuration, Section } from './SegmentPlayer'; +import { hookClipEnd } from '../lib/hookTiming'; import type { Segment } from '../types/transcript'; import type { CameraProfiles, CameraShot, CropViewport, AngleConfig, SpeakerProfile } from '../types/camera'; @@ -44,9 +45,6 @@ function sourceToOutputFrame(sourceSec: number, mainSections: Section[], fps: nu const MIN_WIDE_S = 1.5; // minimum wide-shot duration before cutting to closeup const MAX_CLOSEUP_S = 10.0; // force return to wide after this long in closeup (10s cycle) const PERIODIC_WIDE_S = 45.0; // insert a wide every ~45 s of total closeup time -const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; -const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; -const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; // ── Transform helpers ───────────────────────────────────────────────────────── @@ -131,54 +129,15 @@ function getSpeakerProfile( /** * The duration a segment contributes to the output timeline. - * For phrase hooks this is the hook clip window, not the full segment duration. - * Must match the clip length that buildSections emits for the same segment. + * For hook segments this is the hook clip window (via shared hookClipEnd), + * not the full segment duration. Must match the clip length that buildSections + * emits for the same segment. */ function getOutputDuration(seg: Segment, nextHookStart?: number): number { if (seg.hook) { - // Phrase-bounded hooks play exactly their defined window (no extension). - if (seg.hookFrom !== undefined && seg.hookTo !== undefined) { - const sourceStart = seg.hookFrom; - let sourceEnd = seg.hookTo; - const hasSpokenTokenAfterEnd = seg.tokens.some( - (t) => !/_[A-Z]+_/.test(t.text.trim()) - && t.text.trim() !== '' - && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridgeToNextHook = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - if (endsAtSegmentTail && canBridgeToNextHook) { - sourceEnd = nextHookStart; - } - return (sourceEnd + HOOK_TAIL_PAD_BOUNDED_SECONDS) - sourceStart; - } - // Unbounded hooks play the full raw segment, extended when spoken tokens - // drift past seg.end — must match getHookSubClips in SegmentPlayer. - const baseEnd = seg.end; - const latestSpokenToken = seg.tokens - .filter(t => !/_[A-Z]+_/.test(t.text.trim()) && t.text.trim() !== '') - .reduce((max, t) => Math.max(max, t.t_dtw), -Infinity); - let sourceEnd = baseEnd; - if (latestSpokenToken > baseEnd) { - const drift = latestSpokenToken - baseEnd; - const extension = Math.min(1.5, drift + 0.4); - sourceEnd = baseEnd + extension; - } - const hasSpokenTokenAfterEnd = seg.tokens.some( - (t) => !/_[A-Z]+_/.test(t.text.trim()) - && t.text.trim() !== '' - && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridgeToNextHook = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - if (endsAtSegmentTail && canBridgeToNextHook) { - sourceEnd = nextHookStart; - } - return (sourceEnd + HOOK_TAIL_PAD_UNBOUNDED_SECONDS) - seg.start; + const sourceStart = seg.hookFrom ?? seg.start; + const sourceEnd = hookClipEnd(seg, nextHookStart); + return sourceEnd - sourceStart; } return getEffectiveDuration(seg); } diff --git a/remotion/components/HookOverlay.tsx b/remotion/components/HookOverlay.tsx index 601455b..013c0d9 100644 --- a/remotion/components/HookOverlay.tsx +++ b/remotion/components/HookOverlay.tsx @@ -8,6 +8,7 @@ import { createTikTokStyleCaptions } from '@remotion/captions'; import type { Caption } from '@remotion/captions'; import { whip } from '@remotion/sfx'; import type { Segment, Token } from '../types/transcript'; +import { hookClipEnd } from '../lib/hookTiming'; import { isSpokenToken } from '../lib/tokens'; import type { Brand } from '../types/brand'; import { ChapterMarker } from './overlays/lower-thirds'; @@ -146,9 +147,6 @@ function buildCaptions( // ── Per-hook segment timing ──────────────────────────────────────────────────── type Page = ReturnType['pages'][number]; -const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; -const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; -const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; type HookTiming = { seg: Segment; @@ -167,47 +165,13 @@ function buildHookTimings(segments: Segment[], fps: number): HookTiming[] { if (!seg.hook || seg.cut) continue; const sourceStart = seg.hookFrom ?? seg.start; - const baseEnd = seg.hookTo ?? seg.end; const isBoundedHook = seg.hookTo !== undefined && seg.hookTo !== null; - let sourceEnd = baseEnd; - // Extend to cover the last spoken token's audio tail (both bounded and unbounded hooks) - const lastSpokenToken = seg.tokens - .filter(t => isSpokenToken(t) && t.t_dtw >= sourceStart && t.t_dtw <= baseEnd) - .sort((a, b) => (b.t_end ?? 0) - (a.t_end ?? 0))[0]; - const nextHookSeg = segments.slice(i + 1).find(s => s.hook && !s.cut); const nextHookStart = nextHookSeg ? (nextHookSeg.hookFrom ?? nextHookSeg.start) : undefined; - if (lastSpokenToken?.t_end) { - const tEnd = nextHookStart !== undefined - ? Math.min(lastSpokenToken.t_end, nextHookStart) - : lastSpokenToken.t_end; - sourceEnd = Math.max(sourceEnd, tEnd); - } - - // Bridge to the next hook when the gap is small and this hook ends at the - // segment tail — must match SegmentPlayer.getHookSubClips / CameraPlayer. - const hasSpokenTokenAfterEnd = seg.tokens.some( - t => isSpokenToken(t) && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridge = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - if (endsAtSegmentTail && canBridge) { - sourceEnd = nextHookStart; - } - - // Add a small tail pad to avoid cutting off the audio abruptly - sourceEnd += isBoundedHook - ? HOOK_TAIL_PAD_BOUNDED_SECONDS - : HOOK_TAIL_PAD_UNBOUNDED_SECONDS; - - // Hard cap: never extend into the next hook's source window (must match getHookSubClips). - if (nextHookStart !== undefined) { - sourceEnd = Math.min(sourceEnd, nextHookStart); - } + // Delegate to shared hook timing lib — single source of truth. + const sourceEnd = hookClipEnd(seg, nextHookStart); const captions = buildCaptions(seg.tokens, sourceStart, sourceEnd, isBoundedHook); diff --git a/remotion/components/SegmentPlayer.tsx b/remotion/components/SegmentPlayer.tsx index 77c40ba..9385309 100644 --- a/remotion/components/SegmentPlayer.tsx +++ b/remotion/components/SegmentPlayer.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useRef } from 'react'; import { OffthreadVideo, Sequence, useCurrentFrame, useVideoConfig, getRemotionEnvironment } from 'remotion'; import type { Segment, TimeCut } from '../types/transcript'; -import { isSpokenToken } from '../lib/tokens'; +import { buildHookSections } from '../lib/hookTiming'; +import type { SubClip, Section } from '../lib/hookTiming'; -export type SubClip = { sourceStart: number; sourceEnd: number }; - -export type Section = { trimBefore: number; trimAfter: number }; +// Re-export for backward compatibility — consumers that import these types +// from SegmentPlayer continue to work without changes. +export type { SubClip, Section } from '../lib/hookTiming'; export type SplitSections = { hookSections: Section[]; mainSections: Section[] }; @@ -129,58 +130,6 @@ export function buildMainSubClips( return clips.filter(c => (c.sourceEnd - c.sourceStart) >= 0.034); } -/** - * Returns the clip range for a hook segment: phrase window if set, else the raw segment - * (no cuts applied — hook clips play uninterrupted so the music stays in sync). - * - * The clip end is extended when spoken tokens drift past the segment boundary, - * matching HookOverlay/Composition/CameraPlayer so hook audio does not clip the - * trailing word of a phrase. */ -function getHookSubClips(segment: Segment, nextHookStart?: number): SubClip[] { - const sourceStart = segment.hookFrom ?? segment.start; - const baseEnd = segment.hookTo ?? segment.end; - const isBoundedHook = segment.hookTo !== undefined && segment.hookTo !== null; - - let sourceEnd = baseEnd; - // Extend to cover the last spoken token's audio tail (both bounded and unbounded hooks) - const lastSpokenToken = segment.tokens - .filter(t => isSpokenToken(t) && t.t_dtw >= sourceStart && t.t_dtw <= baseEnd) - .sort((a, b) => (b.t_end ?? 0) - (a.t_end ?? 0))[0]; - - if (lastSpokenToken?.t_end) { - const tEnd = nextHookStart !== undefined - ? Math.min(lastSpokenToken.t_end, nextHookStart) - : lastSpokenToken.t_end; - sourceEnd = Math.max(sourceEnd, tEnd); - } - - // Bridge to the next hook when the gap is small and this hook ends at the - // segment tail — must match CameraPlayer.getOutputDuration / Composition.hookClipEnd. - const hasSpokenTokenAfterEnd = segment.tokens.some( - t => isSpokenToken(t) && t.t_dtw > sourceEnd + 0.02, - ); - const endsAtSegmentTail = !hasSpokenTokenAfterEnd; - const canBridge = nextHookStart !== undefined - && nextHookStart > sourceEnd - && nextHookStart - sourceEnd <= HOOK_BRIDGE_MAX_GAP_SECONDS; - if (endsAtSegmentTail && canBridge) { - sourceEnd = nextHookStart; - } - - // Add a small tail pad to avoid cutting off the audio abruptly - sourceEnd += isBoundedHook - ? HOOK_TAIL_PAD_BOUNDED_SECONDS - : HOOK_TAIL_PAD_UNBOUNDED_SECONDS; - - // Hard cap: never extend into the next hook's source window. - // Prevents overlapping source ranges which cause backward jumps in SectionGroupPlayer. - if (nextHookStart !== undefined) { - sourceEnd = Math.min(sourceEnd, nextHookStart); - } - - return [{ sourceStart, sourceEnd }]; -} - function toSections(clips: SubClip[], fps: number): Section[] { return clips.map(c => { const trimBefore = Math.floor(c.sourceStart * fps); @@ -214,22 +163,9 @@ export function buildSections( videoEnd?: number, ): SplitSections { const hookSegments = segments.filter(s => s.hook && !s.cut); - const rawHookSections = hookSegments - .flatMap((seg, idx) => { - const next = hookSegments[idx + 1]; - const nextHookStart = next ? (next.hookFrom ?? next.start) : undefined; - return toSections(getHookSubClips(seg, nextHookStart), fps); - }); - // De-overlap: if a section's trimBefore precedes the previous section's trimAfter - // (caused by t_end extension or bridging across overlapping source ranges), advance it. - const hookSections: Section[] = []; - for (const s of rawHookSections) { - const prev = hookSections[hookSections.length - 1]; - const trimBefore = prev ? Math.max(s.trimBefore, prev.trimAfter) : s.trimBefore; - if (trimBefore < s.trimAfter) { - hookSections.push({ trimBefore, trimAfter: s.trimAfter }); - } - } + // Delegate to shared hook timing lib — single source of truth for hook sections. + const hookSections = buildHookSections(hookSegments, fps); + const allMainSegments = segments.filter(s => !s.hook); const mainSubClips = buildMainSubClips(allMainSegments, videoStart, videoEnd); const rawMainSections = toSections(mainSubClips, fps); @@ -251,9 +187,6 @@ export function buildSections( // Frames to fade in/out at each cut boundary (~50ms at 60fps) const DECLICK_FRAMES = 3; -const HOOK_TAIL_PAD_UNBOUNDED_SECONDS = 0.16; -const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; -const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; const HOOK_END_FADE_FRAMES = 12; /** From 9073ef5291242348cc0664c06f420a1a8c9d8a9a Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Thu, 14 May 2026 16:46:52 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(claude-md):=20reflect=20changes=20from?= =?UTF-8?q?=20issue=20#18=20=E2=80=94=20hook=20timing=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark hookClipEnd() duplication bug as fixed in Known correctness bugs list - Update HOOK_TAIL_PAD_* and HOOK_BRIDGE_MAX_GAP_SECONDS constants table to point to remotion/lib/hookTiming.ts (their new canonical home) - Add remotion/lib/hookTiming.ts row to Key Source Files table - Update SegmentPlayer refactor note (hookTiming extraction is done) --- CLAUDE.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 477cfc6..e1c6fd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ ty = (0.5 - vp.cy) × 100% ### Known correctness bugs (Phase 5 targets) -- `hookClipEnd()` has 4 separate implementations (`CameraPlayer`, `SegmentPlayer`, `Composition`, `HookOverlay`) — can disagree by 1–3 frames. Fix: `remotion/lib/hookTiming.ts`. +- ~~`hookClipEnd()` has 4 separate implementations (`CameraPlayer`, `SegmentPlayer`, `Composition`, `HookOverlay`) — can disagree by 1–3 frames.~~ **Fixed** — `remotion/lib/hookTiming.ts` is now the single source of truth; all consumers import from it. - `buildCaptions()` duplicated across `HookOverlay` and `CaptionOverlay`. Fix: `remotion/lib/captions.ts`. - No `OverlayErrorBoundary` — overlay crash kills the composition. - No transcript validation on load. @@ -173,9 +173,9 @@ ty = (0.5 - vp.cy) × 100% | `PAUSE_THRESHOLD` | 0.8 s | `edit-transcript.js` | | `WORD_DURATION_ESTIMATE` | 0.4 s | `edit-transcript.js` | | `CUT_START_BIAS` | 1.0 | `edit-transcript.js` | -| `HOOK_TAIL_PAD_UNBOUNDED_SECONDS` | 0.16 s | `SegmentPlayer.tsx` | -| `HOOK_TAIL_PAD_BOUNDED_SECONDS` | 0.02 s | `SegmentPlayer.tsx` | -| `HOOK_BRIDGE_MAX_GAP_SECONDS` | 1.0 s | `SegmentPlayer.tsx` | +| `HOOK_TAIL_PAD_UNBOUNDED_SECONDS` | 0.16 s | `remotion/lib/hookTiming.ts` | +| `HOOK_TAIL_PAD_BOUNDED_SECONDS` | 0.02 s | `remotion/lib/hookTiming.ts` | +| `HOOK_BRIDGE_MAX_GAP_SECONDS` | 1.0 s | `remotion/lib/hookTiming.ts` | | `HOOK_END_FADE_FRAMES` | 12 | `SegmentPlayer.tsx` | | `DECLICK_FRAMES` | 3 | `SegmentPlayer.tsx` | | `MIN_WIDE_S` | 1.5 s | `CameraPlayer.tsx` | @@ -189,7 +189,8 @@ ty = (0.5 - vp.cy) × 100% | File | Purpose | Refactor note | |------|---------|---------------| | `remotion/Composition.tsx` | Root composition, duration calc, asset loading | Add transcript validation (Phase 5) | -| `remotion/components/SegmentPlayer.tsx` | Jump-cut player, section builders | Extract hookTiming, captions (Phase 5) | +| `remotion/lib/hookTiming.ts` | `hookClipEnd()`, `getHookSubClips()`, `buildHookSections()` — single source of truth for all hook clip boundary calculations | — | +| `remotion/components/SegmentPlayer.tsx` | Jump-cut player, section builders | Extract captions (Phase 5) | | `remotion/components/CameraPlayer.tsx` | Camera shots, multi-angle viewport (779 lines) | Extract cameraShots lib → <350 lines (Phase 6) | | `remotion/components/HookOverlay.tsx` | Hook captions, Techybara (518 lines) | Extract captions.ts (Phase 5) | | `remotion/components/OverlayRenderer.tsx` | Graphics cue dispatcher; uses `CORE_TEMPLATE_MAP` + `getBrandOverlays(brand.id)` | Remove remaining brand hardcoding (Phase 0.5 Steps 6–7) | From e744e592fc8b57c3d64e0400fc4913b18ab2f0bd Mon Sep 17 00:00:00 2001 From: Natasha Ann Date: Fri, 15 May 2026 17:00:32 +0800 Subject: [PATCH 4/4] fix(hook-timing): strengthen de-overlap assertion and extract tail epsilon constant - W1: replace conditional if(sections.length>=2) with unconditional expect(sections).toHaveLength(2) so a de-overlap regression fails loudly - W2: extract 0.02 literal as SEGMENT_TAIL_EPSILON_SECONDS; import and use it in the new bridging edge-case test Co-Authored-By: Claude Sonnet 4.6 --- remotion/lib/hookTiming.test.ts | 19 ++++++++++++++++--- remotion/lib/hookTiming.ts | 6 +++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/remotion/lib/hookTiming.test.ts b/remotion/lib/hookTiming.test.ts index 3ff2271..22fee3e 100644 --- a/remotion/lib/hookTiming.test.ts +++ b/remotion/lib/hookTiming.test.ts @@ -12,6 +12,7 @@ import { HOOK_TAIL_PAD_UNBOUNDED_SECONDS, HOOK_TAIL_PAD_BOUNDED_SECONDS, HOOK_BRIDGE_MAX_GAP_SECONDS, + SEGMENT_TAIL_EPSILON_SECONDS, } from './hookTiming'; import type { Segment } from '../types/transcript'; @@ -110,6 +111,19 @@ describe('hookClipEnd', () => { // hasSpokenTokenAfterEnd = true → endsAtSegmentTail = false → no bridge expect(result).toBeCloseTo(15 + HOOK_TAIL_PAD_UNBOUNDED_SECONDS); }); + + it('SEGMENT_TAIL_EPSILON_SECONDS: token at exactly sourceEnd+epsilon does not suppress bridging', () => { + const seg = makeSegment({ + start: 10, + end: 15, + tokens: [makeToken(' hello', 15 + SEGMENT_TAIL_EPSILON_SECONDS)], + // token is at exactly the epsilon threshold — NOT past it, so still "at tail" + }); + // gap = 15.4 - 15 = 0.4 s → within bridge window + const result = hookClipEnd(seg, 15.4); + // endsAtSegmentTail = true (token is not > sourceEnd + epsilon) → bridge fires + expect(result).toBeCloseTo(15.4); + }); }); describe('bounded hook (hookTo set)', () => { @@ -260,9 +274,8 @@ describe('buildHookSections', () => { const seg2 = makeSegment({ id: 2, start: 11, end: 16, tokens: [] }); const sections = buildHookSections([seg1, seg2], FPS); // Even if there's overlap in raw sections, de-overlap pass fixes it - if (sections.length >= 2) { - expect(sections[1].trimBefore).toBeGreaterThanOrEqual(sections[0].trimAfter); - } + expect(sections).toHaveLength(2); + expect(sections[1].trimBefore).toBeGreaterThanOrEqual(sections[0].trimAfter); }); it('section trimAfter is always at least trimBefore + 1', () => { diff --git a/remotion/lib/hookTiming.ts b/remotion/lib/hookTiming.ts index a2d2c85..d117b6a 100644 --- a/remotion/lib/hookTiming.ts +++ b/remotion/lib/hookTiming.ts @@ -20,6 +20,10 @@ export const HOOK_TAIL_PAD_BOUNDED_SECONDS = 0.02; /** Max gap (seconds) between hook end and next hook start for bridging. */ export const HOOK_BRIDGE_MAX_GAP_SECONDS = 1.0; +/** Tolerance (seconds) used to detect whether a spoken token falls past sourceEnd, + * determining if the hook ends at the segment tail (bridging eligibility). */ +export const SEGMENT_TAIL_EPSILON_SECONDS = 0.02; + // ── Core timing function ────────────────────────────────────────────────────── /** @@ -62,7 +66,7 @@ export function hookClipEnd(segment: Segment, nextHookStart?: number): number { // Bridge to the next hook when the gap is small and this hook ends at the segment tail const hasSpokenTokenAfterEnd = segment.tokens.some( - t => isSpokenToken(t) && t.t_dtw > sourceEnd + 0.02, + t => isSpokenToken(t) && t.t_dtw > sourceEnd + SEGMENT_TAIL_EPSILON_SECONDS, ); const endsAtSegmentTail = !hasSpokenTokenAfterEnd; const canBridge = nextHookStart !== undefined