@hyperframes/studio 0.8.81 → 0.8.82
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-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
- package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2414 -2172
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- 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/anchoredResizeReleaseShift.test.ts +2 -6
- 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/propertyPanelAudioFxGroup.test.tsx +71 -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/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- 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 +36 -37
- 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/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 +8 -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/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- 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/useTimelinePlayhead.ts +26 -8
- 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/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
|
@@ -20,7 +20,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
|
20
20
|
import { usePlayerStore } from "../../player";
|
|
21
21
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
22
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
23
|
-
import {
|
|
23
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
24
24
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
25
|
import {
|
|
26
26
|
METER_DB_MARKS,
|
|
@@ -107,7 +107,7 @@ function useVolumeHandlers(): {
|
|
|
107
107
|
|
|
108
108
|
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
|
|
109
109
|
|
|
110
|
-
function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
|
|
110
|
+
function readHook(iframe: HTMLIFrameElement | null | undefined): AudioMeterHook | null {
|
|
111
111
|
try {
|
|
112
112
|
return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
|
|
113
113
|
} catch {
|
|
@@ -123,9 +123,8 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
|
|
|
123
123
|
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark
|
|
127
|
-
*
|
|
128
|
-
* band instead of tinting a flat colour brighter. */
|
|
126
|
+
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark mask that covers the unlit top
|
|
127
|
+
* portion, so a loud peak lights the real red band instead of tinting a flat colour brighter. */
|
|
129
128
|
function paint(bars: StripBars | undefined, channels: Pair): void {
|
|
130
129
|
channels.forEach((ch, i) => {
|
|
131
130
|
bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
|
|
@@ -179,8 +178,13 @@ export function stepAndPaintStrips(
|
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
182
|
-
function useMeterLoop(
|
|
183
|
-
|
|
181
|
+
function useMeterLoop(
|
|
182
|
+
strips: Strip[],
|
|
183
|
+
bars: RefObject<Map<string | null, StripBars>>,
|
|
184
|
+
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
|
|
185
|
+
) {
|
|
186
|
+
const shell = useStudioShellContextOptional();
|
|
187
|
+
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
|
|
184
188
|
const stripsRef = useRef(strips);
|
|
185
189
|
stripsRef.current = strips;
|
|
186
190
|
useEffect(() => {
|
|
@@ -190,7 +194,7 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
|
|
|
190
194
|
const state = new Map<string | null, Pair>();
|
|
191
195
|
const tick = (now: number) => {
|
|
192
196
|
raf = requestAnimationFrame(tick);
|
|
193
|
-
active = followMeterHook(active, readHook(previewIframeRef
|
|
197
|
+
active = followMeterHook(active, readHook(previewIframeRef?.current));
|
|
194
198
|
const levels = active?.read();
|
|
195
199
|
const dt = now - last;
|
|
196
200
|
last = now;
|
|
@@ -395,14 +399,21 @@ function MeterStrip({
|
|
|
395
399
|
);
|
|
396
400
|
}
|
|
397
401
|
|
|
398
|
-
export
|
|
402
|
+
export interface AudioMeterStripProps {
|
|
403
|
+
/** Pass a stable ref (useRef): a new object each render restarts the meter loop. */
|
|
404
|
+
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const AudioMeterStrip = memo(function AudioMeterStrip({
|
|
408
|
+
previewIframeRef,
|
|
409
|
+
}: AudioMeterStripProps) {
|
|
399
410
|
const visible = useAudioMetersVisible((s) => s.visible);
|
|
400
411
|
const projectHasAudio = useProjectHasAudio();
|
|
401
412
|
if (!visible || !projectHasAudio) return null;
|
|
402
|
-
return <MeterStripBody />;
|
|
413
|
+
return <MeterStripBody previewIframeRef={previewIframeRef} />;
|
|
403
414
|
});
|
|
404
415
|
|
|
405
|
-
function MeterStripBody() {
|
|
416
|
+
function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
406
417
|
const strips = useStrips();
|
|
407
418
|
const bars = useRef(new Map<string | null, StripBars>());
|
|
408
419
|
const register = useRef((id: string | null, b: StripBars | null) => {
|
|
@@ -410,7 +421,7 @@ function MeterStripBody() {
|
|
|
410
421
|
else bars.current.delete(id);
|
|
411
422
|
}).current;
|
|
412
423
|
const { onLive, onCommit } = useVolumeHandlers();
|
|
413
|
-
useMeterLoop(strips, bars);
|
|
424
|
+
useMeterLoop(strips, bars, previewIframeRef);
|
|
414
425
|
return (
|
|
415
426
|
<div
|
|
416
427
|
data-testid="audio-meter-strip"
|
|
@@ -7,7 +7,7 @@ import { CompositionsTab } from "./sidebar/CompositionsTab";
|
|
|
7
7
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
8
|
|
|
9
9
|
vi.mock("../contexts/StudioContext", () => ({
|
|
10
|
-
|
|
10
|
+
useStudioShellContextOptional: () => ({
|
|
11
11
|
editHistory: { canUndo: false, canRedo: false },
|
|
12
12
|
handleUndo: vi.fn(),
|
|
13
13
|
handleRedo: vi.fn(),
|
|
@@ -40,31 +40,6 @@ function colorGradingScopePaths(
|
|
|
40
40
|
return scope === "source-file" ? [selectedSourceFile] : compositionPaths;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async function patchColorGradingScopeFiles(
|
|
44
|
-
paths: string[],
|
|
45
|
-
value: string | null,
|
|
46
|
-
readProjectFile: ProjectFileReader,
|
|
47
|
-
): Promise<{ files: Record<string, string>; changedElements: number }> {
|
|
48
|
-
const snapshots = await Promise.all(
|
|
49
|
-
Array.from(new Set(paths)).map(async (path) => ({
|
|
50
|
-
path,
|
|
51
|
-
before: await readProjectFile(path),
|
|
52
|
-
})),
|
|
53
|
-
);
|
|
54
|
-
const files: Record<string, string> = {};
|
|
55
|
-
let changedElements = 0;
|
|
56
|
-
|
|
57
|
-
for (const { path, before } of snapshots) {
|
|
58
|
-
const result = patchMediaColorGradingInHtml(before, value);
|
|
59
|
-
if (result.html !== before) {
|
|
60
|
-
files[path] = result.html;
|
|
61
|
-
changedElements += result.count;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return { files, changedElements };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
43
|
// fallow-ignore-next-line complexity
|
|
69
44
|
export async function applyColorGradingScopeUpdate({
|
|
70
45
|
scope,
|
|
@@ -88,24 +63,25 @@ export async function applyColorGradingScopeUpdate({
|
|
|
88
63
|
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
|
89
64
|
}
|
|
90
65
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
value
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
|
99
|
-
}
|
|
100
|
-
|
|
66
|
+
let changedElements = 0;
|
|
67
|
+
const patchGrading = (before: string) => {
|
|
68
|
+
const result = patchMediaColorGradingInHtml(before, value);
|
|
69
|
+
changedElements += result.count;
|
|
70
|
+
return result.html;
|
|
71
|
+
};
|
|
72
|
+
const paths = colorGradingScopePaths(scope, selectedSourceFile, compositionPaths);
|
|
101
73
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
102
74
|
projectId,
|
|
103
75
|
label: value ? "Apply color grading" : "Clear color grading",
|
|
104
|
-
files,
|
|
76
|
+
files: Object.fromEntries(paths.map((path) => [path, patchGrading])),
|
|
105
77
|
readFile: readProjectFile,
|
|
106
78
|
writeFile: writeProjectFile,
|
|
107
79
|
recordEdit,
|
|
108
80
|
});
|
|
81
|
+
if (changedPaths.length === 0) {
|
|
82
|
+
showToast("No color grading changed", "info");
|
|
83
|
+
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
|
84
|
+
}
|
|
109
85
|
reloadPreview();
|
|
110
86
|
showToast(
|
|
111
87
|
`${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
|
6
|
+
import { TimelineEditProvider, useTimelineEditContext } from "./TimelineEditContext";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function mountProvider() {
|
|
11
|
+
const seen: TimelineEditCallbacks[] = [];
|
|
12
|
+
function Consumer() {
|
|
13
|
+
seen.push(useTimelineEditContext());
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const root = createRoot(document.createElement("div"));
|
|
17
|
+
const render = (value: TimelineEditCallbacks) =>
|
|
18
|
+
act(() =>
|
|
19
|
+
root.render(
|
|
20
|
+
<TimelineEditProvider value={value}>
|
|
21
|
+
<Consumer />
|
|
22
|
+
</TimelineEditProvider>,
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
return { seen, render, unmount: () => act(() => root.unmount()) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("TimelineEditProvider", () => {
|
|
29
|
+
it("hands consumers the latest multi-clip resize callback", () => {
|
|
30
|
+
const { seen, render, unmount } = mountProvider();
|
|
31
|
+
const onMoveElement = vi.fn();
|
|
32
|
+
const staleResize = vi.fn();
|
|
33
|
+
const currentResize = vi.fn();
|
|
34
|
+
render({ onMoveElement, onResizeElements: staleResize });
|
|
35
|
+
render({ onMoveElement, onResizeElements: currentResize });
|
|
36
|
+
expect(seen.at(-1)!.onResizeElements).toBe(currentResize);
|
|
37
|
+
unmount();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps the same bag when the parent passes the same callbacks in a new object", () => {
|
|
41
|
+
const { seen, render, unmount } = mountProvider();
|
|
42
|
+
const onMoveElement = vi.fn();
|
|
43
|
+
const onResizeElements = vi.fn();
|
|
44
|
+
render({ onMoveElement, onResizeElements });
|
|
45
|
+
render({ onMoveElement, onResizeElements });
|
|
46
|
+
expect(seen.at(-1)).toBe(seen[0]);
|
|
47
|
+
unmount();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -25,6 +25,32 @@ export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
|
|
|
25
25
|
return useContext(TimelineEditContext);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
29
|
+
onMoveElement: true,
|
|
30
|
+
onMoveElements: true,
|
|
31
|
+
onResizeElement: true,
|
|
32
|
+
onResizeElements: true,
|
|
33
|
+
onToggleTrackHidden: true,
|
|
34
|
+
onSetAudioGroupAttributeLive: true,
|
|
35
|
+
onSetAudioGroupAttributeQuiet: true,
|
|
36
|
+
onRevertAudioGroupAttributeLive: true,
|
|
37
|
+
onGroupClips: true,
|
|
38
|
+
onSetElementAttributeLive: true,
|
|
39
|
+
onSetElementAttributeQuiet: true,
|
|
40
|
+
onRevertElementAttributeLive: true,
|
|
41
|
+
onBlockedEditAttempt: true,
|
|
42
|
+
onSplitElement: true,
|
|
43
|
+
onRazorSplit: true,
|
|
44
|
+
onRazorSplitAll: true,
|
|
45
|
+
onDeleteKeyframe: true,
|
|
46
|
+
onDeleteAllKeyframes: true,
|
|
47
|
+
onMoveKeyframeToPlayhead: true,
|
|
48
|
+
onMoveKeyframe: true,
|
|
49
|
+
onToggleKeyframeAtPlayhead: true,
|
|
50
|
+
onTogglePropertyGroupKeyframe: true,
|
|
51
|
+
};
|
|
52
|
+
const EDIT_CALLBACK_KEYS = Object.keys(EDIT_CALLBACK_KEY_SET) as (keyof TimelineEditCallbacks)[];
|
|
53
|
+
|
|
28
54
|
export function TimelineEditProvider({
|
|
29
55
|
value,
|
|
30
56
|
children,
|
|
@@ -37,29 +63,7 @@ export function TimelineEditProvider({
|
|
|
37
63
|
// Each callback is a stable reference from the parent — memoize the bag
|
|
38
64
|
// so consumers don't re-render when unrelated parent state changes.
|
|
39
65
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
-
[
|
|
41
|
-
value.onMoveElement,
|
|
42
|
-
value.onMoveElements,
|
|
43
|
-
value.onResizeElement,
|
|
44
|
-
value.onToggleTrackHidden,
|
|
45
|
-
value.onSetAudioGroupAttributeLive,
|
|
46
|
-
value.onSetAudioGroupAttributeQuiet,
|
|
47
|
-
value.onRevertAudioGroupAttributeLive,
|
|
48
|
-
value.onGroupClips,
|
|
49
|
-
value.onSetElementAttributeLive,
|
|
50
|
-
value.onSetElementAttributeQuiet,
|
|
51
|
-
value.onRevertElementAttributeLive,
|
|
52
|
-
value.onBlockedEditAttempt,
|
|
53
|
-
value.onSplitElement,
|
|
54
|
-
value.onRazorSplit,
|
|
55
|
-
value.onRazorSplitAll,
|
|
56
|
-
value.onDeleteKeyframe,
|
|
57
|
-
value.onDeleteAllKeyframes,
|
|
58
|
-
value.onMoveKeyframeToPlayhead,
|
|
59
|
-
value.onMoveKeyframe,
|
|
60
|
-
value.onToggleKeyframeAtPlayhead,
|
|
61
|
-
value.onTogglePropertyGroupKeyframe,
|
|
62
|
-
],
|
|
66
|
+
EDIT_CALLBACK_KEYS.map((key) => value[key]),
|
|
63
67
|
);
|
|
64
68
|
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
|
|
65
69
|
}
|
|
@@ -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
|
+
}
|