@hyperframes/studio 0.4.38 → 0.5.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +1431 -196
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +445 -0
- package/src/components/editor/PropertyPanel.tsx +2466 -206
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +537 -0
- package/src/components/editor/domEditing.ts +762 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/nle/NLELayout.tsx +17 -47
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +34 -55
- package/src/icons/SystemIcons.tsx +0 -2
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -70
- package/src/player/components/PlayerControls.tsx +44 -3
- package/src/player/components/Timeline.test.ts +12 -0
- package/src/player/components/Timeline.tsx +51 -20
- package/src/player/components/TimelineClip.tsx +20 -7
- package/src/player/components/timelineEditing.test.ts +2 -4
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
- package/src/player/hooks/useTimelinePlayer.ts +74 -32
- package/src/player/lib/time.test.ts +1 -11
- package/src/player/lib/time.ts +0 -6
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/dist/assets/index-18P_dZeo.js +0 -93
- package/dist/assets/index-BLrgRQSu.css +0 -1
- package/src/utils/frameCapture.test.ts +0 -26
- package/src/utils/frameCapture.ts +0 -38
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
|
+
getTimelineToggleTitle,
|
|
6
|
+
} from "../../utils/timelineDiscovery";
|
|
7
|
+
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
|
4
8
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
5
9
|
|
|
6
10
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
@@ -26,11 +30,15 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|
|
26
30
|
interface PlayerControlsProps {
|
|
27
31
|
onTogglePlay: () => void;
|
|
28
32
|
onSeek: (time: number) => void;
|
|
33
|
+
timelineVisible?: boolean;
|
|
34
|
+
onToggleTimeline?: () => void;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export const PlayerControls = memo(function PlayerControls({
|
|
32
38
|
onTogglePlay,
|
|
33
39
|
onSeek,
|
|
40
|
+
timelineVisible,
|
|
41
|
+
onToggleTimeline,
|
|
34
42
|
}: PlayerControlsProps) {
|
|
35
43
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
36
44
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -208,10 +216,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
208
216
|
const step = e.shiftKey ? 10 : 1;
|
|
209
217
|
if (e.key === "ArrowLeft") {
|
|
210
218
|
e.preventDefault();
|
|
211
|
-
onSeek(
|
|
219
|
+
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
|
|
212
220
|
} else if (e.key === "ArrowRight") {
|
|
213
221
|
e.preventDefault();
|
|
214
|
-
onSeek(Math.min(duration,
|
|
222
|
+
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
|
|
215
223
|
}
|
|
216
224
|
},
|
|
217
225
|
[timelineReady, duration, onSeek],
|
|
@@ -429,6 +437,39 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
429
437
|
</span>
|
|
430
438
|
))}
|
|
431
439
|
</div>
|
|
440
|
+
|
|
441
|
+
{/* Timeline toggle */}
|
|
442
|
+
{onToggleTimeline !== undefined && (
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
onClick={onToggleTimeline}
|
|
446
|
+
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
|
447
|
+
timelineVisible
|
|
448
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
449
|
+
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
450
|
+
}`}
|
|
451
|
+
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
|
452
|
+
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
453
|
+
>
|
|
454
|
+
<svg
|
|
455
|
+
width="13"
|
|
456
|
+
height="13"
|
|
457
|
+
viewBox="0 0 24 24"
|
|
458
|
+
fill="none"
|
|
459
|
+
stroke="currentColor"
|
|
460
|
+
strokeWidth="2"
|
|
461
|
+
strokeLinecap="round"
|
|
462
|
+
>
|
|
463
|
+
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
464
|
+
<line x1="3" y1="9" x2="21" y2="9" />
|
|
465
|
+
<line x1="3" y1="5" x2="21" y2="5" />
|
|
466
|
+
</svg>
|
|
467
|
+
<span>Timeline</span>
|
|
468
|
+
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
469
|
+
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
470
|
+
</span>
|
|
471
|
+
</button>
|
|
472
|
+
)}
|
|
432
473
|
</div>
|
|
433
474
|
);
|
|
434
475
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getTimelinePlayheadLeft,
|
|
9
9
|
getTimelineScrollLeftForZoomAnchor,
|
|
10
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
+
shouldShowTimelineShortcutHint,
|
|
11
12
|
shouldHandleTimelineDeleteKey,
|
|
12
13
|
shouldAutoScrollTimeline,
|
|
13
14
|
} from "./Timeline";
|
|
@@ -237,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
237
238
|
});
|
|
238
239
|
});
|
|
239
240
|
|
|
241
|
+
describe("shouldShowTimelineShortcutHint", () => {
|
|
242
|
+
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
243
|
+
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
244
|
+
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
248
|
+
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
240
252
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
241
253
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
242
254
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
@@ -35,7 +35,7 @@ const TRACK_H = 72;
|
|
|
35
35
|
const RULER_H = 24;
|
|
36
36
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
37
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
38
|
+
const TIMELINE_SCROLL_BUFFER = 20;
|
|
39
39
|
|
|
40
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
41
|
icon: ReactNode;
|
|
@@ -216,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
216
216
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
export function shouldShowTimelineShortcutHint(
|
|
220
|
+
scrollHeight: number,
|
|
221
|
+
clientHeight: number,
|
|
222
|
+
): boolean {
|
|
223
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
224
|
+
return scrollHeight - clientHeight <= 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
219
227
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
220
228
|
key: string;
|
|
221
229
|
metaKey?: boolean;
|
|
@@ -279,7 +287,6 @@ export function resolveTimelineAssetDrop(
|
|
|
279
287
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
280
288
|
};
|
|
281
289
|
}
|
|
282
|
-
|
|
283
290
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
284
291
|
interface TimelineProps {
|
|
285
292
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -427,30 +434,51 @@ export const Timeline = memo(function Timeline({
|
|
|
427
434
|
onDeleteElementRef.current = onDeleteElement;
|
|
428
435
|
const suppressClickRef = useRef(false);
|
|
429
436
|
const [showPopover, setShowPopover] = useState(false);
|
|
437
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
430
438
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
431
439
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
440
|
+
const shortcutHintRafRef = useRef(0);
|
|
441
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
442
|
+
const scroll = scrollRef.current;
|
|
443
|
+
setShowShortcutHint(
|
|
444
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
445
|
+
);
|
|
446
|
+
}, []);
|
|
447
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
448
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
449
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
450
|
+
shortcutHintRafRef.current = 0;
|
|
451
|
+
syncShortcutHintVisibility();
|
|
452
|
+
});
|
|
453
|
+
}, [syncShortcutHintVisibility]);
|
|
432
454
|
|
|
433
455
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
434
456
|
// useMountEffect can't work here because the component returns null on first
|
|
435
457
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
436
458
|
// effect fires and the ResizeObserver is never created.
|
|
437
|
-
const setContainerRef = useCallback(
|
|
438
|
-
|
|
439
|
-
roRef.current
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
459
|
+
const setContainerRef = useCallback(
|
|
460
|
+
(el: HTMLDivElement | null) => {
|
|
461
|
+
if (roRef.current) {
|
|
462
|
+
roRef.current.disconnect();
|
|
463
|
+
roRef.current = null;
|
|
464
|
+
}
|
|
465
|
+
containerRef.current = el;
|
|
466
|
+
if (!el) return;
|
|
467
|
+
setViewportWidth(el.clientWidth);
|
|
468
|
+
scheduleShortcutHintVisibilitySync();
|
|
469
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
470
|
+
setViewportWidth(entry.contentRect.width);
|
|
471
|
+
scheduleShortcutHintVisibilitySync();
|
|
472
|
+
});
|
|
473
|
+
roRef.current.observe(el);
|
|
474
|
+
},
|
|
475
|
+
[scheduleShortcutHintVisibilitySync],
|
|
476
|
+
);
|
|
450
477
|
|
|
451
478
|
// Clean up ResizeObserver on unmount
|
|
452
479
|
useMountEffect(() => () => {
|
|
453
480
|
roRef.current?.disconnect();
|
|
481
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
454
482
|
});
|
|
455
483
|
|
|
456
484
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -495,6 +523,7 @@ export const Timeline = memo(function Timeline({
|
|
|
495
523
|
}
|
|
496
524
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
497
525
|
}, [draggedClip, trackOrder]);
|
|
526
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
498
527
|
const selectedElement = useMemo(
|
|
499
528
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
500
529
|
[elements, selectedElementId],
|
|
@@ -544,7 +573,6 @@ export const Timeline = memo(function Timeline({
|
|
|
544
573
|
);
|
|
545
574
|
previousZoomModeRef.current = zoomMode;
|
|
546
575
|
}, [zoomMode]);
|
|
547
|
-
|
|
548
576
|
useMountEffect(() => {
|
|
549
577
|
const unsub = liveTime.subscribe((t) => {
|
|
550
578
|
const dur = durationRef.current;
|
|
@@ -1012,6 +1040,10 @@ export const Timeline = memo(function Timeline({
|
|
|
1012
1040
|
);
|
|
1013
1041
|
const majorTickInterval =
|
|
1014
1042
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
syncShortcutHintVisibility();
|
|
1045
|
+
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1046
|
+
|
|
1015
1047
|
const getPreviewElement = useCallback(
|
|
1016
1048
|
(element: TimelineElement): TimelineElement => {
|
|
1017
1049
|
if (resizingClip?.element.id === element.id) {
|
|
@@ -1236,7 +1268,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1236
1268
|
);
|
|
1237
1269
|
}
|
|
1238
1270
|
|
|
1239
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1240
1271
|
const draggedElement = draggedClip?.element ?? null;
|
|
1241
1272
|
const activeDraggedElement =
|
|
1242
1273
|
draggedClip?.started === true && draggedElement
|
|
@@ -1310,7 +1341,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1310
1341
|
<div
|
|
1311
1342
|
ref={setContainerRef}
|
|
1312
1343
|
aria-label="Timeline"
|
|
1313
|
-
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1344
|
+
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1314
1345
|
style={{
|
|
1315
1346
|
touchAction: "pan-x pan-y",
|
|
1316
1347
|
background: theme.shellBackground,
|
|
@@ -1649,8 +1680,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1649
1680
|
</div>
|
|
1650
1681
|
</div>
|
|
1651
1682
|
|
|
1652
|
-
{/* Keyboard shortcut hint
|
|
1653
|
-
{!showPopover && !rangeSelection && (
|
|
1683
|
+
{/* Keyboard shortcut hint */}
|
|
1684
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1654
1685
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1655
1686
|
<div
|
|
1656
1687
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
@@ -62,6 +62,25 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
62
62
|
: theme.clipShadow;
|
|
63
63
|
const capabilities = getTimelineEditCapabilities(el);
|
|
64
64
|
const showHandles = handleOpacity > 0.01;
|
|
65
|
+
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
|
|
66
|
+
const glossBackgroundImage = isSelected
|
|
67
|
+
? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
|
|
68
|
+
: "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
|
|
69
|
+
const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
|
|
70
|
+
isSelected ? "22" : "1e"
|
|
71
|
+
}, transparent 28%)`;
|
|
72
|
+
const compositionStripeBackgroundImage =
|
|
73
|
+
isComposition && !hasCustomContent
|
|
74
|
+
? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
|
|
75
|
+
: undefined;
|
|
76
|
+
const clipBackgroundImage = [
|
|
77
|
+
compositionStripeBackgroundImage,
|
|
78
|
+
glossBackgroundImage,
|
|
79
|
+
accentBackgroundImage,
|
|
80
|
+
baseBackgroundImage,
|
|
81
|
+
]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join(", ");
|
|
65
84
|
|
|
66
85
|
return (
|
|
67
86
|
<div
|
|
@@ -75,13 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
75
94
|
top: clipY,
|
|
76
95
|
bottom: clipY,
|
|
77
96
|
borderRadius: theme.clipRadius,
|
|
78
|
-
|
|
79
|
-
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
|
80
|
-
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
|
81
|
-
backgroundImage:
|
|
82
|
-
isComposition && !hasCustomContent
|
|
83
|
-
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
|
84
|
-
: undefined,
|
|
97
|
+
backgroundImage: clipBackgroundImage,
|
|
85
98
|
border: `1px solid ${borderColor}`,
|
|
86
99
|
boxShadow,
|
|
87
100
|
transition:
|
|
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("allows moving generic motion clips while keeping trims blocked", () => {
|
|
252
252
|
expect(
|
|
253
253
|
getTimelineEditCapabilities({
|
|
254
254
|
tag: "section",
|
|
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
256
256
|
selector: ".feature-card",
|
|
257
257
|
}),
|
|
258
258
|
).toEqual({
|
|
259
|
-
canMove:
|
|
259
|
+
canMove: true,
|
|
260
260
|
canTrimStart: false,
|
|
261
261
|
canTrimEnd: false,
|
|
262
262
|
});
|
|
@@ -428,7 +428,6 @@ describe("buildClipRangeSelection", () => {
|
|
|
428
428
|
});
|
|
429
429
|
});
|
|
430
430
|
});
|
|
431
|
-
|
|
432
431
|
describe("resolveTimelineAutoScroll", () => {
|
|
433
432
|
it("does not scroll when the pointer stays away from the edges", () => {
|
|
434
433
|
expect(
|
|
@@ -512,7 +511,6 @@ describe("buildTimelineElementAgentPrompt", () => {
|
|
|
512
511
|
).toContain("If this clip is animated with GSAP");
|
|
513
512
|
});
|
|
514
513
|
});
|
|
515
|
-
|
|
516
514
|
describe("resolveTimelineResize", () => {
|
|
517
515
|
it("shrinks clip duration from the right edge", () => {
|
|
518
516
|
expect(
|
|
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
|
|
|
233
233
|
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
234
234
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
235
235
|
return {
|
|
236
|
-
canMove: canPatch && hasDeterministicWindow,
|
|
236
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
237
237
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
238
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
239
|
};
|
|
@@ -273,7 +273,6 @@ export function buildClipRangeSelection(
|
|
|
273
273
|
anchorY: anchor.anchorY,
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
|
-
|
|
277
276
|
export function buildTimelineAgentPrompt({
|
|
278
277
|
rangeStart,
|
|
279
278
|
rangeEnd,
|
|
@@ -347,7 +346,6 @@ export function buildTimelineElementAgentPrompt(element: {
|
|
|
347
346
|
|
|
348
347
|
return lines.join("\n");
|
|
349
348
|
}
|
|
350
|
-
|
|
351
349
|
export function formatTimelineAttributeNumber(value: number): string {
|
|
352
350
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
|
353
351
|
}
|
|
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
|
|
63
63
|
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
|
64
64
|
|
|
65
65
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
66
|
-
shellBackground: "#
|
|
66
|
+
shellBackground: "#0A0E15",
|
|
67
67
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
68
68
|
rulerBorder: "rgba(255,255,255,0.045)",
|
|
69
|
-
rowBackground: "#
|
|
69
|
+
rowBackground: "#0A0E15",
|
|
70
70
|
rowBorder: "rgba(255,255,255,0.05)",
|
|
71
|
-
gutterBackground: "#
|
|
71
|
+
gutterBackground: "#0D121B",
|
|
72
72
|
gutterBorder: "rgba(255,255,255,0.05)",
|
|
73
73
|
textPrimary: "#E8EDF5",
|
|
74
74
|
textSecondary: "#8391A8",
|
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
2
3
|
import {
|
|
3
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
findTimelineDomNodeForClip,
|
|
6
|
+
getTimelineElementSelector,
|
|
7
|
+
type ClipManifestClip,
|
|
4
8
|
mergeTimelineElementsPreservingDowngrades,
|
|
5
9
|
resolveStandaloneRootCompositionSrc,
|
|
6
10
|
shouldIgnorePlaybackShortcutEvent,
|
|
7
11
|
shouldIgnorePlaybackShortcutTarget,
|
|
8
12
|
} from "./useTimelinePlayer";
|
|
9
13
|
|
|
14
|
+
function createDocument(markup: string): Document {
|
|
15
|
+
const window = new Window();
|
|
16
|
+
window.document.body.innerHTML = markup;
|
|
17
|
+
return window.document;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
21
|
+
return {
|
|
22
|
+
id: null,
|
|
23
|
+
label: "",
|
|
24
|
+
start: 0,
|
|
25
|
+
duration: 4,
|
|
26
|
+
track: 0,
|
|
27
|
+
kind: "element",
|
|
28
|
+
tagName: "div",
|
|
29
|
+
compositionId: null,
|
|
30
|
+
parentCompositionId: null,
|
|
31
|
+
compositionSrc: null,
|
|
32
|
+
assetUrl: null,
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
11
38
|
return {
|
|
12
39
|
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
@@ -87,6 +114,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
|
|
|
87
114
|
});
|
|
88
115
|
});
|
|
89
116
|
|
|
117
|
+
describe("findTimelineDomNodeForClip", () => {
|
|
118
|
+
it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
|
|
119
|
+
const doc = createDocument(`
|
|
120
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
121
|
+
<section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
|
|
122
|
+
<div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
|
|
123
|
+
<div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
|
|
124
|
+
</div>
|
|
125
|
+
`);
|
|
126
|
+
const used = new Set<Element>();
|
|
127
|
+
|
|
128
|
+
const first = findTimelineDomNodeForClip(
|
|
129
|
+
doc,
|
|
130
|
+
createClip({ id: "__node__index_2", track: 1 }),
|
|
131
|
+
1,
|
|
132
|
+
used,
|
|
133
|
+
) as HTMLElement;
|
|
134
|
+
used.add(first);
|
|
135
|
+
const second = findTimelineDomNodeForClip(
|
|
136
|
+
doc,
|
|
137
|
+
createClip({ id: "__node__index_3", track: 2 }),
|
|
138
|
+
2,
|
|
139
|
+
used,
|
|
140
|
+
) as HTMLElement;
|
|
141
|
+
|
|
142
|
+
expect(first.className).toBe("clip duplicate-card first");
|
|
143
|
+
expect(second.className).toBe("clip duplicate-card second");
|
|
144
|
+
expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
|
|
145
|
+
expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
90
149
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
91
150
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
92
151
|
expect(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
-
import {
|
|
4
|
+
import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
7
|
interface PlaybackAdapter {
|
|
@@ -22,7 +22,7 @@ interface TimelineLike {
|
|
|
22
22
|
isActive: () => boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
interface ClipManifestClip {
|
|
25
|
+
export interface ClipManifestClip {
|
|
26
26
|
id: string | null;
|
|
27
27
|
label: string;
|
|
28
28
|
start: number;
|
|
@@ -253,12 +253,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
253
253
|
return els;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
function
|
|
257
|
-
|
|
256
|
+
function isHtmlElement(el: Element): el is HTMLElement {
|
|
257
|
+
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
258
|
+
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function getTimelineElementSelector(el: Element): string | undefined {
|
|
262
|
+
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
|
258
263
|
const compId = el.getAttribute("data-composition-id");
|
|
259
264
|
if (compId) return `[data-composition-id="${compId}"]`;
|
|
260
|
-
if (el
|
|
261
|
-
const
|
|
265
|
+
if (isHtmlElement(el)) {
|
|
266
|
+
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
267
|
+
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
|
262
268
|
if (firstClass) return `.${firstClass}`;
|
|
263
269
|
}
|
|
264
270
|
return undefined;
|
|
@@ -305,6 +311,47 @@ function buildTimelineElementKey(params: {
|
|
|
305
311
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
306
312
|
}
|
|
307
313
|
|
|
314
|
+
function getTimelineDomNodes(doc: Document): Element[] {
|
|
315
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
316
|
+
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function numbersNearlyEqual(a: number, b: number): boolean {
|
|
320
|
+
return Math.abs(a - b) < 0.001;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
|
324
|
+
const tagName = clip.tagName?.toLowerCase();
|
|
325
|
+
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
|
326
|
+
|
|
327
|
+
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
328
|
+
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
|
|
329
|
+
|
|
330
|
+
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
|
|
331
|
+
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
|
|
332
|
+
|
|
333
|
+
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
|
334
|
+
if (Number.isFinite(track) && track !== clip.track) return false;
|
|
335
|
+
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function findTimelineDomNodeForClip(
|
|
340
|
+
doc: Document,
|
|
341
|
+
clip: ClipManifestClip,
|
|
342
|
+
fallbackIndex: number,
|
|
343
|
+
usedNodes = new Set<Element>(),
|
|
344
|
+
): Element | null {
|
|
345
|
+
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
|
|
346
|
+
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
|
|
347
|
+
|
|
348
|
+
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
|
|
349
|
+
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
|
|
350
|
+
if (exact) return exact;
|
|
351
|
+
|
|
352
|
+
return candidates[fallbackIndex] ?? null;
|
|
353
|
+
}
|
|
354
|
+
|
|
308
355
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
309
356
|
return (
|
|
310
357
|
doc.getElementById(id) ??
|
|
@@ -350,7 +397,6 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
350
397
|
sourceFile: compositionSrc,
|
|
351
398
|
};
|
|
352
399
|
}
|
|
353
|
-
|
|
354
400
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
355
401
|
if (doc.documentElement) {
|
|
356
402
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -697,7 +743,7 @@ export function useTimelinePlayer() {
|
|
|
697
743
|
(deltaFrames: number) => {
|
|
698
744
|
const adapter = getAdapter();
|
|
699
745
|
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
700
|
-
seek(
|
|
746
|
+
seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
|
|
701
747
|
},
|
|
702
748
|
[getAdapter, seek],
|
|
703
749
|
);
|
|
@@ -822,8 +868,18 @@ export function useTimelinePlayer() {
|
|
|
822
868
|
const filtered = data.clips.filter(
|
|
823
869
|
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
824
870
|
);
|
|
871
|
+
let iframeDoc: Document | null = null;
|
|
872
|
+
try {
|
|
873
|
+
iframeDoc = iframeRef.current?.contentDocument ?? null;
|
|
874
|
+
} catch {
|
|
875
|
+
iframeDoc = null;
|
|
876
|
+
}
|
|
877
|
+
const usedHostEls = new Set<Element>();
|
|
825
878
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
826
|
-
let hostEl
|
|
879
|
+
let hostEl = iframeDoc
|
|
880
|
+
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
881
|
+
: null;
|
|
882
|
+
if (hostEl) usedHostEls.add(hostEl);
|
|
827
883
|
const id = clip.id || clip.label || clip.tagName || "element";
|
|
828
884
|
const entry: TimelineElement = {
|
|
829
885
|
id,
|
|
@@ -832,16 +888,7 @@ export function useTimelinePlayer() {
|
|
|
832
888
|
duration: clip.duration,
|
|
833
889
|
track: clip.track,
|
|
834
890
|
};
|
|
835
|
-
try {
|
|
836
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
837
|
-
if (iframeDoc && entry.id) {
|
|
838
|
-
hostEl = findTimelineDomNode(iframeDoc, entry.id);
|
|
839
|
-
}
|
|
840
|
-
} catch {
|
|
841
|
-
/* cross-origin */
|
|
842
|
-
}
|
|
843
891
|
if (hostEl) {
|
|
844
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
845
892
|
entry.domId = hostEl.id || undefined;
|
|
846
893
|
entry.selector = getTimelineElementSelector(hostEl);
|
|
847
894
|
entry.selectorIndex =
|
|
@@ -857,19 +904,13 @@ export function useTimelinePlayer() {
|
|
|
857
904
|
// after inlining, so the clip manifest may not have compositionSrc.
|
|
858
905
|
// Fall back to reading data-composition-file from the DOM.
|
|
859
906
|
let resolvedSrc = clip.compositionSrc;
|
|
860
|
-
let hostEl: Element | null = null;
|
|
861
907
|
if (!resolvedSrc) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
869
|
-
null;
|
|
870
|
-
} catch {
|
|
871
|
-
/* cross-origin */
|
|
872
|
-
}
|
|
908
|
+
hostEl =
|
|
909
|
+
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
910
|
+
resolvedSrc =
|
|
911
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
912
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
913
|
+
null;
|
|
873
914
|
}
|
|
874
915
|
if (resolvedSrc) {
|
|
875
916
|
entry.compositionSrc = resolvedSrc;
|
|
@@ -1196,6 +1237,9 @@ export function useTimelinePlayer() {
|
|
|
1196
1237
|
setIsPlaying(false);
|
|
1197
1238
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1198
1239
|
|
|
1240
|
+
const togglePlayRef = useRef(togglePlay);
|
|
1241
|
+
togglePlayRef.current = togglePlay;
|
|
1242
|
+
|
|
1199
1243
|
const refreshPlayer = useCallback(() => {
|
|
1200
1244
|
const iframe = iframeRef.current;
|
|
1201
1245
|
if (!iframe) return;
|
|
@@ -1304,8 +1348,6 @@ export function useTimelinePlayer() {
|
|
|
1304
1348
|
stopRAFLoop();
|
|
1305
1349
|
stopReverseLoop();
|
|
1306
1350
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1307
|
-
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
|
|
1308
|
-
// to prevent blink. New data will replace old when the iframe reloads.
|
|
1309
1351
|
};
|
|
1310
1352
|
});
|
|
1311
1353
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatFrameTime, frameToSeconds, secondsToFrame,
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -72,14 +72,4 @@ describe("frame helpers", () => {
|
|
|
72
72
|
it("formats current and total frame display", () => {
|
|
73
73
|
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
74
|
});
|
|
75
|
-
|
|
76
|
-
it("steps from a truncated runtime time by integer frame index", () => {
|
|
77
|
-
expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
|
|
78
|
-
expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
|
|
79
|
-
expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("clamps frame stepping at zero", () => {
|
|
83
|
-
expect(stepFrameTime(0, -1)).toBe(0);
|
|
84
|
-
});
|
|
85
75
|
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -19,12 +19,6 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
|
|
|
19
19
|
return frame / fps;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
23
|
-
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
-
const nextFrame = Math.max(0, currentFrame + deltaFrames);
|
|
25
|
-
return frameToSeconds(nextFrame, fps);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
22
|
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
29
23
|
const currentFrame = secondsToFrame(time, fps);
|
|
30
24
|
const totalFrames = secondsToFrame(duration, fps);
|