fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)

* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each)

* fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files

* feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson

Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux:
- Detects the platform automatically
- Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM)
- Falls back to clear manual instructions with exact commands
- 'hyperframes browser ensure' guides through the setup interactively
- After setup, all render commands work without any flags

* fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds

Path exclusions are insufficient — Defender re-scans new files created
during bun install before the exclusion takes effect. Disable real-time
monitoring for the entire job duration instead (standard CI practice).

* refactor(studio): split all files >500 LOC + extract useToast, delete allowlist

All 11 large files split into focused modules under 500 LOC.
App.tsx extracted toast logic into useToast hook (493 LOC now).
.filesize-allowlist deleted — no longer needed.

* fix: remove unused imports from split files, extract useToast from App.tsx

App.tsx: 504 → 493 lines (toast logic extracted to useToast hook)
timelineDOM.ts: remove unused imports from re-export pattern
MotionPanel.tsx: remove unused clampStudioCustomEasePoints import
studioMotionOps.ts: remove unused StudioGsapMotionDirection import

* fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs)

* fix(producer): use node --experimental-strip-types instead of tsx for build:fonts

Eliminates the tsx binary dependency that Windows Defender locks during
bun install, causing EPERM errors. Node 22.6+ strips TypeScript types
natively with no external binary.

* chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500)

* fix(ci): disable Windows Defender before checkout to prevent all EPERM races

* fix(producer): skip build:fonts if fontData.generated.ts already exists

