@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
|
|
3
|
+
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
|
4
|
+
* timeline's scroll container so that clip is visible — horizontally to its
|
|
5
|
+
* time and vertically to its lane.
|
|
6
|
+
*
|
|
7
|
+
* The request is consumed (cleared) whether or not the clip node is found, so
|
|
8
|
+
* a stale request can never replay a scroll later. Respects zoom mode: in
|
|
9
|
+
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
|
10
|
+
* only the vertical axis is scrolled there.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect } from "react";
|
|
13
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
14
|
+
import { GUTTER, RULER_H } from "./timelineLayout";
|
|
15
|
+
import { computeRevealScroll } from "./timelineRevealScroll";
|
|
16
|
+
|
|
17
|
+
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
|
|
18
|
+
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!revealRequest) return;
|
|
22
|
+
// Consume the request first — reveal is one-shot, even when the clip node
|
|
23
|
+
// isn't currently rendered (e.g. drilled into a different composition).
|
|
24
|
+
usePlayerStore.getState().clearClipRevealRequest();
|
|
25
|
+
|
|
26
|
+
const container = scrollRef.current;
|
|
27
|
+
if (!container) return;
|
|
28
|
+
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
|
|
29
|
+
if (!(clip instanceof HTMLElement)) return;
|
|
30
|
+
|
|
31
|
+
const containerRect = container.getBoundingClientRect();
|
|
32
|
+
const clipRect = clip.getBoundingClientRect();
|
|
33
|
+
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
|
|
34
|
+
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
|
|
35
|
+
|
|
36
|
+
const target = computeRevealScroll({
|
|
37
|
+
scrollLeft: container.scrollLeft,
|
|
38
|
+
scrollTop: container.scrollTop,
|
|
39
|
+
viewportWidth: container.clientWidth,
|
|
40
|
+
viewportHeight: container.clientHeight,
|
|
41
|
+
clipLeft,
|
|
42
|
+
clipRight: clipLeft + clipRect.width,
|
|
43
|
+
clipTop,
|
|
44
|
+
clipBottom: clipTop + clipRect.height,
|
|
45
|
+
stickyLeft: GUTTER,
|
|
46
|
+
stickyTop: RULER_H,
|
|
47
|
+
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
|
|
48
|
+
});
|
|
49
|
+
if (target.left === null && target.top === null) return;
|
|
50
|
+
container.scrollTo({
|
|
51
|
+
left: target.left ?? container.scrollLeft,
|
|
52
|
+
top: target.top ?? container.scrollTop,
|
|
53
|
+
behavior: "smooth",
|
|
54
|
+
});
|
|
55
|
+
}, [revealRequest, scrollRef]);
|
|
56
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The timeline scroll container's viewport plumbing — extracted verbatim from
|
|
7
|
+
* Timeline.tsx (600-line studio cap): the ResizeObserver-backed viewport width,
|
|
8
|
+
* the rAF-throttled shortcut-hint visibility sync, and the callback ref that
|
|
9
|
+
* wires both to the scroll element. `resyncShortcutHintOn` re-checks the hint
|
|
10
|
+
* whenever any of its values change (timeline readiness / element count /
|
|
11
|
+
* canvas height), matching the original effect.
|
|
12
|
+
*/
|
|
13
|
+
export function useTimelineScrollViewport(
|
|
14
|
+
scrollRef: RefObject<HTMLDivElement | null>,
|
|
15
|
+
resyncShortcutHintOn: ReadonlyArray<unknown>,
|
|
16
|
+
): {
|
|
17
|
+
viewportWidth: number;
|
|
18
|
+
showShortcutHint: boolean;
|
|
19
|
+
setScrollRef: (el: HTMLDivElement | null) => void;
|
|
20
|
+
} {
|
|
21
|
+
const [viewportWidth, setViewportWidth] = useState(0);
|
|
22
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
23
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
24
|
+
const shortcutHintRafRef = useRef(0);
|
|
25
|
+
|
|
26
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
27
|
+
const scroll = scrollRef.current;
|
|
28
|
+
setShowShortcutHint(
|
|
29
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
30
|
+
);
|
|
31
|
+
}, [scrollRef]);
|
|
32
|
+
|
|
33
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
34
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
35
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
36
|
+
shortcutHintRafRef.current = 0;
|
|
37
|
+
syncShortcutHintVisibility();
|
|
38
|
+
});
|
|
39
|
+
}, [syncShortcutHintVisibility]);
|
|
40
|
+
|
|
41
|
+
const setScrollRef = useCallback(
|
|
42
|
+
(el: HTMLDivElement | null) => {
|
|
43
|
+
if (roRef.current) {
|
|
44
|
+
roRef.current.disconnect();
|
|
45
|
+
roRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
scrollRef.current = el;
|
|
48
|
+
if (!el) return;
|
|
49
|
+
|
|
50
|
+
const syncScrollViewport = () => {
|
|
51
|
+
setViewportWidth(el.clientWidth);
|
|
52
|
+
scheduleShortcutHintVisibilitySync();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
syncScrollViewport();
|
|
56
|
+
roRef.current = new ResizeObserver(syncScrollViewport);
|
|
57
|
+
roRef.current.observe(el);
|
|
58
|
+
},
|
|
59
|
+
[scrollRef, scheduleShortcutHintVisibilitySync],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
useMountEffect(() => () => {
|
|
63
|
+
roRef.current?.disconnect();
|
|
64
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
syncShortcutHintVisibility();
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
|
|
71
|
+
|
|
72
|
+
return { viewportWidth, showShortcutHint, setScrollRef };
|
|
73
|
+
}
|
|
@@ -40,7 +40,6 @@ describe("useTimelineStackingSync", () => {
|
|
|
40
40
|
start: 0,
|
|
41
41
|
duration: 2,
|
|
42
42
|
track: 0,
|
|
43
|
-
sourceFile: "nested.html",
|
|
44
43
|
};
|
|
45
44
|
let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
|
|
46
45
|
null;
|
|
@@ -57,7 +56,17 @@ describe("useTimelineStackingSync", () => {
|
|
|
57
56
|
});
|
|
58
57
|
|
|
59
58
|
expect(commit).toHaveBeenCalledWith(
|
|
60
|
-
[
|
|
59
|
+
[
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
element: node,
|
|
62
|
+
zIndex: 8,
|
|
63
|
+
sourceFile: "nested.html",
|
|
64
|
+
// The patch's store key rides along so the commit updates the store
|
|
65
|
+
// zIndex synchronously on the lane-sync path (and rolls it back on
|
|
66
|
+
// failure) instead of waiting for a preview reload.
|
|
67
|
+
key: "a",
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
61
70
|
"clip-lane-move:7",
|
|
62
71
|
);
|
|
63
72
|
act(() => root.unmount());
|
|
@@ -37,7 +37,7 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
|
|
37
37
|
id: el.domId ?? el.id,
|
|
38
38
|
selector: el.selector,
|
|
39
39
|
selectorIndex: el.selectorIndex,
|
|
40
|
-
sourceFile: el.sourceFile,
|
|
40
|
+
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
|
|
41
41
|
},
|
|
42
42
|
zSyncActiveCompPath,
|
|
43
43
|
);
|
|
@@ -45,17 +45,23 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
|
|
45
45
|
[zSyncPreviewIframeRef, zSyncActiveCompPath],
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
+
// NaN (NOT 0) when the element can't be resolved in the preview iframe — a
|
|
49
|
+
// nested / unmounted sub-comp node, or one outside the active file. Fabricating
|
|
50
|
+
// z=0 would enter computeStackingPatches as a real overlapping neighbour at the
|
|
51
|
+
// z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
|
|
52
|
+
// clip instead. NaN (rather than null) keeps the return assignable to the
|
|
53
|
+
// `(el) => number` reader contract the drag hook / commit deps declare.
|
|
48
54
|
const readClipZIndex = useCallback(
|
|
49
55
|
(el: TimelineElement): number => {
|
|
50
56
|
const node = resolveIframeElement(el);
|
|
51
|
-
return node ? readEffectiveZIndex(node) :
|
|
57
|
+
return node ? readEffectiveZIndex(node) : Number.NaN;
|
|
52
58
|
},
|
|
53
59
|
[resolveIframeElement],
|
|
54
60
|
);
|
|
55
61
|
|
|
56
62
|
const applyStackingPatches = useCallback(
|
|
57
63
|
(patches: StackingPatch[], coalesceKey?: string) => {
|
|
58
|
-
if (!handleDomZIndexReorderCommit) return;
|
|
64
|
+
if (!handleDomZIndexReorderCommit) return Promise.resolve();
|
|
59
65
|
const entries = patches.flatMap((p) => {
|
|
60
66
|
const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
|
|
61
67
|
const node = el && resolveIframeElement(el);
|
|
@@ -68,10 +74,17 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
|
|
68
74
|
selector: el.selector,
|
|
69
75
|
selectorIndex: el.selectorIndex,
|
|
70
76
|
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
|
|
77
|
+
// The store key: lets the commit update the store's zIndex
|
|
78
|
+
// synchronously (and roll it back on failure).
|
|
79
|
+
key: p.key,
|
|
71
80
|
},
|
|
72
81
|
];
|
|
73
82
|
});
|
|
74
|
-
|
|
83
|
+
// Forward the drag-commit's shared coalesce key so the z-reorder history
|
|
84
|
+
// entry merges with the lane change's move entry into one undo step.
|
|
85
|
+
return entries.length
|
|
86
|
+
? handleDomZIndexReorderCommit(entries, coalesceKey).then(() => undefined)
|
|
87
|
+
: Promise.resolve();
|
|
75
88
|
},
|
|
76
89
|
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
|
|
77
90
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
|
|
7
|
+
* clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
|
|
8
|
+
* `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
|
|
9
|
+
* cohesive unit (600-line studio cap); each memo keys on the expanded display
|
|
10
|
+
* element set exactly as before.
|
|
11
|
+
*/
|
|
12
|
+
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
|
|
13
|
+
tracks: [number, TimelineElement[]][];
|
|
14
|
+
trackStyles: Map<number, TrackVisualStyle>;
|
|
15
|
+
trackOrder: number[];
|
|
16
|
+
} {
|
|
17
|
+
const tracks = useMemo(() => {
|
|
18
|
+
const map = new Map<number, TimelineElement[]>();
|
|
19
|
+
for (const el of expandedElements) {
|
|
20
|
+
const list = map.get(el.track) ?? [];
|
|
21
|
+
list.push(el);
|
|
22
|
+
map.set(el.track, list);
|
|
23
|
+
}
|
|
24
|
+
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
25
|
+
}, [expandedElements]);
|
|
26
|
+
|
|
27
|
+
const trackStyles = useMemo(() => {
|
|
28
|
+
const map = new Map<number, TrackVisualStyle>();
|
|
29
|
+
for (const [trackNum, els] of tracks) {
|
|
30
|
+
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}, [tracks]);
|
|
34
|
+
|
|
35
|
+
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
|
36
|
+
|
|
37
|
+
return { tracks, trackStyles, trackOrder };
|
|
38
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState, type MutableRefObject } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DragCommitDeps } from "./timelineClipDragCommit";
|
|
4
|
+
import {
|
|
5
|
+
laneGapFloor,
|
|
6
|
+
resolveAllGapIntervals,
|
|
7
|
+
resolveAllTrackGaps,
|
|
8
|
+
resolveCloseGapShifts,
|
|
9
|
+
resolveTrackGapAt,
|
|
10
|
+
type TrackGapInterval,
|
|
11
|
+
} from "./timelineGaps";
|
|
12
|
+
import {
|
|
13
|
+
canShiftTrackGapClips,
|
|
14
|
+
commitCloseAllTrackGaps,
|
|
15
|
+
commitCloseTrackGap,
|
|
16
|
+
} from "./timelineGapCommit";
|
|
17
|
+
|
|
18
|
+
/** Right-click anchor on EMPTY lane space: pointer position + clicked lane/time. */
|
|
19
|
+
interface TrackGapMenuAnchor {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
track: number;
|
|
23
|
+
time: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Gap strips to paint on one lane while a menu row is hovered. */
|
|
27
|
+
export interface TrackGapHighlight {
|
|
28
|
+
track: number;
|
|
29
|
+
intervals: TrackGapInterval[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Track-gap context menu (right-click on empty lane space) — state, the
|
|
34
|
+
* derived menu model, and the two commit actions. Extracted from Timeline.tsx
|
|
35
|
+
* as a cohesive unit (600-line studio cap); behavior identical.
|
|
36
|
+
*
|
|
37
|
+
* Only the ANCHOR (and the hovered row) is state; the menu model (gap under
|
|
38
|
+
* the pointer, compaction, movability) derives from live `tracks` so an open
|
|
39
|
+
* menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
|
|
40
|
+
* paints while "Close gap" / "Close all gaps" is hovered — derives the same
|
|
41
|
+
* way. Commits are ONE atomic batch each via the existing move-persist
|
|
42
|
+
* pipeline (see timelineGapCommit.ts).
|
|
43
|
+
*/
|
|
44
|
+
export function useTrackGapMenu({
|
|
45
|
+
tracks,
|
|
46
|
+
expandedElementsRef,
|
|
47
|
+
trackOrderRef,
|
|
48
|
+
onMoveElement,
|
|
49
|
+
onMoveElements,
|
|
50
|
+
}: {
|
|
51
|
+
tracks: [number, TimelineElement[]][];
|
|
52
|
+
expandedElementsRef: MutableRefObject<TimelineElement[]>;
|
|
53
|
+
trackOrderRef: MutableRefObject<number[]>;
|
|
54
|
+
onMoveElement: DragCommitDeps["onMoveElement"];
|
|
55
|
+
onMoveElements: DragCommitDeps["onMoveElements"];
|
|
56
|
+
}) {
|
|
57
|
+
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
58
|
+
const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
|
|
59
|
+
const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
|
|
60
|
+
|
|
61
|
+
const gapMenuLaneElements = useMemo(
|
|
62
|
+
() => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
|
|
63
|
+
[gapContextMenu, tracks],
|
|
64
|
+
);
|
|
65
|
+
const gapMenuModel = useMemo(() => {
|
|
66
|
+
if (!gapContextMenu || !gapMenuLaneElements) return null;
|
|
67
|
+
const floor = laneGapFloor(gapMenuLaneElements);
|
|
68
|
+
const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
|
|
69
|
+
const allShifts = resolveAllTrackGaps(gapMenuLaneElements, undefined, floor);
|
|
70
|
+
return {
|
|
71
|
+
x: gapContextMenu.x,
|
|
72
|
+
y: gapContextMenu.y,
|
|
73
|
+
gapWidth: gap ? gap.gapEnd - gap.gapStart : null,
|
|
74
|
+
canCloseGap:
|
|
75
|
+
gap != null &&
|
|
76
|
+
canShiftTrackGapClips(gapMenuLaneElements, resolveCloseGapShifts(gapMenuLaneElements, gap)),
|
|
77
|
+
hasAnyGaps: allShifts.length > 0,
|
|
78
|
+
canCloseAllGaps:
|
|
79
|
+
allShifts.length > 0 && canShiftTrackGapClips(gapMenuLaneElements, allShifts),
|
|
80
|
+
};
|
|
81
|
+
}, [gapContextMenu, gapMenuLaneElements]);
|
|
82
|
+
|
|
83
|
+
// The strips to paint while a menu row is hovered: the one gap under the
|
|
84
|
+
// pointer for "Close gap", every current gap (leading included) for
|
|
85
|
+
// "Close all gaps". Null when nothing is hovered / nothing would close.
|
|
86
|
+
const gapHighlight = useMemo<TrackGapHighlight | null>(() => {
|
|
87
|
+
if (!gapContextMenu || !gapMenuLaneElements || !hoveredGapAction) return null;
|
|
88
|
+
const floor = laneGapFloor(gapMenuLaneElements);
|
|
89
|
+
if (hoveredGapAction === "close-gap") {
|
|
90
|
+
const gap = resolveTrackGapAt(gapMenuLaneElements, gapContextMenu.time, undefined, floor);
|
|
91
|
+
if (!gap) return null;
|
|
92
|
+
return {
|
|
93
|
+
track: gapContextMenu.track,
|
|
94
|
+
intervals: [{ start: gap.gapStart, end: gap.gapEnd }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const intervals = resolveAllGapIntervals(gapMenuLaneElements, undefined, floor);
|
|
98
|
+
return intervals.length > 0 ? { track: gapContextMenu.track, intervals } : null;
|
|
99
|
+
}, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
|
|
100
|
+
|
|
101
|
+
const closeTrackGap = useCallback(() => {
|
|
102
|
+
if (!gapContextMenu || !gapMenuLaneElements) return;
|
|
103
|
+
commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
|
|
104
|
+
elements: expandedElementsRef.current,
|
|
105
|
+
trackOrder: trackOrderRef.current,
|
|
106
|
+
updateElement,
|
|
107
|
+
onMoveElement,
|
|
108
|
+
onMoveElements,
|
|
109
|
+
});
|
|
110
|
+
}, [
|
|
111
|
+
gapContextMenu,
|
|
112
|
+
gapMenuLaneElements,
|
|
113
|
+
expandedElementsRef,
|
|
114
|
+
trackOrderRef,
|
|
115
|
+
updateElement,
|
|
116
|
+
onMoveElement,
|
|
117
|
+
onMoveElements,
|
|
118
|
+
]);
|
|
119
|
+
const closeAllTrackGaps = useCallback(() => {
|
|
120
|
+
if (!gapMenuLaneElements) return;
|
|
121
|
+
commitCloseAllTrackGaps(gapMenuLaneElements, {
|
|
122
|
+
elements: expandedElementsRef.current,
|
|
123
|
+
trackOrder: trackOrderRef.current,
|
|
124
|
+
updateElement,
|
|
125
|
+
onMoveElement,
|
|
126
|
+
onMoveElements,
|
|
127
|
+
});
|
|
128
|
+
}, [
|
|
129
|
+
gapMenuLaneElements,
|
|
130
|
+
expandedElementsRef,
|
|
131
|
+
trackOrderRef,
|
|
132
|
+
updateElement,
|
|
133
|
+
onMoveElement,
|
|
134
|
+
onMoveElements,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
|
|
138
|
+
setHoveredGapAction(null);
|
|
139
|
+
setGapContextMenu(anchor);
|
|
140
|
+
}, []);
|
|
141
|
+
const dismissGapMenu = useCallback(() => {
|
|
142
|
+
setHoveredGapAction(null);
|
|
143
|
+
setGapContextMenu(null);
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
gapMenuModel,
|
|
148
|
+
gapHighlight,
|
|
149
|
+
setHoveredGapAction,
|
|
150
|
+
openGapMenu,
|
|
151
|
+
dismissGapMenu,
|
|
152
|
+
closeTrackGap,
|
|
153
|
+
closeAllTrackGaps,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -152,10 +152,34 @@ describe("buildExpandedElements", () => {
|
|
|
152
152
|
["pill-3", "group-1"],
|
|
153
153
|
]);
|
|
154
154
|
const domClipChildren = [
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
{
|
|
156
|
+
id: "group-1",
|
|
157
|
+
parentId: "scene-host",
|
|
158
|
+
hostId: "scene-host",
|
|
159
|
+
label: "Group 1",
|
|
160
|
+
stackingContextId: "css:0",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "pill-1",
|
|
164
|
+
parentId: "group-1",
|
|
165
|
+
hostId: "scene-host",
|
|
166
|
+
label: "pill-1",
|
|
167
|
+
stackingContextId: "css:0.0",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "pill-2",
|
|
171
|
+
parentId: "group-1",
|
|
172
|
+
hostId: "scene-host",
|
|
173
|
+
label: "pill-2",
|
|
174
|
+
stackingContextId: "css:0.1",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "pill-3",
|
|
178
|
+
parentId: "group-1",
|
|
179
|
+
hostId: "scene-host",
|
|
180
|
+
label: "pill-3",
|
|
181
|
+
stackingContextId: "css:0.1",
|
|
182
|
+
},
|
|
159
183
|
];
|
|
160
184
|
|
|
161
185
|
// Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
|
|
@@ -173,6 +197,7 @@ describe("buildExpandedElements", () => {
|
|
|
173
197
|
expect(pills[0]!.start).toBe(5);
|
|
174
198
|
expect(pills[0]!.duration).toBe(6);
|
|
175
199
|
expect(pills[0]!.sourceFile).toBe("scene.html");
|
|
200
|
+
expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
|
|
176
201
|
// The host row is replaced by its children.
|
|
177
202
|
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
|
178
203
|
});
|
|
@@ -273,3 +298,41 @@ describe("resolveTimelineExpansionRawId", () => {
|
|
|
273
298
|
).toBe("caption");
|
|
274
299
|
});
|
|
275
300
|
});
|
|
301
|
+
|
|
302
|
+
describe("buildExpandedElements — collision-free synthetic rows (cross-file lane safety)", () => {
|
|
303
|
+
it("expanded children NEVER share a display track with an unrelated top-level clip", () => {
|
|
304
|
+
// The Deepwork regression: host on track 0 with two children used to put
|
|
305
|
+
// child #2 on integer track 1 — the same lane as index.html#foreign. Lane
|
|
306
|
+
// grouping merges purely by track number, so a gap-close on that "one"
|
|
307
|
+
// lane batch-persisted a foreign file's clip.
|
|
308
|
+
const elements = [
|
|
309
|
+
el({ id: "host", start: 0, duration: 20, track: 0, compositionSrc: "scene.html" }),
|
|
310
|
+
el({ id: "foreign", start: 20, duration: 5, track: 1 }),
|
|
311
|
+
];
|
|
312
|
+
const manifest = [
|
|
313
|
+
clip({ id: "host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
314
|
+
clip({ id: "c1", start: 0, duration: 5 }),
|
|
315
|
+
clip({ id: "c2", start: 10, duration: 5 }),
|
|
316
|
+
];
|
|
317
|
+
const parentMap = new Map([
|
|
318
|
+
["c1", "host"],
|
|
319
|
+
["c2", "host"],
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "host", "host");
|
|
323
|
+
const foreign = out.find((e) => e.id === "foreign")!;
|
|
324
|
+
const children = out.filter((e) => e.domId === "c1" || e.domId === "c2");
|
|
325
|
+
expect(children).toHaveLength(2);
|
|
326
|
+
for (const child of children) {
|
|
327
|
+
// No lane sharing with the foreign clip…
|
|
328
|
+
expect(child.track).not.toBe(foreign.track);
|
|
329
|
+
// …and structurally impossible to collide with ANY normalized (integer)
|
|
330
|
+
// lane: synthetic rows are strict fractions under the host's lane.
|
|
331
|
+
expect(Number.isInteger(child.track)).toBe(false);
|
|
332
|
+
expect(child.track).toBeGreaterThan(0);
|
|
333
|
+
expect(child.track).toBeLessThan(1);
|
|
334
|
+
}
|
|
335
|
+
// Distinct ordered rows per child.
|
|
336
|
+
expect(children[0].track).not.toBe(children[1].track);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -163,7 +163,24 @@ function buildChildElements(
|
|
|
163
163
|
key,
|
|
164
164
|
start: clamped.start,
|
|
165
165
|
duration: clamped.duration,
|
|
166
|
-
track
|
|
166
|
+
// `track` becomes a synthetic display row under the expanded host, but the
|
|
167
|
+
// factory-set `authoredTrack` (the child's data-track-index in ITS OWN
|
|
168
|
+
// file's coordinate space) and the runtime-computed `stackingContextId`
|
|
169
|
+
// must survive verbatim — lane persists and z-sync read them, they are
|
|
170
|
+
// never reconstructed from display lanes.
|
|
171
|
+
//
|
|
172
|
+
// COLLISION-FREE synthetic rows: the old `display.track + index` scheme
|
|
173
|
+
// could equal a REAL clip's integer lane (host on track 0 with two
|
|
174
|
+
// children puts child #2 on track 1 — where an unrelated top-level clip
|
|
175
|
+
// may live). Lane grouping merges purely by track number, so that
|
|
176
|
+
// collision fused clips from DIFFERENT source files into one display
|
|
177
|
+
// lane, and lane-scoped actions (gap close) then batch-persisted foreign
|
|
178
|
+
// clips. Fractions strictly between the host's lane and the next integer
|
|
179
|
+
// can never equal a normalized (integer) lane, while still rendering the
|
|
180
|
+
// children as their own ordered rows directly under the host.
|
|
181
|
+
track: display.track + (result.length + 1) / (siblings.length + 2),
|
|
182
|
+
authoredTrack: base.authoredTrack,
|
|
183
|
+
stackingContextId: base.stackingContextId,
|
|
167
184
|
expandedParentStart: editBasis.start,
|
|
168
185
|
domId,
|
|
169
186
|
selector,
|
|
@@ -198,6 +215,7 @@ function domSiblingClips(
|
|
|
198
215
|
parentCompositionId: host.id ?? null,
|
|
199
216
|
compositionSrc: host.compositionSrc ?? null,
|
|
200
217
|
assetUrl: null,
|
|
218
|
+
stackingContextId: c.stackingContextId,
|
|
201
219
|
}),
|
|
202
220
|
);
|
|
203
221
|
}
|
|
@@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => {
|
|
|
153
153
|
unmountWithAct(root);
|
|
154
154
|
unsubscribe();
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
it("does not settle from an unsupported runtime protocol message", () => {
|
|
158
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
159
|
+
const iframe = document.createElement("iframe");
|
|
160
|
+
const iframeWindow = {
|
|
161
|
+
postMessage: vi.fn(),
|
|
162
|
+
scrollTo: vi.fn(),
|
|
163
|
+
addEventListener: vi.fn(),
|
|
164
|
+
removeEventListener: vi.fn(),
|
|
165
|
+
} as Record<string, unknown>;
|
|
166
|
+
Object.defineProperty(iframe, "contentWindow", {
|
|
167
|
+
value: iframeWindow,
|
|
168
|
+
configurable: true,
|
|
169
|
+
});
|
|
170
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
171
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
172
|
+
configurable: true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
api.iframeRef.current = iframe;
|
|
177
|
+
api.onIframeLoad();
|
|
178
|
+
});
|
|
179
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
180
|
+
|
|
181
|
+
iframeWindow.__player = {
|
|
182
|
+
play: vi.fn(),
|
|
183
|
+
pause: vi.fn(),
|
|
184
|
+
seek: vi.fn(),
|
|
185
|
+
getTime: () => 0,
|
|
186
|
+
getDuration: () => 30,
|
|
187
|
+
isPlaying: () => false,
|
|
188
|
+
};
|
|
189
|
+
act(() => {
|
|
190
|
+
window.dispatchEvent(
|
|
191
|
+
new MessageEvent("message", {
|
|
192
|
+
source: iframeWindow as unknown as Window,
|
|
193
|
+
data: { source: "hf-preview", type: "state", protocolVersion: 999 },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
198
|
+
|
|
199
|
+
act(() => {
|
|
200
|
+
window.dispatchEvent(
|
|
201
|
+
new MessageEvent("message", {
|
|
202
|
+
source: iframeWindow as unknown as Window,
|
|
203
|
+
data: { source: "hf-preview", type: "state" },
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
208
|
+
|
|
209
|
+
unmountWithAct(root);
|
|
210
|
+
});
|
|
156
211
|
});
|
|
157
212
|
|
|
158
213
|
describe("useTimelinePlayer audio controls (#835)", () => {
|
|
@@ -437,12 +437,19 @@ describe("anonymous timeline identity", () => {
|
|
|
437
437
|
});
|
|
438
438
|
|
|
439
439
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
440
|
-
it("preserves missing
|
|
440
|
+
it("preserves missing sub-composition elements when a shorter manifest arrives", () => {
|
|
441
441
|
expect(
|
|
442
442
|
mergeTimelineElementsPreservingDowngrades(
|
|
443
443
|
[
|
|
444
444
|
{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
|
|
445
|
-
{
|
|
445
|
+
{
|
|
446
|
+
id: "cta",
|
|
447
|
+
tag: "div",
|
|
448
|
+
start: 4,
|
|
449
|
+
duration: 2,
|
|
450
|
+
track: 1,
|
|
451
|
+
compositionSrc: "scenes/cta.html",
|
|
452
|
+
},
|
|
446
453
|
],
|
|
447
454
|
[{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
448
455
|
8,
|
|
@@ -450,10 +457,31 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
450
457
|
),
|
|
451
458
|
).toEqual([
|
|
452
459
|
{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
|
|
453
|
-
{
|
|
460
|
+
{
|
|
461
|
+
id: "cta",
|
|
462
|
+
tag: "div",
|
|
463
|
+
start: 4,
|
|
464
|
+
duration: 2,
|
|
465
|
+
track: 1,
|
|
466
|
+
compositionSrc: "scenes/cta.html",
|
|
467
|
+
},
|
|
454
468
|
]);
|
|
455
469
|
});
|
|
456
470
|
|
|
471
|
+
it("drops missing top-level elements so undo does not leave ghost clips", () => {
|
|
472
|
+
expect(
|
|
473
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
474
|
+
[
|
|
475
|
+
{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
|
|
476
|
+
{ id: "split-clone", tag: "div", start: 4, duration: 2, track: 1 },
|
|
477
|
+
],
|
|
478
|
+
[{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
479
|
+
8,
|
|
480
|
+
8,
|
|
481
|
+
),
|
|
482
|
+
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
483
|
+
});
|
|
484
|
+
|
|
457
485
|
it("accepts longer-duration or same-size updates as authoritative", () => {
|
|
458
486
|
expect(
|
|
459
487
|
mergeTimelineElementsPreservingDowngrades(
|
|
@@ -477,6 +505,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
477
505
|
start: 0,
|
|
478
506
|
duration: 3,
|
|
479
507
|
track: 0,
|
|
508
|
+
compositionSrc: "scenes/cards.html",
|
|
480
509
|
},
|
|
481
510
|
{
|
|
482
511
|
id: "Card",
|
|
@@ -486,6 +515,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
486
515
|
start: 3,
|
|
487
516
|
duration: 3,
|
|
488
517
|
track: 1,
|
|
518
|
+
compositionSrc: "scenes/cards.html",
|
|
489
519
|
},
|
|
490
520
|
],
|
|
491
521
|
[
|
|
@@ -497,6 +527,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
497
527
|
start: 0,
|
|
498
528
|
duration: 3,
|
|
499
529
|
track: 0,
|
|
530
|
+
compositionSrc: "scenes/cards.html",
|
|
500
531
|
},
|
|
501
532
|
],
|
|
502
533
|
8,
|
|
@@ -511,6 +542,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
511
542
|
start: 0,
|
|
512
543
|
duration: 3,
|
|
513
544
|
track: 0,
|
|
545
|
+
compositionSrc: "scenes/cards.html",
|
|
514
546
|
},
|
|
515
547
|
{
|
|
516
548
|
id: "Card",
|
|
@@ -520,6 +552,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
520
552
|
start: 3,
|
|
521
553
|
duration: 3,
|
|
522
554
|
track: 1,
|
|
555
|
+
compositionSrc: "scenes/cards.html",
|
|
523
556
|
},
|
|
524
557
|
]);
|
|
525
558
|
});
|