mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix: harden studio timeline editing and local renders (#463)
* fix: harden studio timeline editing and local renders * test: cover studio local render fallback * fix(studio): scale composition hover previews to stage size * test: normalize studio producer fallback paths * fix(studio): preserve move surface and retry render fallback
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateTicks,
|
||||
getTimelineCanvasHeight,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
shouldAutoScrollTimeline,
|
||||
@@ -151,3 +152,13 @@ describe("getTimelinePlayheadLeft", () => {
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("includes bottom scroll buffer below the last track", () => {
|
||||
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72);
|
||||
});
|
||||
|
||||
it("still keeps ruler space when there are no tracks", () => {
|
||||
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,14 @@ import { formatTime } from "../lib/time";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { EditPopover } from "./EditModal";
|
||||
import {
|
||||
buildClipRangeSelection,
|
||||
getTimelineEditCapabilities,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
type BlockedTimelineEditIntent,
|
||||
type TimelineRangeSelection,
|
||||
} from "./timelineEditing";
|
||||
import {
|
||||
defaultTimelineTheme,
|
||||
@@ -29,6 +33,8 @@ const GUTTER = 32;
|
||||
const TRACK_H = 72;
|
||||
const RULER_H = 24;
|
||||
const CLIP_Y = 3; // vertical inset inside track
|
||||
const CLIP_HANDLE_W = 18;
|
||||
const TIMELINE_SCROLL_BUFFER = 24;
|
||||
|
||||
interface TrackVisualStyle extends TimelineTrackStyle {
|
||||
icon: ReactNode;
|
||||
@@ -130,6 +136,10 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
@@ -157,6 +167,10 @@ interface TimelineProps {
|
||||
"start" | "duration" | "playbackStart"
|
||||
>,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (
|
||||
element: import("../store/playerStore").TimelineElement,
|
||||
intent: BlockedTimelineEditIntent,
|
||||
) => void;
|
||||
theme?: Partial<TimelineTheme>;
|
||||
}
|
||||
|
||||
@@ -185,6 +199,14 @@ interface ResizingClipState {
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
interface BlockedClipState {
|
||||
element: TimelineElement;
|
||||
intent: BlockedTimelineEditIntent;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
onDrillDown,
|
||||
@@ -193,6 +215,7 @@ export const Timeline = memo(function Timeline({
|
||||
onFileDrop,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
theme: themeOverrides,
|
||||
}: TimelineProps = {}) {
|
||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||
@@ -210,6 +233,11 @@ export const Timeline = memo(function Timeline({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const shiftClickClipRef = useRef<{
|
||||
element: TimelineElement;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null>(null);
|
||||
// Range selection (Shift+drag)
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
useMountEffect(() => {
|
||||
@@ -227,18 +255,14 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
const [rangeSelection, setRangeSelection] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null>(null);
|
||||
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
|
||||
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 onMoveElementRef = useRef(onMoveElement);
|
||||
onMoveElementRef.current = onMoveElement;
|
||||
const onResizeElementRef = useRef(onResizeElement);
|
||||
@@ -546,6 +570,7 @@ export const Timeline = memo(function Timeline({
|
||||
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;
|
||||
@@ -561,6 +586,8 @@ export const Timeline = memo(function Timeline({
|
||||
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,
|
||||
@@ -569,7 +596,10 @@ export const Timeline = memo(function Timeline({
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
minStart: 0,
|
||||
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
||||
playbackStart: resize.element.playbackStart,
|
||||
playbackStart:
|
||||
resize.edge === "start" && canSeedPlaybackStart
|
||||
? (resize.element.playbackStart ?? 0)
|
||||
: resize.element.playbackStart,
|
||||
playbackRate: resize.element.playbackRate,
|
||||
},
|
||||
resize.edge,
|
||||
@@ -589,6 +619,23 @@ export const Timeline = memo(function Timeline({
|
||||
);
|
||||
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);
|
||||
setRangeSelection(null);
|
||||
onBlockedEditAttempt?.(blocked.element, blocked.intent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
|
||||
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
||||
@@ -644,6 +691,14 @@ export const Timeline = memo(function Timeline({
|
||||
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;
|
||||
@@ -707,6 +762,7 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
|
||||
shiftClickClipRef.current = null;
|
||||
// Normal click on a clip — let the clip handle it
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
@@ -740,8 +796,14 @@ export const Timeline = memo(function Timeline({
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (isRangeSelecting.current) {
|
||||
isRangeSelecting.current = false;
|
||||
// Show popover if range is meaningful (> 0.2s)
|
||||
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);
|
||||
}
|
||||
// Show popover if range is meaningful (> 0.2s)
|
||||
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
||||
setShowPopover(true);
|
||||
return prev;
|
||||
@@ -869,7 +931,7 @@ export const Timeline = memo(function Timeline({
|
||||
);
|
||||
}
|
||||
|
||||
const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
|
||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
@@ -990,7 +1052,7 @@ export const Timeline = memo(function Timeline({
|
||||
{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 to select range
|
||||
Drag or click a clip to edit range
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1108,6 +1170,7 @@ export const Timeline = memo(function Timeline({
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setResizingClip({
|
||||
@@ -1121,16 +1184,41 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (
|
||||
e.button !== 0 ||
|
||||
e.shiftKey ||
|
||||
!onMoveElement ||
|
||||
!capabilities.canMove
|
||||
)
|
||||
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);
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setDraggedClip({
|
||||
element: el,
|
||||
originClientX: e.clientX,
|
||||
@@ -1270,7 +1358,7 @@ export const Timeline = memo(function Timeline({
|
||||
Shift
|
||||
</kbd>
|
||||
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
||||
+ drag to edit range
|
||||
+ drag/click to edit range
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 18,
|
||||
opacity: showHandles ? 1 : 0,
|
||||
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
|
||||
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
|
||||
zIndex: 4,
|
||||
transition: "opacity 120ms ease-out",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClipRangeSelection,
|
||||
buildPromptCopyText,
|
||||
buildTimelineElementAgentPrompt,
|
||||
buildTimelineAgentPrompt,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
canOffsetTrimClipStart,
|
||||
getTimelineEditCapabilities,
|
||||
hasPatchableTimelineTarget,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
@@ -199,6 +201,14 @@ describe("canOffsetTrimClipStart", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows front trim for plain audio clips even before media-start exists", () => {
|
||||
expect(
|
||||
canOffsetTrimClipStart({
|
||||
tag: "audio",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks front trim for generic motion clips", () => {
|
||||
expect(
|
||||
canOffsetTrimClipStart({
|
||||
@@ -223,6 +233,21 @@ describe("hasPatchableTimelineTarget", () => {
|
||||
});
|
||||
|
||||
describe("getTimelineEditCapabilities", () => {
|
||||
it("does not disable editable audio just because it spans multiple scenes", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "audio",
|
||||
duration: 8,
|
||||
selector: "#voiceover",
|
||||
sourceDuration: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables move and trims for generic motion clips even when patchable", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
@@ -299,6 +324,111 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlockedTimelineEditIntent", () => {
|
||||
it("returns move when the clip body is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 80,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe("move");
|
||||
});
|
||||
|
||||
it("returns resize-start when the left edge is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe("resize-start");
|
||||
});
|
||||
|
||||
it("returns resize-end when the right edge is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 154,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe("resize-end");
|
||||
});
|
||||
|
||||
it("does not block the left edge when the clip can still be moved", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("does not swallow the full surface of a narrow movable clip", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 12,
|
||||
offsetX: 6,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when the relevant edit is supported", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClipRangeSelection", () => {
|
||||
it("anchors the full clip range at the click position", () => {
|
||||
expect(
|
||||
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
|
||||
).toEqual({
|
||||
start: 1.25,
|
||||
end: 4.75,
|
||||
anchorX: 320,
|
||||
anchorY: 180,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAutoScroll", () => {
|
||||
it("does not scroll when the pointer stays away from the edges", () => {
|
||||
expect(
|
||||
@@ -420,6 +550,25 @@ describe("resolveTimelineResize", () => {
|
||||
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
|
||||
});
|
||||
|
||||
it("can seed front trim from an implicit zero playback start", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 0,
|
||||
duration: 8,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 8,
|
||||
playbackStart: 0,
|
||||
playbackRate: 1,
|
||||
},
|
||||
"start",
|
||||
200,
|
||||
),
|
||||
).toEqual({ start: 1, duration: 7, playbackStart: 1 });
|
||||
});
|
||||
|
||||
it("prevents extending media left past available source before media-start", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
|
||||
@@ -175,6 +175,15 @@ export interface TimelineEditCapabilities {
|
||||
canTrimEnd: boolean;
|
||||
}
|
||||
|
||||
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
|
||||
|
||||
export interface TimelineRangeSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
function isDeterministicTimelineWindow(input: {
|
||||
tag: string;
|
||||
compositionSrc?: string;
|
||||
@@ -207,12 +216,7 @@ export function canOffsetTrimClipStart(input: {
|
||||
if (input.playbackStartAttr != null) return true;
|
||||
if (input.playbackStart != null) return true;
|
||||
const normalizedTag = input.tag.toLowerCase();
|
||||
if (!["video", "audio"].includes(normalizedTag)) return false;
|
||||
return (
|
||||
input.sourceDuration != null &&
|
||||
Number.isFinite(input.sourceDuration) &&
|
||||
input.sourceDuration > 0
|
||||
);
|
||||
return ["video", "audio"].includes(normalizedTag);
|
||||
}
|
||||
|
||||
export function getTimelineEditCapabilities(input: {
|
||||
@@ -235,6 +239,41 @@ export function getTimelineEditCapabilities(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBlockedTimelineEditIntent(input: {
|
||||
width: number;
|
||||
offsetX: number;
|
||||
handleWidth: number;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
}): BlockedTimelineEditIntent | null {
|
||||
if (input.capabilities.canMove) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(0, input.width);
|
||||
const safeOffsetX = clamp(input.offsetX, 0, safeWidth);
|
||||
const safeHandleWidth = Math.max(0, input.handleWidth);
|
||||
|
||||
if (safeOffsetX <= safeHandleWidth && !input.capabilities.canTrimStart) {
|
||||
return "resize-start";
|
||||
}
|
||||
if (safeOffsetX >= Math.max(0, safeWidth - safeHandleWidth) && !input.capabilities.canTrimEnd) {
|
||||
return "resize-end";
|
||||
}
|
||||
return "move";
|
||||
}
|
||||
|
||||
export function buildClipRangeSelection(
|
||||
clip: { start: number; duration: number },
|
||||
anchor: { anchorX: number; anchorY: number },
|
||||
): TimelineRangeSelection {
|
||||
return {
|
||||
start: clip.start,
|
||||
end: clip.start + clip.duration,
|
||||
anchorX: anchor.anchorX,
|
||||
anchorY: anchor.anchorY,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineAgentPrompt({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
|
||||
@@ -559,7 +559,11 @@ export function useTimelinePlayer() {
|
||||
|
||||
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
||||
const processTimelineMessage = useCallback(
|
||||
(data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
|
||||
(data: {
|
||||
clips: ClipManifestClip[];
|
||||
durationInFrames: number;
|
||||
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
||||
}) => {
|
||||
if (!data.clips || data.clips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user