The generated file is tracked in git, so CI doesn't need to regenerate
it. This avoids @fontsource/inter node_modules access on Windows which
triggers EPERM from Defender scanning during bun install.
This commit is contained in:
Miguel Ángel
2026-05-13 01:48:12 +02:00
committed by GitHub
parent 03475d54c6
commit 91bdffffe6
74 changed files with 11760 additions and 9759 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,434 @@
import { memo, type ReactNode } from "react";
import { TimelineClip } from "./TimelineClip";
import { TimelineRuler } from "./TimelineRuler";
import {
getTimelineEditCapabilities,
resolveBlockedTimelineEditIntent,
type TimelineRangeSelection,
} from "./timelineEditing";
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import { formatTime } from "../lib/time";
import type { TrackVisualStyle } from "./timelineIcons";
interface TimelineCanvasProps {
major: number[];
minor: number[];
pps: number;
trackContentWidth: number;
totalH: number;
effectiveDuration: number;
majorTickInterval: number;
shiftHeld: boolean;
rangeSelection: TimelineRangeSelection | null;
theme: TimelineTheme;
displayTrackOrder: number[];
trackOrder: number[];
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
selectedElementId: string | null;
hoveredClip: string | null;
draggedClip: DraggedClipState | null;
resizingClip: ResizingClipState | null;
blockedClipRef: React.RefObject<BlockedClipState | null>;
suppressClickRef: React.RefObject<boolean>;
scrollRef: React.RefObject<HTMLDivElement | null>;
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
renderClipOverlay?: (element: TimelineElement) => ReactNode;
playheadRef: React.RefObject<HTMLDivElement | null>;
onResizeElement?: unknown;
onMoveElement?: unknown;
onDrillDown?: (element: TimelineElement) => void;
onSelectElement?: (element: TimelineElement | null) => void;
setHoveredClip: (key: string | null) => void;
setShowPopover: (v: boolean) => void;
setRangeSelection: (v: null) => void;
setResizingClip: (v: ResizingClipState | null) => void;
setDraggedClip: (v: DraggedClipState | null) => void;
setSelectedElementId: (id: string | null) => void;
syncClipDragAutoScroll: (x: number, y: number) => void;
shiftClickClipRef: React.RefObject<{
element: TimelineElement;
anchorX: number;
anchorY: number;
} | null>;
getPreviewElement: (element: TimelineElement) => TimelineElement;
getTrackStyle: (tag: string) => TrackVisualStyle;
}
export const TimelineCanvas = memo(function TimelineCanvas({
major,
minor,
pps,
trackContentWidth,
totalH,
effectiveDuration,
majorTickInterval,
shiftHeld,
rangeSelection,
theme,
displayTrackOrder,
trackOrder,
tracks,
trackStyles,
selectedElementId,
hoveredClip,
draggedClip,
resizingClip: _resizingClip,
blockedClipRef,
suppressClickRef,
scrollRef,
renderClipContent,
renderClipOverlay,
playheadRef,
onResizeElement,
onMoveElement,
onDrillDown,
onSelectElement,
setHoveredClip,
setShowPopover,
setRangeSelection,
setResizingClip,
setDraggedClip,
setSelectedElementId,
syncClipDragAutoScroll,
shiftClickClipRef,
getPreviewElement,
getTrackStyle,
}: TimelineCanvasProps) {
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
? getRenderedTimelineElement({
element: draggedElement,
draggedElementId: draggedElement.key ?? draggedElement.id,
previewStart: draggedClip.previewStart,
previewTrack: draggedClip.previewTrack,
})
: null;
const activeDraggedPosition =
draggedClip?.started === true && activeDraggedElement && scrollRef.current
? {
left:
draggedClip.pointerClientX -
scrollRef.current.getBoundingClientRect().left +
scrollRef.current.scrollLeft -
draggedClip.pointerOffsetX,
top:
draggedClip.pointerClientY -
scrollRef.current.getBoundingClientRect().top +
scrollRef.current.scrollTop -
draggedClip.pointerOffsetY,
}
: null;
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
<>
{renderClipOverlay?.(element)}
<div
className={
renderClipContent
? "absolute inset-0 overflow-hidden"
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
}
>
{renderClipContent?.(element, clipStyle) ?? (
<div className="flex h-full min-h-0 flex-col justify-between py-3">
<span
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
style={{
color: clipStyle.label,
background: `${clipStyle.accent}26`,
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
}}
>
{element.tag}
</span>
<span
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.04)" }}
>
{formatTime(element.start)} {"→"} {formatTime(element.start + element.duration)}
</span>
</div>
)}
</div>
</>
);
return (
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
<TimelineRuler
major={major}
minor={minor}
pps={pps}
trackContentWidth={trackContentWidth}
totalH={totalH}
effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval}
shiftHeld={shiftHeld}
rangeSelection={rangeSelection}
theme={theme}
/>
{displayTrackOrder.map((trackNum) => {
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
return (
<div
key={trackNum}
className="relative flex"
style={{
height: TRACK_H,
background: theme.rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<div
className="flex-shrink-0 flex items-center justify-center"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
<div
className="flex items-center justify-center"
style={{
width: 18,
height: 18,
borderRadius: 6,
backgroundColor: ts.iconBackground,
border: `1px solid ${theme.gutterBorder}`,
color: "#fff",
}}
>
{ts.icon}
</div>
</div>
<div style={{ width: trackContentWidth }} className="relative">
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase",
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
}}
>
New track
</div>
)}
{els.map((el, i) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
const capabilities = getTimelineEditCapabilities(el);
const isSelected = selectedElementId === elementKey;
const isComposition = !!el.compositionSrc;
const clipKey = `${elementKey}-${i}`;
const isDraggingClip =
draggedClip?.started === true &&
(draggedElement?.key ?? draggedElement?.id) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
return (
<TimelineClip
key={clipKey}
el={previewElement}
pps={pps}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
hasCustomContent={!!renderClipContent}
theme={theme}
trackStyle={clipStyle}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={(edge, e) => {
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
element: el,
edge,
originClientX: e.clientX,
previewStart: el.start,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
started: false,
});
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
element: el,
originClientX: e.clientX,
originClientY: e.clientY,
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
originScrollTop: scrollRef.current?.scrollTop ?? 0,
pointerClientX: e.clientX,
pointerClientY: e.clientY,
pointerOffsetX: e.clientX - rect.left,
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
}}
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
if (isComposition && onDrillDown) onDrillDown(el);
}}
>
{renderClipChildren(previewElement, clipStyle)}
</TimelineClip>
);
})}
</div>
</div>
);
})}
{/* Drag ghost */}
{activeDraggedElement && activeDraggedPosition && (
<div
className="absolute pointer-events-none"
style={{
top: activeDraggedPosition.top,
left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * pps, 4),
height: TRACK_H - CLIP_Y * 2,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...activeDraggedElement, start: 0 }}
pps={pps}
clipY={0}
isSelected={selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)}
isHovered={false}
isDragging={true}
hasCustomContent={!!renderClipContent}
theme={theme}
trackStyle={getTrackStyle(activeDraggedElement.tag)}
isComposition={!!activeDraggedElement.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
</TimelineClip>
</div>
)}
{/* Range highlight */}
{rangeSelection && (
<div
className="absolute pointer-events-none"
style={{
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
top: RULER_H,
bottom: 0,
backgroundColor: "rgba(59, 130, 246, 0.12)",
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
zIndex: 50,
}}
/>
)}
{/* Playhead */}
<div
ref={playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: `${GUTTER}px`, zIndex: 100 }}
>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
</div>
</div>
);
});
@@ -0,0 +1,102 @@
import type { DragEventHandler } from "react";
import { GUTTER, RULER_H } from "./timelineLayout";
interface TimelineEmptyStateProps {
isDragOver: boolean;
onFileDrop?: boolean;
onDragOver: DragEventHandler<HTMLDivElement>;
onDragLeave: DragEventHandler<HTMLDivElement>;
onDrop: DragEventHandler<HTMLDivElement>;
}
export function TimelineEmptyState({
isDragOver,
onFileDrop,
onDragOver,
onDragLeave,
onDrop,
}: TimelineEmptyStateProps) {
return (
<div
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{/* Ruler */}
<div
className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
style={{ height: RULER_H, paddingLeft: GUTTER }}
>
{[0, 10, 20, 30, 40, 50].map((s) => (
<div
key={s}
className="flex flex-col items-center"
style={{ position: "absolute", left: GUTTER + s * 14 }}
>
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
</span>
<div className="w-px h-[5px] bg-neutral-700/40" />
</div>
))}
</div>
{/* Empty drop zone */}
<div className="flex-1 flex items-center justify-center">
<div
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
isDragOver ? "border-studio-accent/60 bg-studio-accent/[0.06]" : "border-neutral-700/50"
}`}
>
{isDragOver ? (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-studio-accent flex-shrink-0"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
</>
) : (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-neutral-600 flex-shrink-0"
>
<rect x="2" y="2" width="20" height="20" rx="2" />
<path d="M7 2v20" />
<path d="M17 2v20" />
<path d="M2 7h20" />
<path d="M2 17h20" />
</svg>
<span className="text-[13px] text-neutral-500">
{onFileDrop
? "Drop media here or describe your video to start"
: "Describe your video to start creating"}
</span>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineRangeSelection } from "./timelineEditing";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
interface TimelineRulerProps {
major: number[];
minor: number[];
pps: number;
trackContentWidth: number;
totalH: number;
effectiveDuration: number;
majorTickInterval: number;
shiftHeld: boolean;
rangeSelection: TimelineRangeSelection | null;
theme: TimelineTheme;
}
export const TimelineRuler = memo(function TimelineRuler({
major,
minor,
pps,
trackContentWidth,
totalH,
effectiveDuration,
majorTickInterval,
shiftHeld,
rangeSelection,
theme,
}: TimelineRulerProps) {
return (
<>
{/* Grid lines */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth }}
height={totalH}
>
{major.map((t) => {
const x = t * pps;
return (
<line
key={`g-${t}`}
x1={x}
y1={RULER_H}
x2={x}
y2={totalH}
stroke={theme.tickMinor}
strokeWidth="1"
/>
);
})}
</svg>
{/* Ruler */}
<div
className="relative overflow-hidden"
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
>
{shiftHeld && !rangeSelection && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
Drag or click a clip to edit range
</span>
</div>
)}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
</div>
))}
{major.map((t) => (
<div
key={`M-${t}`}
className="absolute bottom-0 flex flex-col items-center"
style={{ left: t * pps }}
>
<span
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
style={{ color: theme.tickText }}
>
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
</div>
))}
</div>
</>
);
});
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
export interface TrackVisualStyle extends TimelineTrackStyle {
icon: ReactNode;
}
const ICON_BASE = "/icons/timeline";
function TimelineIcon({ src }: { src: string }) {
return (
<img
src={src}
alt=""
width={12}
height={12}
style={{ filter: "brightness(0) invert(1)" }}
draggable={false}
/>
);
}
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
const ICONS: Record<string, ReactNode> = {
video: IconImage,
audio: IconMusic,
img: IconImage,
div: IconComposition,
span: IconCaptions,
p: IconText,
h1: IconText,
section: IconComposition,
sfx: IconAudio,
};
export function getTrackStyle(tag: string): TrackVisualStyle {
const trackStyle = getTimelineTrackStyle(tag);
const normalized = tag.toLowerCase();
const icon =
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
? ICONS.h1
: (ICONS[normalized] ?? IconComposition);
return { ...trackStyle, icon };
}
@@ -0,0 +1,215 @@
import { formatTime } from "../lib/time";
import type { ZoomMode } from "../store/playerStore";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const TRACK_H = 72;
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export const TIMELINE_SCROLL_BUFFER = 20;
/* ── Tick generation ──────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 128;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
}
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
let interval = majorInterval / 2;
if (majorInterval >= 30) interval = majorInterval / 6;
else if (majorInterval >= 15) interval = majorInterval / 3;
else if (majorInterval >= 5) interval = majorInterval / 5;
else if (majorInterval >= 1) interval = majorInterval / 4;
if (
Number.isFinite(pixelsPerSecond) &&
(pixelsPerSecond ?? 0) > 0 &&
interval * (pixelsPerSecond ?? 0) < 20
) {
return Math.max(0.25, majorInterval / 2);
}
return Math.max(0.25, interval);
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
return { major: [], minor: [] };
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
t += minorInterval
) {
const rounded = Math.round(t * 100) / 100;
const isMajor =
Math.abs(rounded % majorInterval) < 0.01 ||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
if (isMajor) major.push(rounded);
else minor.push(rounded);
}
return { major, minor };
}
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "0:00";
const safeTime = Math.max(0, time);
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
const tenth = totalTenths % 10;
return `${formatTime(wholeSeconds)}.${tenth}`;
}
if (duration >= 3600 || safeTime >= 3600) {
const totalSeconds = Math.floor(safeTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return formatTime(safeTime);
}
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
clientWidth: number,
): boolean {
if (zoomMode === "fit") return false;
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
return scrollWidth - clientWidth > 1;
}
export function getTimelineScrollLeftForZoomTransition(
previousZoomMode: ZoomMode | null,
nextZoomMode: ZoomMode,
currentScrollLeft: number,
): number {
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
return currentScrollLeft;
}
export function getTimelineScrollLeftForZoomAnchor(input: {
pointerX: number;
currentScrollLeft: number;
gutter: number;
currentPixelsPerSecond: number;
nextPixelsPerSecond: number;
duration: number;
}): number {
const currentPps = Math.max(0, input.currentPixelsPerSecond);
const nextPps = Math.max(0, input.nextPixelsPerSecond);
if (
!Number.isFinite(input.pointerX) ||
!Number.isFinite(input.currentScrollLeft) ||
!Number.isFinite(input.duration) ||
input.duration <= 0 ||
currentPps <= 0 ||
nextPps <= 0
) {
return Math.max(0, input.currentScrollLeft);
}
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
}
/* ── Playhead / canvas ────────────────────────────────────────────── */
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
}
export function getTimelineCanvasHeight(trackCount: number): number {
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
}
/* ── UI helpers ───────────────────────────────────────────────────── */
export function shouldShowTimelineShortcutHint(
scrollHeight: number,
clientHeight: number,
): boolean {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
return scrollHeight - clientHeight <= 1;
}
export function shouldHandleTimelineDeleteKey(input: {
key: string;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
target?: EventTarget | null;
}): boolean {
if (input.key !== "Delete" && input.key !== "Backspace") return false;
if (input.metaKey || input.ctrlKey || input.altKey) return false;
const target =
input.target && typeof input.target === "object"
? (input.target as {
tagName?: string;
isContentEditable?: boolean;
closest?: (selector: string) => Element | null;
})
: null;
if (target) {
const tag = target.tagName?.toLowerCase() ?? "";
if (target.isContentEditable) return false;
if (["input", "textarea", "select"].includes(tag)) return false;
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
return false;
}
}
return true;
}
/* ── Asset drop ───────────────────────────────────────────────────── */
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
if (trackOrder.length === 0) return 0;
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
if (rowIndex >= trackOrder.length) {
return Math.max(...trackOrder) + 1;
}
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
}
export function resolveTimelineAssetDrop(
input: {
rectLeft: number;
rectTop: number;
scrollLeft: number;
scrollTop: number;
pixelsPerSecond: number;
duration: number;
trackHeight: number;
trackOrder: number[];
},
clientX: number,
clientY: number,
): { start: number; track: number } {
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
const start = Math.max(
0,
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
);
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
return {
start,
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}
@@ -0,0 +1,211 @@
import { formatTime } from "../lib/time";
import type { ZoomMode } from "../store/playerStore";
/* ── Layout constants ─────────────────────────────────────────────── */
export const GUTTER = 32;
export const TRACK_H = 72;
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export const TIMELINE_SCROLL_BUFFER = 20;
/* ── Tick Generation ────────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 128;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
}
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
let interval = majorInterval / 2;
if (majorInterval >= 30) interval = majorInterval / 6;
else if (majorInterval >= 15) interval = majorInterval / 3;
else if (majorInterval >= 5) interval = majorInterval / 5;
else if (majorInterval >= 1) interval = majorInterval / 4;
if (
Number.isFinite(pixelsPerSecond) &&
(pixelsPerSecond ?? 0) > 0 &&
interval * (pixelsPerSecond ?? 0) < 20
) {
return Math.max(0.25, majorInterval / 2);
}
return Math.max(0.25, interval);
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
return { major: [], minor: [] };
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000;
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
t += minorInterval
) {
const rounded = Math.round(t * 100) / 100;
const isMajor =
Math.abs(rounded % majorInterval) < 0.01 ||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
if (isMajor) major.push(rounded);
else minor.push(rounded);
}
return { major, minor };
}
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "0:00";
const safeTime = Math.max(0, time);
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
const tenth = totalTenths % 10;
return `${formatTime(wholeSeconds)}.${tenth}`;
}
if (duration >= 3600 || safeTime >= 3600) {
const totalSeconds = Math.floor(safeTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return formatTime(safeTime);
}
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
clientWidth: number,
): boolean {
if (zoomMode === "fit") return false;
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
return scrollWidth - clientWidth > 1;
}
export function getTimelineScrollLeftForZoomTransition(
previousZoomMode: ZoomMode | null,
nextZoomMode: ZoomMode,
currentScrollLeft: number,
): number {
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
return currentScrollLeft;
}
export function getTimelineScrollLeftForZoomAnchor(input: {
pointerX: number;
currentScrollLeft: number;
gutter: number;
currentPixelsPerSecond: number;
nextPixelsPerSecond: number;
duration: number;
}): number {
const currentPps = Math.max(0, input.currentPixelsPerSecond);
const nextPps = Math.max(0, input.nextPixelsPerSecond);
if (
!Number.isFinite(input.pointerX) ||
!Number.isFinite(input.currentScrollLeft) ||
!Number.isFinite(input.duration) ||
input.duration <= 0 ||
currentPps <= 0 ||
nextPps <= 0
) {
return Math.max(0, input.currentScrollLeft);
}
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
}
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
}
export function getTimelineCanvasHeight(trackCount: number): number {
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
}
export function shouldShowTimelineShortcutHint(
scrollHeight: number,
clientHeight: number,
): boolean {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
return scrollHeight - clientHeight <= 1;
}
export function shouldHandleTimelineDeleteKey(input: {
key: string;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
target?: EventTarget | null;
}): boolean {
if (input.key !== "Delete" && input.key !== "Backspace") return false;
if (input.metaKey || input.ctrlKey || input.altKey) return false;
const target =
input.target && typeof input.target === "object"
? (input.target as {
tagName?: string;
isContentEditable?: boolean;
closest?: (selector: string) => Element | null;
})
: null;
if (target) {
const tag = target.tagName?.toLowerCase() ?? "";
if (target.isContentEditable) return false;
if (["input", "textarea", "select"].includes(tag)) return false;
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
return false;
}
}
return true;
}
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
if (trackOrder.length === 0) return 0;
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
if (rowIndex >= trackOrder.length) {
return Math.max(...trackOrder) + 1;
}
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
}
export function resolveTimelineAssetDrop(
input: {
rectLeft: number;
rectTop: number;
scrollLeft: number;
scrollTop: number;
pixelsPerSecond: number;
duration: number;
trackHeight: number;
trackOrder: number[];
},
clientX: number,
clientY: number,
): { start: number; track: number } {
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
const start = Math.max(
0,
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
);
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
return {
start,
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}
@@ -0,0 +1,388 @@
import { useRef, useState, useCallback } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import {
resolveTimelineMove,
resolveTimelineResize,
resolveTimelineAutoScroll,
type BlockedTimelineEditIntent,
} from "./timelineEditing";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
/* ── Shared state types ─────────────────────────────────────────── */
export interface DraggedClipState {
element: TimelineElement;
originClientX: number;
originClientY: number;
originScrollLeft: number;
originScrollTop: number;
pointerClientX: number;
pointerClientY: number;
pointerOffsetX: number;
pointerOffsetY: number;
previewStart: number;
previewTrack: number;
started: boolean;
}
export interface ResizingClipState {
element: TimelineElement;
edge: "start" | "end";
originClientX: number;
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
started: boolean;
}
export interface BlockedClipState {
element: TimelineElement;
intent: BlockedTimelineEditIntent;
originClientX: number;
originClientY: number;
started: boolean;
}
/* ── Hook ───────────────────────────────────────────────────────── */
interface UseTimelineClipDragInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>;
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
setShowPopover: (show: boolean) => void;
/** Stable ref to the range selection setter — wired after mount to break circular dependency. */
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
}
export function useTimelineClipDrag({
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
setShowPopover,
setRangeSelectionRef,
}: UseTimelineClipDragInput) {
const updateElement = usePlayerStore((s) => s.updateElement);
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
const draggedClipRef = useRef<DraggedClipState | null>(null);
draggedClipRef.current = draggedClip;
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
const resizingClipRef = useRef<ResizingClipState | null>(null);
resizingClipRef.current = resizingClip;
const blockedClipRef = useRef<BlockedClipState | null>(null);
const suppressClickRef = useRef(false);
const onMoveElementRef = useRef(onMoveElement);
onMoveElementRef.current = onMoveElement;
const onResizeElementRef = useRef(onResizeElement);
onResizeElementRef.current = onResizeElement;
const clipDragScrollRaf = useRef(0);
const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const updateDraggedClipPreview = useCallback(
(drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
const scroll = scrollRef.current;
const nextMove = resolveTimelineMove(
{
start: drag.element.start,
track: drag.element.track,
duration: drag.element.duration,
originClientX: drag.originClientX,
originClientY: drag.originClientY,
originScrollLeft: drag.originScrollLeft,
originScrollTop: drag.originScrollTop,
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
pixelsPerSecond: ppsRef.current,
trackHeight: TRACK_H,
maxStart: Math.max(0, durationRef.current - drag.element.duration),
trackOrder: trackOrderRef.current,
},
clientX,
clientY,
);
return {
...drag,
started: true,
pointerClientX: clientX,
pointerClientY: clientY,
previewStart: nextMove.start,
previewTrack: nextMove.track,
};
},
[scrollRef, ppsRef, durationRef, trackOrderRef],
);
const stopClipDragAutoScroll = useCallback(() => {
clipDragPointerRef.current = null;
if (clipDragScrollRaf.current) {
cancelAnimationFrame(clipDragScrollRaf.current);
clipDragScrollRaf.current = 0;
}
}, []);
const stepClipDragAutoScroll = useCallback(() => {
clipDragScrollRaf.current = 0;
const drag = draggedClipRef.current;
const pointer = clipDragPointerRef.current;
const scroll = scrollRef.current;
if (!drag || !pointer || !scroll) return;
const rect = scroll.getBoundingClientRect();
const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
if (delta.x === 0 && delta.y === 0) return;
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return;
scroll.scrollLeft = nextScrollLeft;
scroll.scrollTop = nextScrollTop;
setDraggedClip((prev) =>
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
);
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
}, [scrollRef, updateDraggedClipPreview]);
const syncClipDragAutoScroll = useCallback(
(clientX: number, clientY: number) => {
clipDragPointerRef.current = { clientX, clientY };
const scroll = scrollRef.current;
if (!scroll) return;
const rect = scroll.getBoundingClientRect();
const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
if (delta.x === 0 && delta.y === 0) {
if (clipDragScrollRaf.current) {
cancelAnimationFrame(clipDragScrollRaf.current);
clipDragScrollRaf.current = 0;
}
return;
}
if (!clipDragScrollRaf.current) {
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
}
},
[scrollRef, stepClipDragAutoScroll],
);
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
useMountEffect(() => {
const clearSuppressedClick = () => {
requestAnimationFrame(() => {
suppressClickRef.current = false;
});
};
const handleWindowPointerMove = (e: PointerEvent) => {
const drag = draggedClipRef.current;
const resize = resizingClipRef.current;
const blocked = blockedClipRef.current;
if (resize) {
const distance = Math.abs(e.clientX - resize.originClientX);
if (!resize.started && distance < 2) return;
setShowPopover(false);
setRangeSelectionRef.current?.(null);
const sourceRemaining =
resize.element.sourceDuration != null
? Math.max(
0,
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
Math.max(resize.element.playbackRate ?? 1, 0.1),
)
: Number.POSITIVE_INFINITY;
const normalizedTag = resize.element.tag.toLowerCase();
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
const nextResize = resolveTimelineResize(
{
start: resize.element.start,
duration: resize.element.duration,
originClientX: resize.originClientX,
pixelsPerSecond: ppsRef.current,
minStart: 0,
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
playbackStart:
resize.edge === "start" && canSeedPlaybackStart
? (resize.element.playbackStart ?? 0)
: resize.element.playbackStart,
playbackRate: resize.element.playbackRate,
},
resize.edge,
e.clientX,
);
setResizingClip((prev) =>
prev
? {
...prev,
started: true,
previewStart: nextResize.start,
previewDuration: nextResize.duration,
previewPlaybackStart: nextResize.playbackStart,
}
: prev,
);
return;
}
if (blocked) {
const distance = Math.hypot(
e.clientX - blocked.originClientX,
e.clientY - blocked.originClientY,
);
const threshold = blocked.intent === "move" ? 4 : 2;
if (!blocked.started && distance < threshold) return;
if (!blocked.started) {
blocked.started = true;
blockedClipRef.current = blocked;
suppressClickRef.current = true;
setShowPopover(false);
setRangeSelectionRef.current?.(null);
onBlockedEditAttempt?.(blocked.element, blocked.intent);
}
return;
}
if (!drag) return;
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
if (!drag.started && distance < 4) return;
setShowPopover(false);
setRangeSelectionRef.current?.(null);
setDraggedClip((prev) =>
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
);
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
};
const handleWindowPointerUp = () => {
stopClipDragAutoScrollRef.current();
const resize = resizingClipRef.current;
if (resize) {
resizingClipRef.current = null;
setResizingClip(null);
if (!resize.started) return;
suppressClickRef.current = true;
clearSuppressedClick();
const hasChanged =
resize.previewStart !== resize.element.start ||
resize.previewDuration !== resize.element.duration ||
resize.previewPlaybackStart !== resize.element.playbackStart;
if (!hasChanged) return;
updateElement(resize.element.key ?? resize.element.id, {
start: resize.previewStart,
duration: resize.previewDuration,
playbackStart: resize.previewPlaybackStart,
});
Promise.resolve(
onResizeElementRef.current?.(resize.element, {
start: resize.previewStart,
duration: resize.previewDuration,
playbackStart: resize.previewPlaybackStart,
}),
).catch((error) => {
updateElement(resize.element.key ?? resize.element.id, {
start: resize.element.start,
duration: resize.element.duration,
playbackStart: resize.element.playbackStart,
});
console.error("[Timeline] Failed to persist clip resize", error);
});
return;
}
const blocked = blockedClipRef.current;
if (blocked) {
blockedClipRef.current = null;
if (!blocked.started) return;
clearSuppressedClick();
return;
}
const drag = draggedClipRef.current;
if (!drag) return;
draggedClipRef.current = null;
setDraggedClip(null);
if (!drag.started) return;
suppressClickRef.current = true;
clearSuppressedClick();
const hasChanged =
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
if (!hasChanged) return;
updateElement(drag.element.key ?? drag.element.id, {
start: drag.previewStart,
track: drag.previewTrack,
});
Promise.resolve(
onMoveElementRef.current?.(drag.element, {
start: drag.previewStart,
track: drag.previewTrack,
}),
).catch((error) => {
updateElement(drag.element.key ?? drag.element.id, {
start: drag.element.start,
track: drag.element.track,
});
console.error("[Timeline] Failed to persist clip move", error);
});
};
window.addEventListener("pointermove", handleWindowPointerMove);
window.addEventListener("pointerup", handleWindowPointerUp);
window.addEventListener("pointercancel", handleWindowPointerUp);
return () => {
stopClipDragAutoScrollRef.current();
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
window.removeEventListener("pointercancel", handleWindowPointerUp);
};
});
return {
draggedClip,
setDraggedClip,
resizingClip,
setResizingClip,
blockedClipRef,
suppressClickRef,
syncClipDragAutoScroll,
stopClipDragAutoScroll,
};
}
@@ -0,0 +1,200 @@
import { useRef, useCallback, useEffect } from "react";
import { liveTime, type ZoomMode } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { getPinchTimelineZoomPercent } from "./timelineZoom";
import {
GUTTER,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
shouldAutoScrollTimeline,
} from "./timelineLayout";
interface UseTimelinePlayheadInput {
playheadRef: React.RefObject<HTMLDivElement | null>;
scrollRef: React.RefObject<HTMLDivElement | null>;
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
isDragging: React.RefObject<boolean>;
currentTime: number;
zoomMode: ZoomMode;
manualZoomPercent: number;
zoomModeRef: React.RefObject<ZoomMode>;
manualZoomPercentRef: React.RefObject<number>;
fitPps: number;
fitPpsRef: React.RefObject<number>;
effectiveDuration: number;
pps: number;
timelineReady: boolean;
elementsLength: number;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
onSeek?: (time: number) => void;
}
export function useTimelinePlayhead({
playheadRef,
scrollRef,
ppsRef,
durationRef,
isDragging,
currentTime,
zoomMode,
zoomModeRef,
manualZoomPercentRef,
fitPps: _fitPps,
fitPpsRef,
effectiveDuration,
pps,
timelineReady,
elementsLength,
setZoomMode,
setManualZoomPercent,
onSeek,
}: UseTimelinePlayheadInput) {
const dragScrollRaf = useRef(0);
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
const syncPlayheadPosition = useCallback(
(time: number) => {
if (!playheadRef.current || durationRef.current <= 0) return;
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
},
[playheadRef, durationRef, ppsRef],
);
useEffect(() => {
syncPlayheadPosition(currentTime);
}, [currentTime, pps, syncPlayheadPosition]);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) {
previousZoomModeRef.current = zoomMode;
return;
}
scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
previousZoomModeRef.current,
zoomMode,
scroll.scrollLeft,
);
previousZoomModeRef.current = zoomMode;
}, [zoomMode, scrollRef]);
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
if (!playheadRef.current || durationRef.current <= 0) return;
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
playheadRef.current.style.left = `${playheadX}px`;
const scroll = scrollRef.current;
if (
scroll &&
!isDragging.current &&
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
) {
const edgeMargin = scroll.clientWidth * 0.12;
if (playheadX > scroll.scrollLeft + scroll.clientWidth - edgeMargin)
scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
else if (playheadX < scroll.scrollLeft + GUTTER)
scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
}
});
return unsub;
});
const seekFromX = useCallback(
(clientX: number) => {
const el = scrollRef.current;
if (!el || effectiveDuration <= 0) return;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left + el.scrollLeft - GUTTER;
if (x < 0) return;
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
liveTime.notify(time);
onSeek?.(time);
},
[scrollRef, effectiveDuration, pps, onSeek],
);
const autoScrollDuringDrag = useCallback(
(clientX: number) => {
cancelAnimationFrame(dragScrollRaf.current);
const el = scrollRef.current;
if (
!el ||
!isDragging.current ||
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
)
return;
const rect = el.getBoundingClientRect();
const edgeZone = 40;
const maxSpeed = 12;
let scrollDelta = 0;
if (clientX < rect.left + edgeZone)
scrollDelta = -maxSpeed * Math.max(0, 1 - (clientX - rect.left) / edgeZone);
else if (clientX > rect.right - edgeZone)
scrollDelta = maxSpeed * Math.max(0, 1 - (rect.right - clientX) / edgeZone);
if (scrollDelta !== 0) {
el.scrollLeft += scrollDelta;
seekFromX(clientX);
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
}
},
[scrollRef, isDragging, zoomModeRef, seekFromX],
);
const handlePinchWheel = useCallback(
(e: WheelEvent) => {
if (!e.ctrlKey) return;
const scroll = scrollRef.current;
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0)
return;
e.preventDefault();
e.stopPropagation();
const rect = scroll.getBoundingClientRect();
const nextZoomPercent = getPinchTimelineZoomPercent(
e.deltaY,
zoomModeRef.current,
manualZoomPercentRef.current,
);
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
return;
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX: e.clientX - rect.left,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER,
currentPixelsPerSecond: ppsRef.current,
nextPixelsPerSecond: nextPps,
duration: durationRef.current,
});
setZoomMode("manual");
setManualZoomPercent(nextZoomPercent);
requestAnimationFrame(() => {
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
});
},
[
scrollRef,
durationRef,
fitPpsRef,
ppsRef,
zoomModeRef,
manualZoomPercentRef,
setManualZoomPercent,
setZoomMode,
],
);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
return () => {
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
};
}, [handlePinchWheel, scrollRef, timelineReady, elementsLength]);
return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
}
@@ -0,0 +1,135 @@
import { useRef, useState, useCallback } from "react";
import { buildClipRangeSelection, type TimelineRangeSelection } from "./timelineEditing";
import type { TimelineElement } from "../store/playerStore";
import { liveTime } from "../store/playerStore";
import { GUTTER } from "./timelineLayout";
interface UseTimelineRangeSelectionInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
ppsRef: React.RefObject<number>;
effectiveDuration: number;
pps: number;
onSeek?: (time: number) => void;
seekFromX: (clientX: number) => void;
autoScrollDuringDrag: (clientX: number) => void;
dragScrollRaf: React.RefObject<number>;
isDragging: React.RefObject<boolean>;
setShowPopover: (v: boolean) => void;
}
export function useTimelineRangeSelection({
scrollRef,
ppsRef: _ppsRef,
effectiveDuration: _effectiveDuration,
pps,
onSeek: _onSeek,
seekFromX,
autoScrollDuringDrag,
dragScrollRaf,
isDragging,
setShowPopover,
}: UseTimelineRangeSelectionInput) {
const isRangeSelecting = useRef(false);
const rangeAnchorTime = useRef(0);
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
const shiftClickClipRef = useRef<{
element: TimelineElement;
anchorX: number;
anchorY: number;
} | null>(null);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return;
if (e.shiftKey) {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isRangeSelecting.current = true;
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
const time = Math.max(0, x / pps);
rangeAnchorTime.current = time;
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
}
return;
}
shiftClickClipRef.current = null;
if ((e.target as HTMLElement).closest("[data-clip]")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isDragging.current = true;
setRangeSelection(null);
setShowPopover(false);
seekFromX(e.clientX);
},
[seekFromX, pps, scrollRef, isDragging, setShowPopover],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (isRangeSelecting.current) {
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
setRangeSelection((prev) =>
prev
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
: null,
);
}
return;
}
if (!isDragging.current) return;
seekFromX(e.clientX);
autoScrollDuringDrag(e.clientX);
},
[seekFromX, autoScrollDuringDrag, pps, scrollRef, isDragging],
);
const handlePointerUp = useCallback(() => {
if (isRangeSelecting.current) {
isRangeSelecting.current = false;
const pendingShiftClick = shiftClickClipRef.current;
shiftClickClipRef.current = null;
setRangeSelection((prev) => {
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
setShowPopover(true);
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
}
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
setShowPopover(true);
return prev;
}
return null;
});
return;
}
isDragging.current = false;
cancelAnimationFrame(dragScrollRaf.current);
}, [isDragging, dragScrollRaf, setShowPopover]);
return {
rangeSelection,
setRangeSelection,
shiftClickClipRef,
handlePointerDown,
handlePointerMove,
handlePointerUp,
};
}
/* ── Seek + scroll utilities (used in Timeline only) ──────────────── */
export function seekTimeFromScrollX(
scrollEl: HTMLDivElement,
clientX: number,
effectiveDuration: number,
pps: number,
onSeek?: (time: number) => void,
): void {
const rect = scrollEl.getBoundingClientRect();
const x = clientX - rect.left + scrollEl.scrollLeft - GUTTER;
if (x < 0) return;
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
liveTime.notify(time);
onSeek?.(time);
}
@@ -0,0 +1,171 @@
/**
* Keyboard shortcut handler for playback (Space/JKL/Arrow keys) and
* iframe shortcut listener setup.
*
* Accepts stable playback callbacks and returns the keyboard event handlers
* and iframe listener setup function. Has no side effects of its own.
*/
import { useRef, useCallback } from "react";
import { useCaptionStore } from "../../captions/store";
import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts";
import { usePlayerStore } from "../store/playerStore";
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
import type { PlaybackAdapter } from "../lib/playbackTypes";
interface UsePlaybackKeyboardParams {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
shuttleDirectionRef: React.MutableRefObject<"forward" | "backward" | null>;
shuttleSpeedIndexRef: React.MutableRefObject<number>;
iframeShortcutCleanupRef: React.MutableRefObject<(() => void) | null>;
getAdapter: () => PlaybackAdapter | null;
play: () => void;
playBackward: (rate: number) => void;
pause: () => void;
seek: (time: number) => void;
}
export function usePlaybackKeyboard({
iframeRef,
shuttleDirectionRef,
shuttleSpeedIndexRef,
iframeShortcutCleanupRef,
getAdapter,
play,
playBackward,
pause,
seek,
}: UsePlaybackKeyboardParams) {
const pressedCodesRef = useRef(new Set<string>());
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
const stepFrames = useCallback(
(deltaFrames: number) => {
const adapter = getAdapter();
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
},
[getAdapter, seek],
);
const shuttle = useCallback(
(direction: "forward" | "backward") => {
if (shuttleDirectionRef.current === direction) {
shuttleSpeedIndexRef.current = Math.min(
shuttleSpeedIndexRef.current + 1,
SHUTTLE_SPEEDS.length - 1,
);
} else {
shuttleSpeedIndexRef.current = 0;
}
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
usePlayerStore.getState().setPlaybackRate(speed);
if (direction === "forward") {
play();
} else {
playBackward(speed);
}
},
[play, playBackward, shuttleDirectionRef, shuttleSpeedIndexRef],
);
const togglePlay = useCallback(() => {
if (usePlayerStore.getState().isPlaying) {
pause();
} else {
play();
}
}, [play, pause]);
const handlePlaybackKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const captionState = useCaptionStore.getState();
if (
shouldIgnorePlaybackShortcutEvent(e, {
isCaptionEditMode: captionState.isEditMode,
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
})
) {
return;
}
pressedCodesRef.current.add(e.code);
if (e.code === "Space") {
e.preventDefault();
togglePlay();
return;
}
if (e.code === "ArrowLeft") {
e.preventDefault();
stepFrames(e.shiftKey ? -10 : -1);
return;
}
if (e.code === "ArrowRight") {
e.preventDefault();
stepFrames(e.shiftKey ? 10 : 1);
return;
}
if (e.repeat) return;
if (e.code === "KeyK") {
e.preventDefault();
pause();
return;
}
if (e.code === "KeyJ") {
e.preventDefault();
if (pressedCodesRef.current.has("KeyK")) {
stepFrames(-1);
return;
}
shuttle("backward");
return;
}
if (e.code === "KeyL") {
e.preventDefault();
if (pressedCodesRef.current.has("KeyK")) {
stepFrames(1);
return;
}
shuttle("forward");
}
},
[pause, shuttle, stepFrames, togglePlay],
);
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
pressedCodesRef.current.delete(e.code);
}, []);
playbackKeyDownRef.current = handlePlaybackKeyDown;
playbackKeyUpRef.current = handlePlaybackKeyUp;
const attachIframeShortcutListeners = useCallback(() => {
iframeShortcutCleanupRef.current?.();
iframeShortcutCleanupRef.current = null;
const iframeWin = iframeRef.current?.contentWindow;
const iframeDoc = iframeRef.current?.contentDocument;
if (!iframeWin && !iframeDoc) return;
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
iframeShortcutCleanupRef.current = () => {
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
};
}, [iframeRef, iframeShortcutCleanupRef]);
return {
playbackKeyDownRef,
playbackKeyUpRef,
attachIframeShortcutListeners,
togglePlay,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
/**
* React callbacks for synchronising the player store from iframe runtime data.
*
* Covers four related concerns:
* - processTimelineMessage turn a clip-manifest postMessage into TimelineElements
* - enrichMissingCompositions fill gaps the manifest misses (element-ref starts)
* - initializeAdapter called after iframe load: seek, set duration, read elements
* - onIframeLoad orchestrates initializeAdapter with a message-based fallback
*/
import { useCallback } from "react";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
import {
parseTimelineFromDOM,
createTimelineElementFromManifestClip,
findTimelineDomNodeForClip,
createImplicitTimelineLayersFromDOM,
buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades,
getTimelineElementSelector,
} from "../lib/timelineDOM";
import {
normalizePreviewViewport,
autoHealMissingCompositionIds,
unmutePreviewMedia,
buildMissingCompositionElements,
} from "../lib/timelineIframeHelpers";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
interface UseTimelineSyncCallbacksParams {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
pendingSeekRef: React.MutableRefObject<number | null>;
isRefreshingRef: React.MutableRefObject<boolean>;
getAdapter: () => PlaybackAdapter | null;
syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
setDuration: (v: number) => void;
setCurrentTime: (v: number) => void;
setTimelineReady: (v: boolean) => void;
setIsPlaying: (v: boolean) => void;
attachIframeShortcutListeners: () => void;
}
export function useTimelineSyncCallbacks({
iframeRef,
probeIntervalRef,
pendingSeekRef,
isRefreshingRef,
getAdapter,
syncTimelineElements,
setDuration,
setCurrentTime,
setTimelineReady,
setIsPlaying,
attachIframeShortcutListeners,
}: UseTimelineSyncCallbacksParams) {
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback(
(data: {
clips: ClipManifestClip[];
durationInFrames: number;
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
}) => {
if (!data.clips || data.clips.length === 0) {
return;
}
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
const filtered = data.clips.filter(
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
);
let iframeDoc: Document | null = null;
try {
iframeDoc = iframeRef.current?.contentDocument ?? null;
} catch {
iframeDoc = null;
}
const usedHostEls = new Set<Element>();
const els: TimelineElement[] = filtered.map((clip, index) => {
const hostEl = iframeDoc
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
: null;
if (hostEl) usedHostEls.add(hostEl);
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
doc: iframeDoc,
hostEl,
});
});
const rawDuration = data.durationInFrames / 30;
// Clamp non-finite or absurdly large durations — the runtime can emit
// Infinity when it detects a loop-inflated GSAP timeline without an
// explicit data-duration on the root composition.
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
const clampedEls =
effectiveDuration > 0
? els
.filter((element) => element.start < effectiveDuration)
.map((element) => ({
...element,
duration: Math.min(element.duration, effectiveDuration - element.start),
}))
.filter((element) => element.duration > 0)
: els;
const timelineEls =
iframeDoc && effectiveDuration > 0
? [
...clampedEls,
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
]
: clampedEls;
if (timelineEls.length > 0) {
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
}
},
[iframeRef, syncTimelineElements],
);
const enrichMissingCompositions = useCallback(() => {
try {
const iframe = iframeRef.current;
const doc = iframe?.contentDocument;
const iframeWin = iframe?.contentWindow as IframeWindow | null;
if (!doc || !iframeWin) return;
const currentEls = usePlayerStore.getState().elements;
const rootDuration = usePlayerStore.getState().duration;
const { missing, updatedEls, patched } = buildMissingCompositionElements(
doc,
iframeWin,
currentEls,
rootDuration,
);
if (missing.length > 0 || patched) {
// Dedup: ensure no missing element duplicates an existing one
const finalIds = new Set(updatedEls.map((e) => e.id));
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
syncTimelineElements([...updatedEls, ...dedupedMissing]);
}
} catch (err) {
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
}
}, [iframeRef, syncTimelineElements]);
const initializeAdapter = useCallback(() => {
const adapter = getAdapter();
if (!adapter || adapter.getDuration() <= 0) return false;
adapter.pause();
const seekTo = pendingSeekRef.current;
pendingSeekRef.current = null;
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
adapter.seek(startTime);
const adapterDur = adapter.getDuration();
if (
Number.isFinite(adapterDur) &&
adapterDur > 0 &&
adapterDur < 7200 &&
adapterDur !== usePlayerStore.getState().duration
) {
setDuration(adapterDur);
}
setCurrentTime(startTime);
if (!isRefreshingRef.current) {
setTimelineReady(true);
}
isRefreshingRef.current = false;
setIsPlaying(false);
try {
const iframe = iframeRef.current;
const doc = iframe?.contentDocument;
const iframeWin = iframe?.contentWindow as IframeWindow | null;
if (doc && iframeWin) {
normalizePreviewViewport(doc, iframeWin);
autoHealMissingCompositionIds(doc);
attachIframeShortcutListeners();
}
const manifest = iframeWin?.__clipManifest;
if (manifest && manifest.clips.length > 0) {
processTimelineMessage(manifest);
}
enrichMissingCompositions();
if (usePlayerStore.getState().elements.length === 0 && doc) {
const els = parseTimelineFromDOM(doc, adapter.getDuration());
if (els.length > 0) syncTimelineElements(els);
}
if (usePlayerStore.getState().elements.length === 0 && doc) {
const rootComp = doc.querySelector("[data-composition-id]");
const rootDuration = adapter.getDuration();
if (rootComp && rootDuration > 0) {
const fallbackElement = buildStandaloneRootTimelineElement({
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
tagName: (rootComp as HTMLElement).tagName || "div",
rootDuration,
iframeSrc: iframe?.src || "",
selector: getTimelineElementSelector(rootComp),
});
if (fallbackElement) syncTimelineElements([fallbackElement]);
}
}
} catch (err) {
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
}
return true;
}, [
getAdapter,
setDuration,
setCurrentTime,
setTimelineReady,
setIsPlaying,
processTimelineMessage,
enrichMissingCompositions,
syncTimelineElements,
attachIframeShortcutListeners,
iframeRef,
isRefreshingRef,
pendingSeekRef,
]);
const onIframeLoad = useCallback(() => {
unmutePreviewMedia(iframeRef.current);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Fast path: adapter already available (in-place reloads, cached compositions)
if (initializeAdapter()) return;
// The runtime posts "state" or "timeline" messages once ready.
// Listen for those instead of polling.
const iframe = iframeRef.current;
let settled = false;
const trySettle = () => {
if (settled) return;
if (initializeAdapter()) {
settled = true;
window.removeEventListener("message", onMessage);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
}
};
const onMessage = (e: MessageEvent) => {
if (e.source && iframe && e.source !== iframe.contentWindow) return;
const data = e.data;
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
trySettle();
}
};
window.addEventListener("message", onMessage);
// Safety net: if no message arrives within 5s, try one last time then give up.
probeIntervalRef.current = setTimeout(() => {
if (!settled) {
trySettle();
if (!settled) {
console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
}
}
window.removeEventListener("message", onMessage);
}, 5000) as unknown as ReturnType<typeof setInterval>;
}, [initializeAdapter, iframeRef, probeIntervalRef]);
// Stable refs so mount-effect closures always call the latest version
const processTimelineMessageRef = { current: processTimelineMessage };
const enrichMissingCompositionsRef = { current: enrichMissingCompositions };
return {
processTimelineMessage,
processTimelineMessageRef,
enrichMissingCompositions,
enrichMissingCompositionsRef,
initializeAdapter,
onIframeLoad,
};
}
// Re-export the merge helper so the hook can use it via this module (avoids
// adding another import line to the already-large useTimelinePlayer.ts).
export { mergeTimelineElementsPreservingDowngrades, getTimelineElementIdentity };
@@ -0,0 +1,145 @@
/**
* Playback adapter utilities: factory for the static-seek adapter used when a
* composition exposes only a `renderSeek` / `seek` API (no native play/pause
* support), plus a thin wrapper that normalises GSAP-style `TimelineLike`
* objects to the `PlaybackAdapter` interface.
*/
import type {
PlaybackAdapter,
RuntimePlaybackAdapter,
StaticSeekPlaybackClock,
TimelineLike,
} from "./playbackTypes";
// ---------------------------------------------------------------------------
// Pure numeric helpers
// ---------------------------------------------------------------------------
export function isFinitePositive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
export function clampTime(time: number, duration: number): number {
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
}
export function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
if (!adapter) return 0;
try {
const duration = Number(adapter.getDuration());
return isFinitePositive(duration) ? duration : 0;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// Clock factory
// ---------------------------------------------------------------------------
export function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
return {
now: () => win.performance.now(),
requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
};
}
// ---------------------------------------------------------------------------
// Static-seek adapter
// ---------------------------------------------------------------------------
/**
* Wraps a render-only player (exposes `renderSeek`/`seek` but no native
* play/pause) and drives playback via `requestAnimationFrame`.
*/
export function createStaticSeekPlaybackAdapter(
player: Pick<RuntimePlaybackAdapter, "getTime"> &
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
duration: number,
clock: StaticSeekPlaybackClock,
getPlaybackRate: () => number = () => 1,
): PlaybackAdapter {
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
let playing = false;
let rafId = 0;
let playStartTime = currentTime;
let playStartNow = clock.now();
const renderSeek = (time: number) => {
currentTime = clampTime(time, safeDuration);
if (typeof player.renderSeek === "function") {
player.renderSeek(currentTime);
return;
}
player.seek?.(currentTime);
};
const stopTicker = () => {
if (rafId) {
clock.cancelAnimationFrame(rafId);
rafId = 0;
}
};
const tick: FrameRequestCallback = (now) => {
if (!playing) return;
const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
const elapsed = ((now - playStartNow) / 1000) * playbackRate;
renderSeek(playStartTime + elapsed);
if (currentTime >= safeDuration) {
playing = false;
rafId = 0;
return;
}
rafId = clock.requestAnimationFrame(tick);
};
return {
play: () => {
if (playing || safeDuration <= 0) return;
if (currentTime >= safeDuration) renderSeek(0);
playing = true;
playStartTime = currentTime;
playStartNow = clock.now();
stopTicker();
rafId = clock.requestAnimationFrame(tick);
},
pause: () => {
playing = false;
stopTicker();
},
seek: (time) => {
renderSeek(time);
if (playing) {
playStartTime = currentTime;
playStartNow = clock.now();
}
},
getTime: () => currentTime,
getDuration: () => safeDuration,
isPlaying: () => playing,
};
}
// ---------------------------------------------------------------------------
// GSAP timeline wrapper
// ---------------------------------------------------------------------------
export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
return {
play: () => tl.play(),
pause: () => tl.pause(),
seek: (t) => {
tl.pause();
tl.seek(t);
},
getTime: () => tl.time(),
getDuration: () => tl.duration(),
isPlaying: () => tl.isActive(),
};
}
@@ -0,0 +1,68 @@
/**
* Keyboard shortcut filtering logic for playback controls.
*
* Determines whether a keydown event should be handled as a playback shortcut
* or ignored (e.g. when focus is in an input field, or when caption edit mode
* is active and the user is navigating caption segments).
*/
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
"input",
"textarea",
"select",
"button",
"a[href]",
"[contenteditable='true']",
"[role='button']",
"[role='checkbox']",
"[role='combobox']",
"[role='menuitem']",
"[role='radio']",
"[role='slider']",
"[role='spinbutton']",
"[role='switch']",
"[role='textbox']",
].join(",");
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
if (!target || typeof target !== "object") return false;
const candidate = target as { closest?: unknown };
if (typeof candidate.closest !== "function") return false;
return (
(candidate.closest as (selector: string) => Element | null).call(
target,
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
) !== null
);
}
interface PlaybackShortcutCaptionState {
isCaptionEditMode: boolean;
selectedCaptionSegmentCount: number;
}
type PlaybackShortcutEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
>;
export function shouldIgnorePlaybackShortcutEvent(
event: PlaybackShortcutEvent,
captionState: PlaybackShortcutCaptionState = {
isCaptionEditMode: false,
selectedCaptionSegmentCount: 0,
},
): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return true;
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
return (
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
captionState.isCaptionEditMode &&
captionState.selectedCaptionSegmentCount > 0
);
}
/** JKL shuttle speeds (×1, ×2, ×4). */
export const SHUTTLE_SPEEDS = [1, 2, 4] as const;
@@ -0,0 +1,60 @@
/**
* Shared type definitions for the timeline playback subsystem.
* Kept in a separate module so adapter, DOM, and hook modules can all import
* from here without creating circular dependencies.
*/
export interface PlaybackAdapter {
play: () => void;
pause: () => void;
seek: (time: number) => void;
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
}
export type RuntimePlaybackAdapter = PlaybackAdapter & {
renderSeek?: (time: number) => void;
};
export interface StaticSeekPlaybackClock {
now: () => number;
requestAnimationFrame: (callback: FrameRequestCallback) => number;
cancelAnimationFrame: (handle: number) => void;
}
export interface TimelineLike {
play: () => void;
pause: () => void;
seek: (time: number) => void;
time: () => number;
duration: () => number;
isActive: () => boolean;
}
export interface ClipManifestClip {
id: string | null;
label: string;
start: number;
duration: number;
track: number;
kind: "video" | "audio" | "image" | "element" | "composition";
tagName: string | null;
compositionId: string | null;
parentCompositionId: string | null;
compositionSrc: string | null;
assetUrl: string | null;
}
export interface ClipManifest {
clips: ClipManifestClip[];
scenes: Array<{ id: string; label: string; start: number; duration: number }>;
durationInFrames: number;
}
export type IframeWindow = Window & {
__player?: RuntimePlaybackAdapter;
__timeline?: TimelineLike;
__timelines?: Record<string, TimelineLike>;
__clipManifest?: ClipManifest;
};
@@ -0,0 +1,373 @@
/**
* Higher-level timeline DOM operations: element factories, DOM-to-element
* parsing, timeline merging, and standalone composition helpers.
*
* Preview iframe utilities (normaliseViewport, autoHeal, unmute, resolveIframe,
* buildMissingCompositionElements) live in timelineIframeHelpers.ts.
*
* Pure functions (no React, no store reads) testable in isolation.
*/
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
getTimelineElementDisplayLabel,
getImplicitTimelineLayerLabel,
isImplicitTimelineLayerCandidate,
getTimelineElementSelector,
getTimelineElementSourceFile,
getTimelineElementSelectorIndex,
buildTimelineElementKey,
buildTimelineElementIdentity,
getTimelineElementIdentity,
} from "./timelineElementHelpers";
// Re-export helpers that were previously public from this module so that
// existing import sites (hook + tests) don't need to change.
export {
readTimelineDurationFromDocument,
resolveMediaElement,
applyMediaMetadataFromElement,
getTimelineElementSelector,
getTimelineElementSourceFile,
getTimelineElementSelectorIndex,
buildTimelineElementIdentity,
getTimelineElementIdentity,
findTimelineDomNodeForClip,
} from "./timelineElementHelpers";
// Re-export iframe helpers so the hook can keep a single import source.
export {
normalizePreviewViewport,
autoHealMissingCompositionIds,
unmutePreviewMedia,
resolveIframe,
buildMissingCompositionElements,
} from "./timelineIframeHelpers";
// ---------------------------------------------------------------------------
// TimelineElement factories
// ---------------------------------------------------------------------------
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
fallbackIndex: number;
doc?: Document | null;
hostEl?: Element | null;
}): TimelineElement {
const { clip, fallbackIndex, doc } = params;
let hostEl = params.hostEl ?? null;
const label = getTimelineElementDisplayLabel({
id: clip.id,
label: clip.label,
tag: clip.tagName || clip.kind,
});
let domId: string | undefined;
let selector: string | undefined;
let selectorIndex: number | undefined;
let sourceFile: string | undefined;
if (hostEl) {
domId = hostEl.id || undefined;
selector = getTimelineElementSelector(hostEl);
selectorIndex =
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
sourceFile = getTimelineElementSourceFile(hostEl);
}
const identity = buildTimelineElementIdentity({
preferredId: clip.id,
label,
fallbackIndex,
domId,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: clip.tagName || clip.kind,
start: clip.start,
duration: clip.duration,
track: clip.track,
domId,
selector,
selectorIndex,
sourceFile,
};
if (hostEl) {
applyMediaMetadataFromElement(entry, hostEl);
}
if (clip.assetUrl) entry.src = clip.assetUrl;
if (clip.kind === "composition" && clip.compositionId) {
let resolvedSrc = clip.compositionSrc;
if (!resolvedSrc) {
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
resolvedSrc =
hostEl?.getAttribute("data-composition-src") ??
hostEl?.getAttribute("data-composition-file") ??
null;
}
if (resolvedSrc) {
entry.compositionSrc = resolvedSrc;
} else if (hostEl) {
const innerVideo = hostEl.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
if (hostEl) {
entry.domId = hostEl.id || undefined;
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
doc && entry.selector
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
: undefined;
entry.sourceFile = getTimelineElementSourceFile(hostEl);
const nextIdentity = buildTimelineElementIdentity({
preferredId: clip.id,
label,
fallbackIndex,
domId: entry.domId,
selector: entry.selector,
selectorIndex: entry.selectorIndex,
sourceFile: entry.sourceFile,
});
entry.id = nextIdentity.id;
entry.key = nextIdentity.key;
}
}
return entry;
}
export function createImplicitTimelineLayersFromDOM(
doc: Document,
rootDuration: number,
existingElements: readonly TimelineElement[] = [],
): TimelineElement[] {
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
const rootComp = doc.querySelector("[data-composition-id]");
if (!rootComp) return [];
const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
const maxTrack = existingElements.reduce(
(max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
-1,
);
const layers: TimelineElement[] = [];
for (const child of Array.from(rootComp.children)) {
if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
const selector = getTimelineElementSelector(child);
if (!selector) continue;
const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
const sourceFile = getTimelineElementSourceFile(child);
const label = getImplicitTimelineLayerLabel(child);
const identity = buildTimelineElementIdentity({
preferredId: child.id || null,
label,
fallbackIndex: existingElements.length + layers.length,
domId: child.id || undefined,
selector,
selectorIndex,
sourceFile,
});
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
layers.push({
domId: child.id || undefined,
duration: rootDuration,
id: identity.id,
key: identity.key,
label,
selector,
selectorIndex,
sourceFile,
start: 0,
tag: child.tagName.toLowerCase(),
timingSource: "implicit",
track: maxTrack + 1 + layers.length,
});
}
return layers;
}
/**
* Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
*/
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
const rootComp = doc.querySelector("[data-composition-id]");
const nodes = doc.querySelectorAll("[data-start]");
const els: TimelineElement[] = [];
let trackCounter = 0;
nodes.forEach((node) => {
if (node === rootComp) return;
const el = node as HTMLElement;
const startStr = el.getAttribute("data-start");
if (startStr == null) return;
const start = parseFloat(startStr);
if (isNaN(start)) return;
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
const tagLower = el.tagName.toLowerCase();
let dur = 0;
const durStr = el.getAttribute("data-duration");
if (durStr != null) dur = parseFloat(durStr);
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
if (Number.isFinite(rootDuration) && rootDuration > 0) {
dur = Math.min(dur, Math.max(0, rootDuration - start));
}
if (!Number.isFinite(dur) || dur <= 0) return;
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
const compId = el.getAttribute("data-composition-id");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const label = getTimelineElementDisplayLabel({
id: el.id || compId || null,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: tagLower,
});
const identity = buildTimelineElementIdentity({
preferredId: el.id || compId || null,
label,
fallbackIndex: els.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: tagLower,
start,
duration: dur,
track: isNaN(track) ? 0 : track,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
timingSource: "authored",
};
const mediaEl = resolveMediaElement(el);
if (mediaEl) {
if (mediaEl.tagName === "IMG") {
entry.tag = "img";
}
const src = mediaEl.getAttribute("src");
if (src) entry.src = src;
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
if (vol) entry.volume = parseFloat(vol);
applyMediaMetadataFromElement(entry, el);
}
// Sub-compositions
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
if (compSrc) {
entry.compositionSrc = compSrc;
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
// Inline composition — expose inner video for thumbnails
const innerVideo = el.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
els.push(entry);
});
return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
}
// ---------------------------------------------------------------------------
// Merge helpers
// ---------------------------------------------------------------------------
export function mergeTimelineElementsPreservingDowngrades(
currentElements: TimelineElement[],
nextElements: TimelineElement[],
currentDuration: number,
nextDuration: number,
): TimelineElement[] {
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
if (
currentElements.length === 0 ||
nextElements.length >= currentElements.length ||
safeNextDuration > safeCurrentDuration
) {
return nextElements;
}
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
const preserved = currentElements.filter(
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
);
if (preserved.length === 0) return nextElements;
return [...nextElements, ...preserved];
}
// ---------------------------------------------------------------------------
// Standalone composition helpers
// ---------------------------------------------------------------------------
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
}
export function buildStandaloneRootTimelineElement(params: {
compositionId: string;
tagName: string;
rootDuration: number;
iframeSrc: string;
selector?: string;
selectorIndex?: number;
}): TimelineElement | null {
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
return {
id: params.compositionId,
label: getTimelineElementDisplayLabel({
id: params.compositionId,
tag: params.tagName,
}),
key: buildTimelineElementKey({
id: params.compositionId,
fallbackIndex: 0,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: compositionSrc,
}),
tag: params.tagName.toLowerCase() || "div",
start: 0,
duration: params.rootDuration,
track: 0,
compositionSrc,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: compositionSrc,
};
}
@@ -0,0 +1,303 @@
/**
* Low-level helpers for building and identifying TimelineElement objects.
*
* Covers: duration reading, media-element metadata extraction, selector/key/
* identity builders, DOM node lookup, and implicit layer detection. These are
* intentionally dependency-free (no store, no hooks) so they can be used in
* both the React hook and test environments.
*/
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { isFinitePositive } from "./playbackAdapter";
// ---------------------------------------------------------------------------
// Duration attribute helpers
// ---------------------------------------------------------------------------
export function readDurationAttribute(el: Element | null | undefined): number {
if (!el) return 0;
const duration =
Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
return isFinitePositive(duration) ? duration : 0;
}
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
if (!doc) return 0;
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
if (rootDuration > 0) return rootDuration;
let maxEnd = 0;
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
const duration = readDurationAttribute(node);
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
maxEnd = Math.max(maxEnd, start + duration);
}
return maxEnd;
}
// ---------------------------------------------------------------------------
// DOM element type guards
// ---------------------------------------------------------------------------
export function isHtmlElement(el: Element): el is HTMLElement {
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
}
export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
const win = el.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
const candidate = el.querySelector("video, audio, img");
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
? candidate
: null;
}
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
const mediaStartAttr = el.getAttribute("data-playback-start")
? "playback-start"
: el.getAttribute("data-media-start")
? "media-start"
: undefined;
const mediaStartValue =
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
if (mediaStartValue != null) {
const playbackStart = parseFloat(mediaStartValue);
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
}
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
const mediaEl = resolveMediaElement(el);
if (!mediaEl) return;
entry.tag = mediaEl.tagName.toLowerCase();
const src = mediaEl.getAttribute("src");
if (src) entry.src = src;
const win = mediaEl.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
const sourceDurationAttr =
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
entry.sourceDuration = sourceDuration;
}
const playbackRate = mediaEl.defaultPlaybackRate;
if (Number.isFinite(playbackRate) && playbackRate > 0) {
entry.playbackRate = playbackRate;
}
}
// ---------------------------------------------------------------------------
// Label helpers
// ---------------------------------------------------------------------------
export function getTimelineElementDisplayLabel(input: {
id?: string | null;
label?: string | null;
tag?: string | null;
}): string {
const label = input.label?.trim();
if (label) return label;
const id = input.id?.trim();
if (id) return id;
const tag = input.tag?.trim().toLowerCase();
return tag ? `${tag} clip` : "Timeline clip";
}
export const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
"base",
"link",
"meta",
"noscript",
"script",
"style",
"template",
]);
export function humanizeTimelineIdentifier(value: string): string {
return value
.trim()
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase());
}
export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
const explicitLabel =
el.getAttribute("data-timeline-label") ??
el.getAttribute("data-label") ??
el.getAttribute("aria-label");
if (explicitLabel?.trim()) return explicitLabel.trim();
if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
const classes = el.className.split(/\s+/).filter(Boolean);
const className = classes.find((value) => value !== "clip") ?? classes[0];
if (className) return humanizeTimelineIdentifier(className);
return getTimelineElementDisplayLabel({ tag: el.tagName });
}
// ---------------------------------------------------------------------------
// Selector / identity / key builders
// ---------------------------------------------------------------------------
export function getTimelineElementSelector(el: Element): string | undefined {
if (isHtmlElement(el) && el.id) return `#${el.id}`;
const compId = el.getAttribute("data-composition-id");
if (compId) return `[data-composition-id="${compId}"]`;
if (isHtmlElement(el)) {
const classes = el.className.split(/\s+/).filter(Boolean);
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
if (firstClass) return `.${firstClass}`;
}
return undefined;
}
export function getTimelineElementSourceFile(el: Element): string | undefined {
const ownerRoot = el.parentElement?.closest("[data-composition-id]");
return (
ownerRoot?.getAttribute("data-composition-file") ??
ownerRoot?.getAttribute("data-composition-src") ??
undefined
);
}
export function getTimelineElementSelectorIndex(
doc: Document,
el: Element,
selector: string | undefined,
): number | undefined {
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
return undefined;
}
try {
const matches = Array.from(doc.querySelectorAll(selector));
const matchIndex = matches.indexOf(el);
return matchIndex >= 0 ? matchIndex : undefined;
} catch {
return undefined;
}
}
export function buildTimelineElementKey(params: {
id: string;
fallbackIndex: number;
domId?: string;
selector?: string;
selectorIndex?: number;
sourceFile?: string;
}): string {
const scope = params.sourceFile ?? "index.html";
if (params.domId) return `${scope}#${params.domId}`;
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
return `${scope}:${params.id}:${params.fallbackIndex}`;
}
export function buildTimelineElementIdentity(params: {
preferredId?: string | null;
label: string;
fallbackIndex: number;
domId?: string;
selector?: string;
selectorIndex?: number;
sourceFile?: string;
}): { id: string; key: string } {
const id =
params.preferredId?.trim() ||
buildTimelineElementKey({
id: params.label,
fallbackIndex: params.fallbackIndex,
domId: params.domId,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: params.sourceFile,
});
const key = buildTimelineElementKey({
id,
fallbackIndex: params.fallbackIndex,
domId: params.domId,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: params.sourceFile,
});
return { id, key };
}
export function getTimelineElementIdentity(element: TimelineElement): string {
return element.key ?? element.id;
}
// ---------------------------------------------------------------------------
// DOM node querying
// ---------------------------------------------------------------------------
export function getTimelineDomNodes(doc: Document): Element[] {
const rootComp = doc.querySelector("[data-composition-id]");
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
}
function numbersNearlyEqual(a: number, b: number): boolean {
return Math.abs(a - b) < 0.001;
}
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
const tagName = clip.tagName?.toLowerCase();
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
if (Number.isFinite(track) && track !== clip.track) return false;
return true;
}
function findTimelineDomNode(doc: Document, id: string): Element | null {
return (
doc.getElementById(id) ??
doc.querySelector(`[data-composition-id="${id}"]`) ??
doc.querySelector(`.${id}`) ??
null
);
}
export function findTimelineDomNodeForClip(
doc: Document,
clip: ClipManifestClip,
fallbackIndex: number,
usedNodes = new Set<Element>(),
): Element | null {
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
if (exact) return exact;
return candidates[fallbackIndex] ?? null;
}
// ---------------------------------------------------------------------------
// Implicit layer detection
// ---------------------------------------------------------------------------
export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
if (!isHtmlElement(el)) return false;
if (el.parentElement !== root) return false;
const tagName = el.tagName.toLowerCase();
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
return Boolean(getTimelineElementSelector(el));
}
@@ -0,0 +1,269 @@
/**
* Runtime iframe integration utilities.
*
* Handles the boundary between the studio host page and the preview iframe:
* - Viewport normalisation on load
* - Auto-healing missing data-composition-id attributes
* - Unmuting media via postMessage
* - Resolving the underlying <iframe> from any wrapper element
* - Scanning the DOM for composition hosts the manifest missed
* (element-reference starts that the CDN runtime fails to resolve)
*/
import type { TimelineElement } from "../store/playerStore";
import type { IframeWindow } from "./playbackTypes";
import {
getTimelineElementSelector,
getTimelineElementSourceFile,
getTimelineElementSelectorIndex,
getTimelineElementDisplayLabel,
buildTimelineElementIdentity,
} from "./timelineElementHelpers";
// ---------------------------------------------------------------------------
// Viewport / DOM normalisation
// ---------------------------------------------------------------------------
export function normalizePreviewViewport(doc: Document, win: Window): void {
if (doc.documentElement) {
doc.documentElement.style.overflow = "hidden";
doc.documentElement.style.margin = "0";
}
if (doc.body) {
doc.body.style.overflow = "hidden";
doc.body.style.margin = "0";
}
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
export function autoHealMissingCompositionIds(doc: Document): void {
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
const referencedIds = new Set<string>();
const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
for (const node of scopedNodes) {
const text = node.textContent || "";
if (!text) continue;
let match: RegExpExecArray | null;
while ((match = compositionIdRe.exec(text)) !== null) {
const id = (match[1] || "").trim();
if (id) referencedIds.add(id);
}
}
if (referencedIds.size === 0) return;
const existingIds = new Set<string>();
const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
for (const node of existingNodes) {
const id = node.getAttribute("data-composition-id");
if (id) existingIds.add(id);
}
for (const compId of referencedIds) {
if (compId === "root" || existingIds.has(compId)) continue;
const host =
doc.getElementById(`${compId}-layer`) ||
doc.getElementById(`${compId}-comp`) ||
doc.getElementById(compId);
if (!host) continue;
if (!host.getAttribute("data-composition-id")) {
host.setAttribute("data-composition-id", compId);
}
}
}
// ---------------------------------------------------------------------------
// Muting / iframe resolution
// ---------------------------------------------------------------------------
export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
if (!iframe) return;
try {
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
"*",
);
} catch (err) {
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
}
}
/**
* Resolve the underlying iframe from any host element. Supports:
* - Direct `<iframe>` element (most common studio's own `Player.tsx`)
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
* - Wrapper elements whose light DOM contains a descendant iframe
*
* Exported so web-component consumers can pre-resolve the iframe before
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
* when the element has no associated iframe yet.
*
* @example
* ```tsx
* const { iframeRef } = useTimelinePlayer();
* const playerElRef = useRef<HyperframesPlayer>(null);
*
* useEffect(() => {
* iframeRef.current = resolveIframe(playerElRef.current);
* }, [iframeRef]);
* ```
*/
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
if (!el) return null;
if (el instanceof HTMLIFrameElement) return el;
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
}
// ---------------------------------------------------------------------------
// Enrich missing compositions from DOM
// ---------------------------------------------------------------------------
/**
* Scan the iframe DOM for composition hosts missing from the current
* timeline elements and add them. The CDN runtime often fails to resolve
* element-reference starts (`data-start="intro"`) so composition hosts
* are silently dropped from `__clipManifest`. This pass reads the DOM +
* GSAP timeline registry directly to fill the gaps.
*/
export function buildMissingCompositionElements(
doc: Document,
iframeWin: IframeWindow,
currentEls: readonly TimelineElement[],
rootDuration: number,
): { missing: TimelineElement[]; updatedEls: TimelineElement[]; patched: boolean } {
const existingIds = new Set(currentEls.map((e) => e.id));
const rootComp = doc.querySelector("[data-composition-id]");
const rootCompId = rootComp?.getAttribute("data-composition-id");
// Use [data-composition-id][data-start] — the composition loader strips
// data-composition-src after loading, so we can't rely on it.
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
const missing: TimelineElement[] = [];
hosts.forEach((host) => {
const el = host as HTMLElement;
const compId = el.getAttribute("data-composition-id");
if (!compId || compId === rootCompId) return;
if (existingIds.has(el.id) || existingIds.has(compId)) return;
// Resolve start: numeric or element-reference
const startAttr = el.getAttribute("data-start") ?? "0";
let start = parseFloat(startAttr);
if (isNaN(start)) {
const ref =
doc.getElementById(startAttr) || doc.querySelector(`[data-composition-id="${startAttr}"]`);
if (ref) {
const refStartAttr = ref.getAttribute("data-start") ?? "0";
let refStart = parseFloat(refStartAttr);
// Recursively resolve one level of reference for the ref's own start
if (isNaN(refStart)) {
const refRef =
doc.getElementById(refStartAttr) ||
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
const rrCompId = refRef?.getAttribute("data-composition-id");
const rrDur =
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
(rrCompId
? ((
iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0)
: 0);
refStart = rrStart + rrDur;
}
const refCompId = ref.getAttribute("data-composition-id");
const refDur =
parseFloat(ref.getAttribute("data-duration") ?? "") ||
(refCompId
? ((
iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0)
: 0);
start = refStart + refDur;
} else {
start = 0;
}
}
// Resolve duration from data-duration or GSAP timeline
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
if (isNaN(dur) || dur <= 0) {
dur =
(
iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0;
}
if (!Number.isFinite(dur) || dur <= 0) return;
if (!Number.isFinite(start)) start = 0;
if (Number.isFinite(rootDuration) && rootDuration > 0) {
if (start >= rootDuration) return;
dur = Math.min(dur, Math.max(0, rootDuration - start));
if (dur <= 0) return;
}
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const label = getTimelineElementDisplayLabel({
id: el.id || compId || null,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: el.tagName,
});
const identity = buildTimelineElementIdentity({
preferredId: el.id || compId || null,
label,
fallbackIndex: missing.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: el.tagName.toLowerCase(),
start,
duration: dur,
track: isNaN(track) ? 0 : track,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
};
if (compSrc) {
entry.compositionSrc = compSrc;
} else {
// Inline composition — expose inner video for thumbnails
const innerVideo = el.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
missing.push(entry);
});
// Patch existing elements that are missing compositionSrc
let patched = false;
const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
if (existing.compositionSrc) return existing;
// Find the matching DOM host by element id or composition id
const host =
doc.getElementById(existing.id) ??
doc.querySelector(`[data-composition-id="${existing.id}"]`);
if (!host) return existing;
const compSrc =
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
if (compSrc) {
patched = true;
return { ...existing, compositionSrc: compSrc };
}
return existing;
});
return { missing, updatedEls, patched };
}