mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user