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
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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` |
Expand All @@ -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) |
Expand Down
38 changes: 1 addition & 37 deletions remotion/Composition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 1 addition & 39 deletions remotion/ShortFormClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 7 additions & 48 deletions remotion/components/CameraPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
}
Expand Down
42 changes: 3 additions & 39 deletions remotion/components/HookOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -146,9 +147,6 @@ function buildCaptions(
// ── Per-hook segment timing ────────────────────────────────────────────────────

type Page = ReturnType<typeof createTikTokStyleCaptions>['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;
Expand All @@ -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);

Expand Down
83 changes: 8 additions & 75 deletions remotion/components/SegmentPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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[] };

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;

/**
Expand Down
Loading
Loading