@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.
Files changed (98) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
  4. package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2414 -2172
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  11. package/src/components/TimelineHistoryButtons.tsx +26 -14
  12. package/src/components/TimelineToolbar.test.tsx +9 -1
  13. package/src/components/TimelineToolbar.tsx +13 -5
  14. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  15. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  16. package/src/components/editor/manualEdits.test.ts +24 -0
  17. package/src/components/editor/manualEdits.ts +6 -2
  18. package/src/components/editor/manualEditsDom.ts +0 -65
  19. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  20. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  21. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  23. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  24. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  25. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  26. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  27. package/src/components/editor/useFxLevelling.ts +35 -10
  28. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  29. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  30. package/src/components/pointerTargetSize.test.tsx +1 -1
  31. package/src/components/studioColorGradingScope.ts +12 -36
  32. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  33. package/src/contexts/TimelineEditContext.tsx +27 -23
  34. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  35. package/src/hooks/gsapMutationClient.ts +116 -0
  36. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  37. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  38. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  39. package/src/hooks/timelineEditingHelpers.ts +66 -61
  40. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  41. package/src/hooks/timelineTimingSync.test.ts +12 -0
  42. package/src/hooks/timelineTimingSync.ts +31 -123
  43. package/src/hooks/timelineTrackVisibility.ts +15 -29
  44. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  45. package/src/hooks/useAskAgentModal.ts +3 -1
  46. package/src/hooks/useClipboard.source.test.ts +32 -0
  47. package/src/hooks/useClipboard.ts +21 -20
  48. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  49. package/src/hooks/useDomEditCommits.ts +88 -97
  50. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  51. package/src/hooks/useEditorSave.ts +1 -1
  52. package/src/hooks/useElementLifecycleOps.ts +36 -37
  53. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  54. package/src/hooks/useFileManager.ts +21 -12
  55. package/src/hooks/useGroupCommits.ts +23 -24
  56. package/src/hooks/useGsapAnimationOps.ts +26 -8
  57. package/src/hooks/useGsapScriptCommits.ts +1 -0
  58. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  59. package/src/hooks/useThumbnailStripSize.ts +31 -0
  60. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  61. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  62. package/src/hooks/useTimelineEditing.ts +3 -1
  63. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  64. package/src/index.ts +8 -0
  65. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  66. package/src/player/components/CompositionThumbnail.tsx +6 -21
  67. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  68. package/src/player/components/ImageThumbnail.tsx +4 -18
  69. package/src/player/components/PlayerControls.test.tsx +52 -0
  70. package/src/player/components/PlayerControls.tsx +2 -1
  71. package/src/player/components/Timeline.test.ts +28 -3
  72. package/src/player/components/TimelineCanvas.tsx +5 -3
  73. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  74. package/src/player/components/VideoThumbnail.tsx +7 -29
  75. package/src/player/components/thumbnailUtils.test.ts +16 -20
  76. package/src/player/components/thumbnailUtils.ts +6 -7
  77. package/src/player/components/timelineEditing.test.ts +55 -0
  78. package/src/player/components/timelineGroupEditing.ts +13 -6
  79. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  80. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  81. package/src/player/components/useTimelinePlayhead.ts +26 -8
  82. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  83. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  84. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  85. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  86. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  87. package/src/player/lib/playbackShortcuts.ts +18 -0
  88. package/src/player/lib/playbackTypes.ts +1 -0
  89. package/src/player/lib/timelineDOM.test.ts +92 -0
  90. package/src/player/lib/timelineDOM.ts +0 -2
  91. package/src/player/lib/timelineElementHelpers.ts +47 -10
  92. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  93. package/src/player/store/timelineElement.ts +2 -1
  94. package/src/timelineChromeExports.test.tsx +79 -0
  95. package/src/utils/authoredSource.ts +15 -0
  96. package/src/utils/blockInstaller.ts +32 -27
  97. package/src/utils/studioFileHistory.test.ts +14 -14
  98. 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 { useStudioShellContext } from "../../contexts/StudioContext";
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
- * mask that covers the unlit top portion, so a loud peak lights the real red
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(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
183
- const { previewIframeRef } = useStudioShellContext();
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.current));
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 const AudioMeterStrip = memo(function AudioMeterStrip() {
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
- useStudioShellContext: () => ({
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
- const { files, changedElements } = await patchColorGradingScopeFiles(
92
- colorGradingScopePaths(scope, selectedSourceFile, compositionPaths),
93
- value,
94
- readProjectFile,
95
- );
96
- if (Object.keys(files).length === 0) {
97
- showToast("No color grading changed", "info");
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
+ }