@hyperframes/studio 0.5.0-alpha.8 → 0.5.0
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/hyperframes-player-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1436
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2462
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +9 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
- package/src/player/hooks/useTimelinePlayer.ts +487 -106
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +6 -1
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -442
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
getTimelinePlayheadLeft,
|
|
9
9
|
getTimelineScrollLeftForZoomAnchor,
|
|
10
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
-
shouldShowTimelineShortcutHint,
|
|
12
11
|
shouldHandleTimelineDeleteKey,
|
|
13
12
|
shouldAutoScrollTimeline,
|
|
14
13
|
} from "./Timeline";
|
|
@@ -238,17 +237,6 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
238
237
|
});
|
|
239
238
|
});
|
|
240
239
|
|
|
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
|
-
|
|
252
240
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
253
241
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
254
242
|
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 = 24;
|
|
39
39
|
|
|
40
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
41
|
icon: ReactNode;
|
|
@@ -216,14 +216,6 @@ 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
|
-
|
|
227
219
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
228
220
|
key: string;
|
|
229
221
|
metaKey?: boolean;
|
|
@@ -287,6 +279,7 @@ export function resolveTimelineAssetDrop(
|
|
|
287
279
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
288
280
|
};
|
|
289
281
|
}
|
|
282
|
+
|
|
290
283
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
291
284
|
interface TimelineProps {
|
|
292
285
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -434,51 +427,30 @@ export const Timeline = memo(function Timeline({
|
|
|
434
427
|
onDeleteElementRef.current = onDeleteElement;
|
|
435
428
|
const suppressClickRef = useRef(false);
|
|
436
429
|
const [showPopover, setShowPopover] = useState(false);
|
|
437
|
-
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
438
430
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
439
431
|
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]);
|
|
454
432
|
|
|
455
433
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
456
434
|
// useMountEffect can't work here because the component returns null on first
|
|
457
435
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
458
436
|
// effect fires and the ResizeObserver is never created.
|
|
459
|
-
const setContainerRef = useCallback(
|
|
460
|
-
(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
});
|
|
473
|
-
roRef.current.observe(el);
|
|
474
|
-
},
|
|
475
|
-
[scheduleShortcutHintVisibilitySync],
|
|
476
|
-
);
|
|
437
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
438
|
+
if (roRef.current) {
|
|
439
|
+
roRef.current.disconnect();
|
|
440
|
+
roRef.current = null;
|
|
441
|
+
}
|
|
442
|
+
containerRef.current = el;
|
|
443
|
+
if (!el) return;
|
|
444
|
+
setViewportWidth(el.clientWidth);
|
|
445
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
446
|
+
setViewportWidth(entry.contentRect.width);
|
|
447
|
+
});
|
|
448
|
+
roRef.current.observe(el);
|
|
449
|
+
}, []);
|
|
477
450
|
|
|
478
451
|
// Clean up ResizeObserver on unmount
|
|
479
452
|
useMountEffect(() => () => {
|
|
480
453
|
roRef.current?.disconnect();
|
|
481
|
-
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
482
454
|
});
|
|
483
455
|
|
|
484
456
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -523,7 +495,6 @@ export const Timeline = memo(function Timeline({
|
|
|
523
495
|
}
|
|
524
496
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
525
497
|
}, [draggedClip, trackOrder]);
|
|
526
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
527
498
|
const selectedElement = useMemo(
|
|
528
499
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
529
500
|
[elements, selectedElementId],
|
|
@@ -573,6 +544,7 @@ export const Timeline = memo(function Timeline({
|
|
|
573
544
|
);
|
|
574
545
|
previousZoomModeRef.current = zoomMode;
|
|
575
546
|
}, [zoomMode]);
|
|
547
|
+
|
|
576
548
|
useMountEffect(() => {
|
|
577
549
|
const unsub = liveTime.subscribe((t) => {
|
|
578
550
|
const dur = durationRef.current;
|
|
@@ -1040,12 +1012,12 @@ export const Timeline = memo(function Timeline({
|
|
|
1040
1012
|
);
|
|
1041
1013
|
const majorTickInterval =
|
|
1042
1014
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1043
|
-
useEffect(() => {
|
|
1044
|
-
syncShortcutHintVisibility();
|
|
1045
|
-
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1046
1015
|
const getPreviewElement = useCallback(
|
|
1047
1016
|
(element: TimelineElement): TimelineElement => {
|
|
1048
|
-
if (
|
|
1017
|
+
if (
|
|
1018
|
+
resizingClip &&
|
|
1019
|
+
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
|
|
1020
|
+
) {
|
|
1049
1021
|
return {
|
|
1050
1022
|
...element,
|
|
1051
1023
|
start: resizingClip.previewStart,
|
|
@@ -1267,12 +1239,13 @@ export const Timeline = memo(function Timeline({
|
|
|
1267
1239
|
);
|
|
1268
1240
|
}
|
|
1269
1241
|
|
|
1242
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1270
1243
|
const draggedElement = draggedClip?.element ?? null;
|
|
1271
1244
|
const activeDraggedElement =
|
|
1272
1245
|
draggedClip?.started === true && draggedElement
|
|
1273
1246
|
? getRenderedTimelineElement({
|
|
1274
1247
|
element: draggedElement,
|
|
1275
|
-
draggedElementId: draggedElement.id,
|
|
1248
|
+
draggedElementId: draggedElement.key ?? draggedElement.id,
|
|
1276
1249
|
previewStart: draggedClip.previewStart,
|
|
1277
1250
|
previewTrack: draggedClip.previewTrack,
|
|
1278
1251
|
})
|
|
@@ -1340,7 +1313,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1340
1313
|
<div
|
|
1341
1314
|
ref={setContainerRef}
|
|
1342
1315
|
aria-label="Timeline"
|
|
1343
|
-
className={`
|
|
1316
|
+
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1344
1317
|
style={{
|
|
1345
1318
|
touchAction: "pan-x pan-y",
|
|
1346
1319
|
background: theme.shellBackground,
|
|
@@ -1679,8 +1652,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1679
1652
|
</div>
|
|
1680
1653
|
</div>
|
|
1681
1654
|
|
|
1682
|
-
{/* Keyboard shortcut hint */}
|
|
1683
|
-
{
|
|
1655
|
+
{/* Keyboard shortcut hint — always visible */}
|
|
1656
|
+
{!showPopover && !rangeSelection && (
|
|
1684
1657
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1685
1658
|
<div
|
|
1686
1659
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
@@ -61,26 +61,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
61
61
|
? theme.clipShadowHover
|
|
62
62
|
: theme.clipShadow;
|
|
63
63
|
const capabilities = getTimelineEditCapabilities(el);
|
|
64
|
+
const displayLabel = el.label || el.id || el.tag;
|
|
64
65
|
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(", ");
|
|
84
66
|
|
|
85
67
|
return (
|
|
86
68
|
<div
|
|
@@ -94,7 +76,13 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
94
76
|
top: clipY,
|
|
95
77
|
bottom: clipY,
|
|
96
78
|
borderRadius: theme.clipRadius,
|
|
97
|
-
|
|
79
|
+
background: isSelected
|
|
80
|
+
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
|
81
|
+
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
|
82
|
+
backgroundImage:
|
|
83
|
+
isComposition && !hasCustomContent
|
|
84
|
+
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
|
85
|
+
: undefined,
|
|
98
86
|
border: `1px solid ${borderColor}`,
|
|
99
87
|
boxShadow,
|
|
100
88
|
transition:
|
|
@@ -106,7 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
106
94
|
title={
|
|
107
95
|
isComposition
|
|
108
96
|
? `${el.compositionSrc} \u2022 Double-click to open`
|
|
109
|
-
: `${
|
|
97
|
+
: `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
|
|
110
98
|
}
|
|
111
99
|
onPointerEnter={onHoverStart}
|
|
112
100
|
onPointerLeave={onHoverEnd}
|
|
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("disables move and trims for generic motion clips even when patchable", () => {
|
|
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: false,
|
|
260
260
|
canTrimStart: false,
|
|
261
261
|
canTrimEnd: false,
|
|
262
262
|
});
|
|
@@ -428,6 +428,7 @@ describe("buildClipRangeSelection", () => {
|
|
|
428
428
|
});
|
|
429
429
|
});
|
|
430
430
|
});
|
|
431
|
+
|
|
431
432
|
describe("resolveTimelineAutoScroll", () => {
|
|
432
433
|
it("does not scroll when the pointer stays away from the edges", () => {
|
|
433
434
|
expect(
|
|
@@ -511,6 +512,7 @@ describe("buildTimelineElementAgentPrompt", () => {
|
|
|
511
512
|
).toContain("If this clip is animated with GSAP");
|
|
512
513
|
});
|
|
513
514
|
});
|
|
515
|
+
|
|
514
516
|
describe("resolveTimelineResize", () => {
|
|
515
517
|
it("shrinks clip duration from the right edge", () => {
|
|
516
518
|
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 &&
|
|
236
|
+
canMove: canPatch && hasDeterministicWindow,
|
|
237
237
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
238
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
239
|
};
|
|
@@ -273,6 +273,7 @@ export function buildClipRangeSelection(
|
|
|
273
273
|
anchorY: anchor.anchorY,
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
|
+
|
|
276
277
|
export function buildTimelineAgentPrompt({
|
|
277
278
|
rangeStart,
|
|
278
279
|
rangeEnd,
|
|
@@ -346,6 +347,7 @@ export function buildTimelineElementAgentPrompt(element: {
|
|
|
346
347
|
|
|
347
348
|
return lines.join("\n");
|
|
348
349
|
}
|
|
350
|
+
|
|
349
351
|
export function formatTimelineAttributeNumber(value: number): string {
|
|
350
352
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
|
351
353
|
}
|
|
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
|
|
|
53
53
|
}),
|
|
54
54
|
).toEqual({ ...element, start: 2.4, track: 3 });
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
it("uses key before id when matching the dragged clip", () => {
|
|
58
|
+
const element = {
|
|
59
|
+
id: "Card",
|
|
60
|
+
key: "index.html:.card:1",
|
|
61
|
+
tag: "div",
|
|
62
|
+
start: 1,
|
|
63
|
+
duration: 2,
|
|
64
|
+
track: 0,
|
|
65
|
+
};
|
|
66
|
+
expect(
|
|
67
|
+
getRenderedTimelineElement({
|
|
68
|
+
element,
|
|
69
|
+
draggedElementId: "index.html:.card:1",
|
|
70
|
+
previewStart: 2.4,
|
|
71
|
+
previewTrack: 3,
|
|
72
|
+
}),
|
|
73
|
+
).toEqual({ ...element, start: 2.4, track: 3 });
|
|
74
|
+
});
|
|
56
75
|
});
|
|
@@ -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: "#0A0A0B",
|
|
67
67
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
68
68
|
rulerBorder: "rgba(255,255,255,0.045)",
|
|
69
|
-
rowBackground: "#
|
|
69
|
+
rowBackground: "#0A0A0B",
|
|
70
70
|
rowBorder: "rgba(255,255,255,0.05)",
|
|
71
|
-
gutterBackground: "#
|
|
71
|
+
gutterBackground: "#0A0A0B",
|
|
72
72
|
gutterBorder: "rgba(255,255,255,0.05)",
|
|
73
73
|
textPrimary: "#E8EDF5",
|
|
74
74
|
textSecondary: "#8391A8",
|
|
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
|
|
|
130
130
|
previewStart: number | null;
|
|
131
131
|
previewTrack: number | null;
|
|
132
132
|
}): TimelineElement {
|
|
133
|
-
if (
|
|
133
|
+
if (
|
|
134
|
+
(element.key ?? element.id) !== draggedElementId ||
|
|
135
|
+
previewStart === null ||
|
|
136
|
+
previewTrack === null
|
|
137
|
+
) {
|
|
134
138
|
return element;
|
|
135
139
|
}
|
|
136
140
|
return {
|
|
@@ -2,13 +2,37 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { Window } from "happy-dom";
|
|
3
3
|
import {
|
|
4
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
createTimelineElementFromManifestClip,
|
|
5
6
|
findTimelineDomNodeForClip,
|
|
6
7
|
getTimelineElementSelector,
|
|
8
|
+
parseTimelineFromDOM,
|
|
7
9
|
type ClipManifestClip,
|
|
8
10
|
mergeTimelineElementsPreservingDowngrades,
|
|
9
11
|
resolveStandaloneRootCompositionSrc,
|
|
12
|
+
shouldIgnorePlaybackShortcutEvent,
|
|
13
|
+
shouldIgnorePlaybackShortcutTarget,
|
|
10
14
|
} from "./useTimelinePlayer";
|
|
11
15
|
|
|
16
|
+
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
17
|
+
return {
|
|
18
|
+
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
19
|
+
} as unknown as EventTarget;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mockKeyboardEvent(
|
|
23
|
+
code: string,
|
|
24
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
|
25
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
|
26
|
+
return {
|
|
27
|
+
altKey: false,
|
|
28
|
+
ctrlKey: false,
|
|
29
|
+
metaKey: false,
|
|
30
|
+
code,
|
|
31
|
+
target: mockTargetMatching("[data-missing]"),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
function createDocument(markup: string): Document {
|
|
13
37
|
const window = new Window();
|
|
14
38
|
window.document.body.innerHTML = markup;
|
|
@@ -18,7 +42,7 @@ function createDocument(markup: string): Document {
|
|
|
18
42
|
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
19
43
|
return {
|
|
20
44
|
id: null,
|
|
21
|
-
label: "",
|
|
45
|
+
label: "Element",
|
|
22
46
|
start: 0,
|
|
23
47
|
duration: 4,
|
|
24
48
|
track: 0,
|
|
@@ -44,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
|
|
|
44
68
|
}),
|
|
45
69
|
).toEqual({
|
|
46
70
|
id: "hero",
|
|
71
|
+
label: "hero",
|
|
47
72
|
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
|
|
48
73
|
tag: "div",
|
|
49
74
|
start: 0,
|
|
@@ -124,6 +149,83 @@ describe("findTimelineDomNodeForClip", () => {
|
|
|
124
149
|
});
|
|
125
150
|
});
|
|
126
151
|
|
|
152
|
+
describe("anonymous timeline identity", () => {
|
|
153
|
+
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
|
|
154
|
+
const doc = createDocument(`
|
|
155
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
156
|
+
<div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
157
|
+
<div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
158
|
+
</div>
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
const elements = parseTimelineFromDOM(doc, 8);
|
|
162
|
+
|
|
163
|
+
expect(elements).toHaveLength(2);
|
|
164
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
165
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
166
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
167
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
|
|
171
|
+
const doc = createDocument(`
|
|
172
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
173
|
+
<div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
174
|
+
<div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
175
|
+
</div>
|
|
176
|
+
`);
|
|
177
|
+
const clips = [
|
|
178
|
+
createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
|
|
179
|
+
createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
|
|
180
|
+
];
|
|
181
|
+
const used = new Set<Element>();
|
|
182
|
+
const elements = clips.map((clip, index) => {
|
|
183
|
+
const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
|
|
184
|
+
if (hostEl) used.add(hostEl);
|
|
185
|
+
return createTimelineElementFromManifestClip({
|
|
186
|
+
clip,
|
|
187
|
+
fallbackIndex: index,
|
|
188
|
+
doc,
|
|
189
|
+
hostEl,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
194
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
195
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
196
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reads media metadata from owner-window media elements", () => {
|
|
200
|
+
const doc = createDocument(`
|
|
201
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
202
|
+
<div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
|
|
203
|
+
<video src="/clip.mp4" data-source-duration="12"></video>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
`);
|
|
207
|
+
const hostEl = doc.querySelector(".video-card");
|
|
208
|
+
const video = hostEl?.querySelector("video");
|
|
209
|
+
if (!hostEl || !video) throw new Error("missing video test fixture");
|
|
210
|
+
Object.defineProperty(video, "defaultPlaybackRate", {
|
|
211
|
+
value: 1.5,
|
|
212
|
+
configurable: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const element = createTimelineElementFromManifestClip({
|
|
216
|
+
clip: createClip({ kind: "video", tagName: "div" }),
|
|
217
|
+
fallbackIndex: 0,
|
|
218
|
+
doc,
|
|
219
|
+
hostEl,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(element.tag).toBe("video");
|
|
223
|
+
expect(element.src).toBe("/clip.mp4");
|
|
224
|
+
expect(element.sourceDuration).toBe(12);
|
|
225
|
+
expect(element.playbackRate).toBe(1.5);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
127
229
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
128
230
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
129
231
|
expect(
|
|
@@ -152,4 +254,120 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
152
254
|
),
|
|
153
255
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
154
256
|
});
|
|
257
|
+
|
|
258
|
+
it("preserves distinct anonymous clips that share the same friendly id label", () => {
|
|
259
|
+
expect(
|
|
260
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
261
|
+
[
|
|
262
|
+
{
|
|
263
|
+
id: "Card",
|
|
264
|
+
key: "index.html:.card:0",
|
|
265
|
+
label: "Card",
|
|
266
|
+
tag: "div",
|
|
267
|
+
start: 0,
|
|
268
|
+
duration: 3,
|
|
269
|
+
track: 0,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "Card",
|
|
273
|
+
key: "index.html:.card:1",
|
|
274
|
+
label: "Card",
|
|
275
|
+
tag: "div",
|
|
276
|
+
start: 3,
|
|
277
|
+
duration: 3,
|
|
278
|
+
track: 1,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
{
|
|
283
|
+
id: "Card",
|
|
284
|
+
key: "index.html:.card:0",
|
|
285
|
+
label: "Card",
|
|
286
|
+
tag: "div",
|
|
287
|
+
start: 0,
|
|
288
|
+
duration: 3,
|
|
289
|
+
track: 0,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
8,
|
|
293
|
+
8,
|
|
294
|
+
),
|
|
295
|
+
).toEqual([
|
|
296
|
+
{
|
|
297
|
+
id: "Card",
|
|
298
|
+
key: "index.html:.card:0",
|
|
299
|
+
label: "Card",
|
|
300
|
+
tag: "div",
|
|
301
|
+
start: 0,
|
|
302
|
+
duration: 3,
|
|
303
|
+
track: 0,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "Card",
|
|
307
|
+
key: "index.html:.card:1",
|
|
308
|
+
label: "Card",
|
|
309
|
+
tag: "div",
|
|
310
|
+
start: 3,
|
|
311
|
+
duration: 3,
|
|
312
|
+
track: 1,
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("shouldIgnorePlaybackShortcutTarget", () => {
|
|
319
|
+
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
|
|
320
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
|
|
324
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("allows non-interactive preview targets to use playback shortcuts", () => {
|
|
328
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("shouldIgnorePlaybackShortcutEvent", () => {
|
|
333
|
+
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
|
|
334
|
+
expect(
|
|
335
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
|
|
336
|
+
).toBe(true);
|
|
337
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
|
|
338
|
+
true,
|
|
339
|
+
);
|
|
340
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
|
|
341
|
+
true,
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
|
|
346
|
+
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
|
347
|
+
|
|
348
|
+
expect(
|
|
349
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
|
350
|
+
).toBe(true);
|
|
351
|
+
expect(
|
|
352
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
|
|
353
|
+
).toBe(true);
|
|
354
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
|
|
355
|
+
false,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
|
360
|
+
expect(
|
|
361
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
362
|
+
isCaptionEditMode: true,
|
|
363
|
+
selectedCaptionSegmentCount: 0,
|
|
364
|
+
}),
|
|
365
|
+
).toBe(false);
|
|
366
|
+
expect(
|
|
367
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
368
|
+
isCaptionEditMode: false,
|
|
369
|
+
selectedCaptionSegmentCount: 1,
|
|
370
|
+
}),
|
|
371
|
+
).toBe(false);
|
|
372
|
+
});
|
|
155
373
|
});
|