@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Drives Studio's snapping and marquee by package name with a host's own iframe and overlay.
|
|
3
|
+
import { act, type PointerEvent } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
collectSnapContext,
|
|
7
|
+
resolveSnapAdjustment,
|
|
8
|
+
SNAP_THRESHOLD_PX,
|
|
9
|
+
SnapGuideOverlay,
|
|
10
|
+
useMarqueeGestures,
|
|
11
|
+
usePlayerStore,
|
|
12
|
+
type MarqueeGestures,
|
|
13
|
+
type MarqueeGesturesDeps,
|
|
14
|
+
type MarqueeRect,
|
|
15
|
+
} from "@hyperframes/studio";
|
|
16
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
17
|
+
import {
|
|
18
|
+
collectDomEditLayerItems,
|
|
19
|
+
resolveDomEditSelection,
|
|
20
|
+
} from "./components/editor/domEditingLayers";
|
|
21
|
+
|
|
22
|
+
const rects = new Map<string, MarqueeRect>([
|
|
23
|
+
["a", { left: 0, top: 0, width: 10, height: 10 }],
|
|
24
|
+
["b", { left: 100, top: 100, width: 10, height: 10 }],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
vi.mock("./components/editor/domEditOverlayGeometry", async (importOriginal) => ({
|
|
28
|
+
...(await importOriginal<object>()),
|
|
29
|
+
isElementVisibleForOverlay: () => true,
|
|
30
|
+
toVisibleOverlayRect: (_overlay: unknown, _iframe: unknown, el: HTMLElement) => {
|
|
31
|
+
const rect = rects.get(el.id);
|
|
32
|
+
return rect ? { ...rect, editScaleX: 1, editScaleY: 1 } : null;
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("./components/editor/domEditingElement", async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<object>()),
|
|
37
|
+
isElementComputedVisible: () => true,
|
|
38
|
+
}));
|
|
39
|
+
vi.mock("./components/editor/domEditingLayers", async (importOriginal) => ({
|
|
40
|
+
...(await importOriginal<object>()),
|
|
41
|
+
collectDomEditLayerItems: vi.fn((root: HTMLElement) =>
|
|
42
|
+
Array.from(root.children).map((element) => ({ element })),
|
|
43
|
+
),
|
|
44
|
+
resolveDomEditSelection: vi.fn(async (element: HTMLElement) => ({ id: element.id })),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
installReactActEnvironment();
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
document.body.innerHTML = "";
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function MarqueeHost<T>(props: {
|
|
56
|
+
deps: MarqueeGesturesDeps<T>;
|
|
57
|
+
onState: (state: MarqueeGestures) => void;
|
|
58
|
+
}) {
|
|
59
|
+
props.onState(useMarqueeGestures(props.deps as unknown as MarqueeGesturesDeps));
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mountMarquee<T>(extra: Partial<MarqueeGesturesDeps<T>> = {}) {
|
|
64
|
+
document.body.innerHTML = `<div data-composition-id="main" data-width="1920" data-height="1080">
|
|
65
|
+
<div id="a"></div><div id="b"></div></div>`;
|
|
66
|
+
const overlay = document.createElement("div");
|
|
67
|
+
overlay.setPointerCapture = vi.fn();
|
|
68
|
+
overlay.releasePointerCapture = vi.fn();
|
|
69
|
+
document.body.append(overlay);
|
|
70
|
+
const onSelect = vi.fn();
|
|
71
|
+
const deps: MarqueeGesturesDeps<T> = {
|
|
72
|
+
iframeRef: { current: { contentDocument: document } as unknown as HTMLIFrameElement },
|
|
73
|
+
overlayRef: { current: overlay },
|
|
74
|
+
activeCompositionPathRef: { current: "index.html" },
|
|
75
|
+
onMarqueeSelectRef: { current: onSelect },
|
|
76
|
+
...extra,
|
|
77
|
+
};
|
|
78
|
+
let state: MarqueeGestures | undefined;
|
|
79
|
+
const root = mountReactHarness(<MarqueeHost deps={deps} onState={(next) => (state = next)} />);
|
|
80
|
+
const at = (x: number, y: number, shiftKey = false) =>
|
|
81
|
+
({
|
|
82
|
+
clientX: x,
|
|
83
|
+
clientY: y,
|
|
84
|
+
pointerId: 7,
|
|
85
|
+
shiftKey,
|
|
86
|
+
currentTarget: overlay,
|
|
87
|
+
}) as unknown as PointerEvent<HTMLDivElement>;
|
|
88
|
+
const marquee = () => state as MarqueeGestures;
|
|
89
|
+
return { overlay, onSelect, root, at, marquee };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("marquee package export, driven by a host", () => {
|
|
93
|
+
const byId = { resolveHits: (elements: HTMLElement[]) => elements.map((el) => el.id) };
|
|
94
|
+
|
|
95
|
+
it("hands the elements a drag touched to the host's resolveHits and selects what it returns", async () => {
|
|
96
|
+
const { overlay, onSelect, at, marquee } = mountMarquee<string>(byId);
|
|
97
|
+
act(() => marquee().begin(at(1, 1)));
|
|
98
|
+
expect(overlay.setPointerCapture).toHaveBeenCalledWith(7);
|
|
99
|
+
act(() => marquee().onPointerMove(at(20, 20)));
|
|
100
|
+
expect(marquee().marqueeRect).toEqual({ left: 1, top: 1, width: 19, height: 19 });
|
|
101
|
+
expect(marquee().candidateRects).toEqual([rects.get("a")]);
|
|
102
|
+
await act(async () => marquee().onPointerUp(at(20, 20, true)));
|
|
103
|
+
expect(onSelect).toHaveBeenCalledWith(["a"], true);
|
|
104
|
+
expect(overlay.releasePointerCapture).toHaveBeenCalledWith(7);
|
|
105
|
+
expect(marquee().marqueeRect).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("measures the candidates once per drag", async () => {
|
|
109
|
+
const { at, marquee } = mountMarquee<string>(byId);
|
|
110
|
+
act(() => marquee().begin(at(1, 1)));
|
|
111
|
+
act(() => marquee().onPointerMove(at(20, 20)));
|
|
112
|
+
act(() => marquee().onPointerMove(at(120, 120)));
|
|
113
|
+
await act(async () => marquee().onPointerUp(at(120, 120)));
|
|
114
|
+
expect(collectDomEditLayerItems).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("selects nothing on a release under the threshold, as a click", async () => {
|
|
118
|
+
const resolveHits = vi.fn(byId.resolveHits);
|
|
119
|
+
const { onSelect, at, marquee } = mountMarquee<string>({ resolveHits });
|
|
120
|
+
act(() => marquee().begin(at(1, 1)));
|
|
121
|
+
await act(async () => marquee().onPointerUp(at(2, 2)));
|
|
122
|
+
expect(onSelect).toHaveBeenCalledWith([], false);
|
|
123
|
+
expect(resolveHits).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("cancel() drops an active marquee and the release that follows selects nothing", async () => {
|
|
127
|
+
const { overlay, onSelect, at, marquee } = mountMarquee<string>(byId);
|
|
128
|
+
act(() => marquee().begin(at(1, 1)));
|
|
129
|
+
act(() => marquee().onPointerMove(at(20, 20)));
|
|
130
|
+
act(() => marquee().cancel());
|
|
131
|
+
expect(marquee().marqueeRect).toBeNull();
|
|
132
|
+
expect(marquee().candidateRects).toEqual([]);
|
|
133
|
+
expect(overlay.releasePointerCapture).toHaveBeenCalledWith(7);
|
|
134
|
+
await act(async () => marquee().onPointerUp(at(20, 20)));
|
|
135
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a pointercancel mid-drag drops the marquee without selecting", async () => {
|
|
139
|
+
const { onSelect, at, marquee } = mountMarquee<string>(byId);
|
|
140
|
+
act(() => marquee().begin(at(1, 1)));
|
|
141
|
+
act(() => marquee().onPointerMove(at(20, 20)));
|
|
142
|
+
act(() => marquee().onPointerCancel());
|
|
143
|
+
expect(marquee().marqueeRect).toBeNull();
|
|
144
|
+
await act(async () => marquee().onPointerUp(at(20, 20)));
|
|
145
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("will not hand a host Studio's selections under the host's own pick type", () => {
|
|
149
|
+
const hostDeps: MarqueeGesturesDeps<string> = {
|
|
150
|
+
iframeRef: { current: null },
|
|
151
|
+
overlayRef: { current: null },
|
|
152
|
+
activeCompositionPathRef: { current: null },
|
|
153
|
+
onMarqueeSelectRef: { current: undefined },
|
|
154
|
+
};
|
|
155
|
+
// @ts-expect-error a host pick type needs its own resolveHits
|
|
156
|
+
const typed = () => useMarqueeGestures<string>(hostDeps);
|
|
157
|
+
expect(typed).toBeTypeOf("function");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("resolves Studio edit selections when the host passes no resolveHits", async () => {
|
|
161
|
+
const { onSelect, at, marquee } = mountMarquee();
|
|
162
|
+
act(() => marquee().begin(at(1, 1)));
|
|
163
|
+
act(() => marquee().onPointerMove(at(20, 20)));
|
|
164
|
+
await act(async () => marquee().onPointerUp(at(20, 20)));
|
|
165
|
+
expect(resolveDomEditSelection).toHaveBeenCalledWith(
|
|
166
|
+
document.getElementById("a"),
|
|
167
|
+
expect.objectContaining({ skipSourceProbe: true }),
|
|
168
|
+
);
|
|
169
|
+
expect(onSelect).toHaveBeenCalledWith([{ id: "a" }], false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("passes pointer events that are not a marquee to Studio's gestures, and needs none", () => {
|
|
173
|
+
const gestures = { onPointerMove: vi.fn(), onPointerUp: vi.fn(), clearPointerState: vi.fn() };
|
|
174
|
+
const selectionRef = { current: null };
|
|
175
|
+
const withGestures = mountMarquee<string>({ ...byId, gestures, selectionRef });
|
|
176
|
+
act(() => withGestures.marquee().onPointerMove(withGestures.at(5, 5)));
|
|
177
|
+
act(() => withGestures.marquee().onPointerUp(withGestures.at(5, 5)));
|
|
178
|
+
act(() => withGestures.marquee().onPointerCancel());
|
|
179
|
+
expect(gestures.onPointerMove).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(gestures.onPointerUp).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(gestures.clearPointerState).toHaveBeenCalledWith(selectionRef);
|
|
182
|
+
|
|
183
|
+
const bare = mountMarquee<string>(byId);
|
|
184
|
+
expect(() => {
|
|
185
|
+
bare.marquee().onPointerMove(bare.at(5, 5));
|
|
186
|
+
bare.marquee().onPointerUp(bare.at(5, 5));
|
|
187
|
+
bare.marquee().onPointerCancel();
|
|
188
|
+
}).not.toThrow();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("snapping package export, driven by a host", () => {
|
|
193
|
+
it("snaps a host's drag to another element and draws the guide", async () => {
|
|
194
|
+
document.body.innerHTML = `<div data-composition-id="main" data-width="1920" data-height="1080">
|
|
195
|
+
<div id="a"></div><div id="b"></div></div>`;
|
|
196
|
+
const overlay = document.createElement("div");
|
|
197
|
+
const iframe = {
|
|
198
|
+
contentDocument: document,
|
|
199
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1920, height: 1080 }),
|
|
200
|
+
} as unknown as HTMLIFrameElement;
|
|
201
|
+
const moving = document.getElementById("a") as HTMLElement;
|
|
202
|
+
const context = collectSnapContext({
|
|
203
|
+
overlayEl: overlay,
|
|
204
|
+
iframe,
|
|
205
|
+
excludeElements: new Set([moving]),
|
|
206
|
+
});
|
|
207
|
+
const snap = resolveSnapAdjustment({
|
|
208
|
+
movingRect: rects.get("a")!,
|
|
209
|
+
proposedDx: 98,
|
|
210
|
+
proposedDy: 0,
|
|
211
|
+
targets: context.targets,
|
|
212
|
+
threshold: SNAP_THRESHOLD_PX,
|
|
213
|
+
disabled: !context.snapEnabled,
|
|
214
|
+
});
|
|
215
|
+
expect(snap.dx).toBe(100);
|
|
216
|
+
expect(snap.guides.length).toBeGreaterThan(0);
|
|
217
|
+
|
|
218
|
+
// The guides draw once the host's preview has booted, as NLEPreview's player marks it.
|
|
219
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
220
|
+
const snapGuidesRef = { current: { guides: snap.guides, spacingGuides: snap.spacingGuides } };
|
|
221
|
+
const root = mountReactHarness(
|
|
222
|
+
<SnapGuideOverlay
|
|
223
|
+
snapGuidesRef={snapGuidesRef}
|
|
224
|
+
compositionLeft={0}
|
|
225
|
+
compositionTop={0}
|
|
226
|
+
compositionWidth={1920}
|
|
227
|
+
compositionHeight={1080}
|
|
228
|
+
/>,
|
|
229
|
+
);
|
|
230
|
+
await vi.waitFor(() => {
|
|
231
|
+
const guide = document.querySelector<HTMLElement>('[aria-hidden="true"] > div');
|
|
232
|
+
expect(guide?.style.display).toBe("");
|
|
233
|
+
});
|
|
234
|
+
await act(async () => root.unmount());
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
6
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
7
|
+
import { buildTimelineMoveTimingPatch, persistTimelineEdit } from "./timelineEditingHelpers";
|
|
8
|
+
import { useDomEditCommits } from "./useDomEditCommits";
|
|
9
|
+
|
|
10
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
11
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
const SOURCE =
|
|
14
|
+
'<!doctype html><html><head></head><body><div data-hf-id="hf-card" style="color: red">Card</div>' +
|
|
15
|
+
'<div id="clip" class="clip" data-start="1" data-duration="5" data-track-index="0"></div></body></html>';
|
|
16
|
+
|
|
17
|
+
// The server patches whatever is on disk; the writer refuses a stale base like If-Match does.
|
|
18
|
+
// The first write can be held mid-flight.
|
|
19
|
+
function fakeProject() {
|
|
20
|
+
let disk = SOURCE;
|
|
21
|
+
const refused: string[] = [];
|
|
22
|
+
let writes = 0;
|
|
23
|
+
let release = () => {};
|
|
24
|
+
const firstWrite = new Promise<void>((resolve) => (release = resolve));
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
"fetch",
|
|
27
|
+
vi.fn(async (input: string, init?: RequestInit) => {
|
|
28
|
+
if (input.includes("/file-mutations/patch-element/")) {
|
|
29
|
+
const { operations } = JSON.parse(String(init?.body)) as {
|
|
30
|
+
operations: Array<{ property?: string; value?: string }>;
|
|
31
|
+
};
|
|
32
|
+
for (const op of operations) {
|
|
33
|
+
if (op.property === "color") disk = disk.replace(/color: \w+/, `color: ${op.value}`);
|
|
34
|
+
}
|
|
35
|
+
return Response.json({ ok: true, changed: true, matched: true, content: disk });
|
|
36
|
+
}
|
|
37
|
+
return Response.json({ content: disk });
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const writeProjectFile = async (path: string, content: string, expected?: string) => {
|
|
41
|
+
if (++writes === 1) await firstWrite;
|
|
42
|
+
if (expected !== undefined && expected !== disk && content !== disk) {
|
|
43
|
+
refused.push(path);
|
|
44
|
+
throw new Error(`409 conflict on ${path}`);
|
|
45
|
+
}
|
|
46
|
+
disk = content;
|
|
47
|
+
};
|
|
48
|
+
return { writeProjectFile, refused, release: () => release(), read: () => disk };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Project = ReturnType<typeof fakeProject>;
|
|
52
|
+
|
|
53
|
+
function mountCanvasCommits(project: Project) {
|
|
54
|
+
const iframe = document.createElement("iframe");
|
|
55
|
+
document.body.append(iframe);
|
|
56
|
+
iframe.contentDocument!.body.innerHTML =
|
|
57
|
+
'<div data-hf-id="hf-card" style="color: red">Card</div>';
|
|
58
|
+
const element = iframe.contentDocument!.querySelector<HTMLElement>('[data-hf-id="hf-card"]')!;
|
|
59
|
+
const selection = {
|
|
60
|
+
element,
|
|
61
|
+
label: "Card",
|
|
62
|
+
tagName: "div",
|
|
63
|
+
sourceFile: "index.html",
|
|
64
|
+
compositionPath: "index.html",
|
|
65
|
+
isCompositionHost: false,
|
|
66
|
+
isInsideLockedComposition: false,
|
|
67
|
+
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
68
|
+
textContent: "Card",
|
|
69
|
+
dataAttributes: {},
|
|
70
|
+
inlineStyles: { color: "red" },
|
|
71
|
+
computedStyles: {},
|
|
72
|
+
textFields: [],
|
|
73
|
+
capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true },
|
|
74
|
+
hfId: "hf-card",
|
|
75
|
+
selector: '[data-hf-id="hf-card"]',
|
|
76
|
+
selectorIndex: 0,
|
|
77
|
+
} as unknown as DomEditSelection;
|
|
78
|
+
const captured: { current: ReturnType<typeof useDomEditCommits> | null } = { current: null };
|
|
79
|
+
const toasts: string[] = [];
|
|
80
|
+
function Probe() {
|
|
81
|
+
captured.current = useDomEditCommits({
|
|
82
|
+
activeCompPath: "index.html",
|
|
83
|
+
previewIframeRef: { current: iframe },
|
|
84
|
+
showToast: (message) => toasts.push(message),
|
|
85
|
+
queueDomEditSave: (save) => save(),
|
|
86
|
+
writeProjectFile: project.writeProjectFile,
|
|
87
|
+
editHistory: { recordEdit: async () => {} },
|
|
88
|
+
fileTree: [],
|
|
89
|
+
importedFontAssetsRef: { current: [] },
|
|
90
|
+
projectId: "p1",
|
|
91
|
+
projectIdRef: { current: "p1" },
|
|
92
|
+
reloadPreview: () => {},
|
|
93
|
+
domEditSelection: selection,
|
|
94
|
+
applyDomSelection: () => {},
|
|
95
|
+
clearDomSelection: () => {},
|
|
96
|
+
refreshDomEditSelectionFromPreview: () => {},
|
|
97
|
+
buildDomSelectionFromTarget: async () => null,
|
|
98
|
+
readOnlyPreview: false,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const root = createRoot(document.createElement("div"));
|
|
103
|
+
act(() => root.render(createElement(Probe)));
|
|
104
|
+
return { hook: captured.current!, selection, toasts };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const selfText: DomEditTextField = {
|
|
108
|
+
key: "self",
|
|
109
|
+
label: "self",
|
|
110
|
+
value: "Card",
|
|
111
|
+
tagName: "div",
|
|
112
|
+
attributes: [],
|
|
113
|
+
inlineStyles: {},
|
|
114
|
+
computedStyles: {},
|
|
115
|
+
source: "self",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const moveClip = (project: Project) =>
|
|
119
|
+
persistTimelineEdit({
|
|
120
|
+
projectId: "p1",
|
|
121
|
+
activeCompPath: "index.html",
|
|
122
|
+
element: {
|
|
123
|
+
id: "clip",
|
|
124
|
+
key: "clip",
|
|
125
|
+
domId: "clip",
|
|
126
|
+
tag: "div",
|
|
127
|
+
start: 1,
|
|
128
|
+
duration: 5,
|
|
129
|
+
track: 0,
|
|
130
|
+
} as TimelineElement,
|
|
131
|
+
label: "Move clip",
|
|
132
|
+
buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, 3, 5),
|
|
133
|
+
writeProjectFile: project.writeProjectFile,
|
|
134
|
+
recordEdit: async () => {},
|
|
135
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Starts the second save while the first one's write is in flight; both must land unrefused.
|
|
139
|
+
async function expectBothLand(
|
|
140
|
+
project: Project,
|
|
141
|
+
first: () => Promise<unknown>,
|
|
142
|
+
second: () => Promise<unknown>,
|
|
143
|
+
) {
|
|
144
|
+
await act(async () => {
|
|
145
|
+
const saves = [first()];
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
147
|
+
saves.push(second());
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
149
|
+
project.release();
|
|
150
|
+
await Promise.allSettled(saves);
|
|
151
|
+
});
|
|
152
|
+
expect(project.refused).toEqual([]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
document.body.innerHTML = "";
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("canvas edit commits share the file queue", () => {
|
|
161
|
+
it("lands a second canvas commit started while the first one's font write is held", async () => {
|
|
162
|
+
const project = fakeProject();
|
|
163
|
+
const { hook, selection, toasts } = mountCanvasCommits(project);
|
|
164
|
+
const font = {
|
|
165
|
+
family: "Imported",
|
|
166
|
+
path: "fonts/Imported.woff2",
|
|
167
|
+
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
await expectBothLand(
|
|
171
|
+
project,
|
|
172
|
+
() => hook.commitDomTextFields(selection, [selfText], { importedFont: font }),
|
|
173
|
+
() => hook.handleDomStyleCommit("color", "green"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(toasts).toEqual([]);
|
|
177
|
+
expect(project.read()).toContain("@font-face");
|
|
178
|
+
expect(project.read()).toContain("color: green");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("lands a canvas commit started while a timeline save is writing", async () => {
|
|
182
|
+
const project = fakeProject();
|
|
183
|
+
const { hook, toasts } = mountCanvasCommits(project);
|
|
184
|
+
|
|
185
|
+
await expectBothLand(
|
|
186
|
+
project,
|
|
187
|
+
() => moveClip(project),
|
|
188
|
+
() => hook.handleDomStyleCommit("color", "green"),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(toasts).toEqual([]);
|
|
192
|
+
expect(project.read()).toContain('id="clip" class="clip" data-start="3"');
|
|
193
|
+
expect(project.read()).toContain("color: green");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
2
|
+
|
|
3
|
+
export class GsapPreviewConvergenceError extends Error {}
|
|
4
|
+
export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
|
5
|
+
|
|
6
|
+
/** Verify rollback ownership support before any GSAP mutation can land. */
|
|
7
|
+
export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
|
8
|
+
const response = await fetch(
|
|
9
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
|
10
|
+
);
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
|
|
13
|
+
}
|
|
14
|
+
const body = await response.json().catch(() => null);
|
|
15
|
+
if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
|
|
16
|
+
throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Atomically restore one GSAP mutation only while its exact output still owns
|
|
21
|
+
* the file. The server performs compare + write synchronously, eliminating the
|
|
22
|
+
* client GET→PUT window that could overwrite a successor edit. */
|
|
23
|
+
export async function rollbackOwnedMutation(
|
|
24
|
+
projectId: string,
|
|
25
|
+
targetPath: string,
|
|
26
|
+
expected: string,
|
|
27
|
+
restore: string,
|
|
28
|
+
): Promise<"restored" | "conflict"> {
|
|
29
|
+
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
30
|
+
throw new Error(`Unsafe path: ${targetPath}`);
|
|
31
|
+
}
|
|
32
|
+
const response = await fetch(
|
|
33
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
|
34
|
+
{
|
|
35
|
+
method: "POST",
|
|
36
|
+
// Deliberately unclaimed: a rollback runs because a mutation did not
|
|
37
|
+
// converge, so let the restored file reload the preview.
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ expected, restore }),
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to restore ${targetPath}`);
|
|
44
|
+
}
|
|
45
|
+
const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
|
|
46
|
+
if (result.restored === true && result.conflict === false) return "restored";
|
|
47
|
+
if (result.restored === false && result.conflict === true) return "conflict";
|
|
48
|
+
throw new Error(`Invalid restore response for ${targetPath}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server's GSAP-mutation response. `scriptText` is the rewritten root script for a soft
|
|
52
|
+
* reload, null when none came back: the caller then full-reloads when `mutated`, or rebinds
|
|
53
|
+
* the runtime timing in place when nothing was rewritten (see syncTimingEditPreview). */
|
|
54
|
+
export type GsapMutationStatus = {
|
|
55
|
+
mutated: boolean;
|
|
56
|
+
scriptText: string | null;
|
|
57
|
+
/** Atomic whole-file ownership pair returned by the mutation endpoint. */
|
|
58
|
+
before?: string;
|
|
59
|
+
after?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
67
|
+
if (
|
|
68
|
+
!isRecord(value) ||
|
|
69
|
+
typeof value.mutated !== "boolean" ||
|
|
70
|
+
typeof value.before !== "string" ||
|
|
71
|
+
typeof value.after !== "string" ||
|
|
72
|
+
value.mutated !== (value.before !== value.after) ||
|
|
73
|
+
("changed" in value && value.changed !== value.mutated)
|
|
74
|
+
) {
|
|
75
|
+
throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
mutated: value.mutated,
|
|
79
|
+
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
|
80
|
+
before: value.before,
|
|
81
|
+
after: value.after,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readMutationError(value: unknown, fallback: string): string {
|
|
86
|
+
if (isRecord(value) && typeof value.error === "string") return value.error;
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function postGsapMutation(
|
|
91
|
+
projectId: string,
|
|
92
|
+
filePath: string,
|
|
93
|
+
mutation: Record<string, unknown>,
|
|
94
|
+
fallback: string,
|
|
95
|
+
): Promise<GsapMutationStatus> {
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(
|
|
99
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
103
|
+
body: JSON.stringify(mutation),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const body: unknown = await response.json().catch(() => null);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
|
|
114
|
+
}
|
|
115
|
+
return readMutationStatus(body);
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
|
|
2
|
+
let resizeCallback: ResizeObserverCallback | null = null;
|
|
3
|
+
|
|
4
|
+
export function MockResizeObserver(callback: ResizeObserverCallback) {
|
|
5
|
+
resizeCallback = callback;
|
|
6
|
+
return { observe() {}, disconnect() {}, unobserve() {} };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function reportResize(width: number, height: number): void {
|
|
10
|
+
resizeCallback!(
|
|
11
|
+
[{ contentRect: { width, height } } as ResizeObserverEntry],
|
|
12
|
+
{} as ResizeObserver,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -14,9 +14,9 @@ import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
|
14
14
|
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
15
15
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
16
16
|
import {
|
|
17
|
-
applyPatchByTarget,
|
|
18
|
-
buildPatchTarget,
|
|
19
17
|
findTimelineElementInIframe,
|
|
18
|
+
operationChanges,
|
|
19
|
+
patchTimelineChangesInSource,
|
|
20
20
|
readFileContent,
|
|
21
21
|
type RecordEditInput,
|
|
22
22
|
} from "./timelineEditingHelpers";
|
|
@@ -197,52 +197,30 @@ export async function createAudioGroupAndAssignMembers({
|
|
|
197
197
|
property: HF_AUDIO_GROUP_ATTR,
|
|
198
198
|
value: groupId,
|
|
199
199
|
};
|
|
200
|
-
const
|
|
201
|
-
const
|
|
200
|
+
const groupPath = activeCompPath || "index.html";
|
|
201
|
+
const byPath = groupElementsByTargetPath(elements, activeCompPath);
|
|
202
|
+
const files: Record<string, (current: string) => string> = {};
|
|
203
|
+
for (const targetPath of new Set([...byPath.keys(), groupPath])) {
|
|
204
|
+
files[targetPath] = (current) => {
|
|
205
|
+
let patched = patchTimelineChangesInSource(
|
|
206
|
+
current,
|
|
207
|
+
targetPath,
|
|
208
|
+
operationChanges(byPath.get(targetPath) ?? [], groupOperation),
|
|
209
|
+
);
|
|
210
|
+
if (targetPath === groupPath) patched = insertGroupElement(patched, groupId, groupLabel);
|
|
211
|
+
if (patched !== current) pendingTimelineEditPathRef.current.add(targetPath);
|
|
212
|
+
return patched;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
202
215
|
|
|
203
216
|
try {
|
|
204
|
-
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
205
|
-
let patchedContent = await readFileContent(projectId, targetPath);
|
|
206
|
-
originalByPath.set(targetPath, patchedContent);
|
|
207
|
-
|
|
208
|
-
for (const element of fileElements) {
|
|
209
|
-
const patchTarget = buildPatchTarget(element);
|
|
210
|
-
if (!patchTarget) {
|
|
211
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
212
|
-
}
|
|
213
|
-
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
214
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
215
|
-
}
|
|
216
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
files[targetPath] = patchedContent;
|
|
220
|
-
pendingTimelineEditPathRef.current.add(targetPath);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const groupPath = activeCompPath || "index.html";
|
|
224
|
-
let groupContent = files[groupPath];
|
|
225
|
-
if (groupContent === undefined) {
|
|
226
|
-
groupContent = await readFileContent(projectId, groupPath);
|
|
227
|
-
originalByPath.set(groupPath, groupContent);
|
|
228
|
-
}
|
|
229
|
-
const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
|
|
230
|
-
if (withGroupElement !== groupContent) {
|
|
231
|
-
files[groupPath] = withGroupElement;
|
|
232
|
-
pendingTimelineEditPathRef.current.add(groupPath);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
217
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
236
218
|
projectId,
|
|
237
219
|
label: groupLabel
|
|
238
220
|
? `Group ${elements.length} clips as ${groupLabel}`
|
|
239
221
|
: `Group ${elements.length} voice clips`,
|
|
240
222
|
files,
|
|
241
|
-
readFile:
|
|
242
|
-
const original = originalByPath.get(path);
|
|
243
|
-
if (original !== undefined) return original;
|
|
244
|
-
return readFileContent(projectId, path);
|
|
245
|
-
},
|
|
223
|
+
readFile: (path) => readFileContent(projectId, path),
|
|
246
224
|
writeFile: writeProjectFile,
|
|
247
225
|
recordEdit,
|
|
248
226
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PersistTimelineBatchChange,
|
|
13
13
|
} from "./timelineEditingHelpers";
|
|
14
14
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
15
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
15
16
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
17
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
17
18
|
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
|
@@ -244,6 +245,26 @@ describe("buildTimelineResizeTimingPatch", () => {
|
|
|
244
245
|
});
|
|
245
246
|
});
|
|
246
247
|
|
|
248
|
+
describe("buildTimelineResizeTimingPatch — source in-point read as playback reads it", () => {
|
|
249
|
+
it.each([
|
|
250
|
+
['data-playback-start="-1" data-media-start="2"', 4.5, 1.5],
|
|
251
|
+
['data-playback-start="2"', 4.5, 1.5],
|
|
252
|
+
['data-media-start="junk"', 5.5, 0.5],
|
|
253
|
+
['data-media-start="1.5s"', 5.5, 0.5],
|
|
254
|
+
])("%s, head to %s", (inPoint, start, expected) => {
|
|
255
|
+
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" ${inPoint}></video></div>`;
|
|
256
|
+
const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
|
|
257
|
+
const host = document.createElement("div");
|
|
258
|
+
host.innerHTML = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
|
|
259
|
+
start,
|
|
260
|
+
duration: 8 - start,
|
|
261
|
+
playbackStart: undefined,
|
|
262
|
+
});
|
|
263
|
+
const video = host.querySelector("#a")!;
|
|
264
|
+
expect(readMediaOffsetSeconds((name) => video.getAttribute(name))).toBe(expected);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
247
268
|
describe("persistTimelineBatchEdit", () => {
|
|
248
269
|
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
|
|
249
270
|
|