@hyperframes/studio 0.8.80 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.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 +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -0,0 +1,195 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ buildTimelineMoveTimingPatch,
5
+ buildTimelineResizeTimingPatch,
6
+ persistElementAttribute,
7
+ persistTimelineBatchEdit,
8
+ persistTimelineEdit,
9
+ } from "./timelineEditingHelpers";
10
+ import type { TimelineElement } from "../player/store/playerStore";
11
+ import { finishClipTimingFallback } from "./timelineTimingSync";
12
+ import { applyColorGradingScopeUpdate } from "../components/studioColorGradingScope";
13
+
14
+ const SOURCE =
15
+ '<div id="root"><video id="a" class="clip" data-start="1" data-duration="5" data-track-index="0"></video>' +
16
+ '<video id="b" class="clip" data-start="2" data-duration="5" data-track-index="1"></video>' +
17
+ '<script>gsap.to("#a", { x: 1 }, 1)</script></div>';
18
+
19
+ // Refuses a write whose base is stale, like the server's If-Match; one write can be held mid-flight.
20
+ // A GSAP shift lands 10 ms after it is posted and releases the held write.
21
+ function fakeProject() {
22
+ let file = SOURCE;
23
+ const refused: string[] = [];
24
+ let writes = 0;
25
+ let release = () => {};
26
+ const firstWrite = new Promise<void>((resolve) => (release = resolve));
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn(async (input: string) => {
30
+ if (input.includes("/gsap-mutation-capabilities")) {
31
+ return Response.json({ atomicOwnershipPairs: true });
32
+ }
33
+ if (input.includes("/gsap-mutations/")) {
34
+ await new Promise((resolve) => setTimeout(resolve, 10));
35
+ const before = file;
36
+ file = file.replace("{ x: 1 }, 1)", "{ x: 1 }, 3)");
37
+ release();
38
+ return Response.json({ mutated: true, before, after: file, scriptText: null });
39
+ }
40
+ return Response.json({ content: file });
41
+ }),
42
+ );
43
+ const writeProjectFile = async (path: string, content: string, expected?: string) => {
44
+ if (++writes === 1) await firstWrite;
45
+ if (expected !== undefined && expected !== file && content !== file) {
46
+ refused.push(path);
47
+ throw new Error(`409 conflict on ${path}`);
48
+ }
49
+ file = content;
50
+ };
51
+ return { writeProjectFile, refused, release: () => release(), read: () => file };
52
+ }
53
+
54
+ type Project = ReturnType<typeof fakeProject>;
55
+
56
+ function clip(id: string): TimelineElement {
57
+ return { id, key: id, domId: id, tag: "video", label: id, start: 0, duration: 5, track: 0 };
58
+ }
59
+
60
+ const shared = (project: Project) => ({
61
+ projectId: "p1",
62
+ activeCompPath: "index.html",
63
+ writeProjectFile: project.writeProjectFile,
64
+ recordEdit: async () => {},
65
+ pendingTimelineEditPathRef: { current: new Set<string>() },
66
+ });
67
+
68
+ const move = (project: Project, id: string, start: number) =>
69
+ persistTimelineEdit({
70
+ ...shared(project),
71
+ element: clip(id),
72
+ label: "Move clip",
73
+ buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, start, 5),
74
+ });
75
+
76
+ const trim = (project: Project, id: string, duration: number) =>
77
+ persistTimelineBatchEdit({
78
+ ...shared(project),
79
+ label: "Trim clip",
80
+ changes: [
81
+ {
82
+ element: clip(id),
83
+ buildPatches: (original, target) =>
84
+ buildTimelineResizeTimingPatch(original, target, clip(id), { start: 0, duration }),
85
+ },
86
+ ],
87
+ });
88
+
89
+ // Starts the second save while the first one's write is in flight; both must land unrefused.
90
+ async function expectBothLand(
91
+ project: Project,
92
+ first: () => Promise<unknown>,
93
+ second: () => Promise<unknown>,
94
+ ) {
95
+ const saves = [first()];
96
+ await new Promise((resolve) => setTimeout(resolve, 0));
97
+ saves.push(second());
98
+ await new Promise((resolve) => setTimeout(resolve, 0));
99
+ project.release();
100
+ const results = await Promise.allSettled(saves);
101
+ expect(project.refused).toEqual([]);
102
+ expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
103
+ }
104
+
105
+ afterEach(() => {
106
+ vi.unstubAllGlobals();
107
+ });
108
+
109
+ describe("two quick timeline saves on one file", () => {
110
+ it("lands both moves", async () => {
111
+ const project = fakeProject();
112
+ await expectBothLand(
113
+ project,
114
+ () => move(project, "a", 3),
115
+ () => move(project, "b", 4),
116
+ );
117
+
118
+ expect(project.read()).toContain('id="a" class="clip" data-start="3"');
119
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
120
+ });
121
+
122
+ it("lands both trims", async () => {
123
+ const project = fakeProject();
124
+ await expectBothLand(
125
+ project,
126
+ () => trim(project, "a", 3),
127
+ () => trim(project, "b", 2),
128
+ );
129
+
130
+ expect(project.read()).toMatch(/id="a"[^>]*data-duration="3"/);
131
+ expect(project.read()).toMatch(/id="b"[^>]*data-duration="2"/);
132
+ });
133
+
134
+ it("lands a move started while a volume save is writing", async () => {
135
+ const project = fakeProject();
136
+ const volume = () =>
137
+ persistElementAttribute({
138
+ ...shared(project),
139
+ targetPath: "index.html",
140
+ patchTarget: { id: "a" },
141
+ attr: "data-volume",
142
+ value: "0.5",
143
+ label: "Set volume",
144
+ patchLive: () => {},
145
+ onFileRead: () => {},
146
+ });
147
+ await expectBothLand(project, volume, () => move(project, "b", 4));
148
+
149
+ expect(project.read()).toContain('data-volume="0.5"');
150
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
151
+ });
152
+
153
+ it("keeps a move when a color grade starts while it is writing", async () => {
154
+ const project = fakeProject();
155
+ const grade = () =>
156
+ applyColorGradingScopeUpdate({
157
+ scope: "source-file",
158
+ value: "warm",
159
+ selectedSourceFile: "index.html",
160
+ fileTree: ["index.html"],
161
+ projectId: "p1",
162
+ waitForPendingDomEditSaves: async () => {},
163
+ readProjectFile: async () => project.read(),
164
+ writeProjectFile: project.writeProjectFile,
165
+ recordEdit: async () => {},
166
+ reloadPreview: () => {},
167
+ showToast: () => {},
168
+ });
169
+ await expectBothLand(project, () => move(project, "a", 3), grade);
170
+
171
+ expect(project.read()).toContain('id="a" class="clip" data-start="3"');
172
+ expect(project.read()).toContain('data-color-grading="warm"');
173
+ });
174
+
175
+ it("lands a move started while the previous move's GSAP rewrite is in flight", async () => {
176
+ const project = fakeProject();
177
+ const shiftA = finishClipTimingFallback({
178
+ iframe: null,
179
+ reloadPreview: () => {},
180
+ projectId: "p1",
181
+ targetPath: "index.html",
182
+ domId: "a",
183
+ label: "Move clip",
184
+ recordEdit: async () => {},
185
+ writeProjectFile: project.writeProjectFile,
186
+ edit: { kind: "shift", delta: 2 },
187
+ });
188
+ const results = await Promise.allSettled([shiftA, move(project, "b", 4)]);
189
+
190
+ expect(project.refused).toEqual([]);
191
+ expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
192
+ expect(project.read()).toContain('gsap.to("#a", { x: 1 }, 3)');
193
+ expect(project.read()).toContain('id="b" class="clip" data-start="4"');
194
+ });
195
+ });
@@ -11,6 +11,8 @@ import {
11
11
  shiftGsapPositions,
12
12
  } from "./timelineTimingSync";
13
13
 
14
+ const writeProjectFile = async () => {};
15
+
14
16
  afterEach(() => {
15
17
  usePlayerStore.getState().reset();
16
18
  vi.restoreAllMocks();
@@ -68,6 +70,7 @@ function clipFallbackInput(overrides: {
68
70
  domId: "clip",
69
71
  label: "Move timeline clip",
70
72
  recordEdit: overrides.recordEdit as never,
73
+ writeProjectFile,
71
74
  edit: { kind: "shift", delta: 1 } as const,
72
75
  };
73
76
  }
@@ -495,6 +498,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
495
498
  const element = { sourceFile: "index.html" } as TimelineElement;
496
499
 
497
500
  await finishGroupTimingGsapFallback({
501
+ writeProjectFile,
498
502
  projectId: "p1",
499
503
  iframe,
500
504
  reloadPreview,
@@ -522,6 +526,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
522
526
  const reloadPreview = vi.fn();
523
527
 
524
528
  await finishGroupTimingGsapFallback({
529
+ writeProjectFile,
525
530
  projectId: "p1",
526
531
  iframe,
527
532
  reloadPreview,
@@ -602,6 +607,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
602
607
  let clipIndex = 0;
603
608
 
604
609
  await finishGroupTimingGsapFallback({
610
+ writeProjectFile,
605
611
  projectId: "p1",
606
612
  iframe: buildLivePreviewIframe().iframe,
607
613
  reloadPreview: vi.fn(),
@@ -632,6 +638,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
632
638
  let clipIndex = 0;
633
639
 
634
640
  await finishGroupTimingGsapFallback({
641
+ writeProjectFile,
635
642
  projectId: "p1",
636
643
  iframe: buildLivePreviewIframe().iframe,
637
644
  reloadPreview: vi.fn(),
@@ -669,6 +676,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
669
676
 
670
677
  const reloadPreview = vi.fn();
671
678
  await finishGroupTimingGsapFallback({
679
+ writeProjectFile,
672
680
  projectId: "p1",
673
681
  iframe: buildLivePreviewIframe().iframe,
674
682
  reloadPreview,
@@ -698,6 +706,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
698
706
  let clipIndex = 0;
699
707
 
700
708
  await finishGroupTimingGsapFallback({
709
+ writeProjectFile,
701
710
  projectId: "p1",
702
711
  iframe: buildLivePreviewIframe().iframe,
703
712
  reloadPreview,
@@ -728,6 +737,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
728
737
  const recordEdit = vi.fn(async () => {});
729
738
 
730
739
  await finishGroupTimingGsapFallback({
740
+ writeProjectFile,
731
741
  projectId: "p1",
732
742
  iframe: buildLivePreviewIframe().iframe,
733
743
  reloadPreview: vi.fn(),
@@ -762,6 +772,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
762
772
 
763
773
  const reloadPreview = vi.fn();
764
774
  await finishGroupTimingGsapFallback({
775
+ writeProjectFile,
765
776
  projectId: "p1",
766
777
  iframe: buildLivePreviewIframe().iframe,
767
778
  reloadPreview,
@@ -797,6 +808,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
797
808
  const server = installOwnedFileServer(contents, { failReadAt: 1 });
798
809
 
799
810
  await finishGroupTimingGsapFallback({
811
+ writeProjectFile,
800
812
  projectId: "p1",
801
813
  iframe: buildLivePreviewIframe().iframe,
802
814
  reloadPreview: vi.fn(),
@@ -7,10 +7,18 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
7
7
  import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
- import { studioWriteHeaders } from "../utils/studioFileVersion";
11
-
12
- class GsapPreviewConvergenceError extends Error {}
13
- class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
10
+ import {
11
+ GsapOwnershipProtocolError,
12
+ GsapPreviewConvergenceError,
13
+ postGsapMutation,
14
+ requireGsapOwnershipProtocol,
15
+ rollbackOwnedMutation,
16
+ type GsapMutationStatus,
17
+ } from "./gsapMutationClient";
18
+ import {
19
+ serializeStudioFileMutations,
20
+ type StudioProjectFileWriter,
21
+ } from "../utils/studioFileMutationCoordinator";
14
22
 
15
23
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
16
24
  if (targetPath.includes("\0") || targetPath.includes("..")) {
@@ -29,51 +37,6 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
29
37
  return data.content;
30
38
  }
31
39
 
32
- /** Verify rollback ownership support before any GSAP mutation can land. */
33
- async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
34
- const response = await fetch(
35
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
36
- );
37
- if (!response.ok) {
38
- throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
39
- }
40
- const body = await response.json().catch(() => null);
41
- if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
42
- throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
43
- }
44
- }
45
-
46
- /** Atomically restore one GSAP mutation only while its exact output still owns
47
- * the file. The server performs compare + write synchronously, eliminating the
48
- * client GET→PUT window that could overwrite a successor edit. */
49
- async function rollbackOwnedMutation(
50
- projectId: string,
51
- targetPath: string,
52
- expected: string,
53
- restore: string,
54
- ): Promise<"restored" | "conflict"> {
55
- if (targetPath.includes("\0") || targetPath.includes("..")) {
56
- throw new Error(`Unsafe path: ${targetPath}`);
57
- }
58
- const response = await fetch(
59
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
60
- {
61
- method: "POST",
62
- // Deliberately unclaimed: a rollback runs because a mutation did not
63
- // converge, so let the restored file reload the preview.
64
- headers: { "Content-Type": "application/json" },
65
- body: JSON.stringify({ expected, restore }),
66
- },
67
- );
68
- if (!response.ok) {
69
- throw new Error(`Failed to restore ${targetPath}`);
70
- }
71
- const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
72
- if (result.restored === true && result.conflict === false) return "restored";
73
- if (result.restored === false && result.conflict === true) return "conflict";
74
- throw new Error(`Invalid restore response for ${targetPath}`);
75
- }
76
-
77
40
  /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
78
41
  function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
79
42
  try {
@@ -102,79 +65,6 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
102
65
  };
103
66
  }
104
67
 
105
- /**
106
- * The bits of the server GSAP-mutation response the timeline edit path needs.
107
- * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
108
- * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
109
- * when the endpoint didn't return one (older server, or a multi-script comp the
110
- * soft path can't scope) — the caller then full-reloads when `mutated`, or
111
- * rebinds the runtime timing in place when nothing was rewritten (see
112
- * syncTimingEditPreview).
113
- */
114
- export type GsapMutationStatus = {
115
- mutated: boolean;
116
- scriptText: string | null;
117
- /** Atomic whole-file ownership pair returned by the mutation endpoint. */
118
- before?: string;
119
- after?: string;
120
- };
121
-
122
- function isRecord(value: unknown): value is Record<string, unknown> {
123
- return typeof value === "object" && value !== null;
124
- }
125
-
126
- function readMutationStatus(value: unknown): GsapMutationStatus {
127
- if (
128
- !isRecord(value) ||
129
- typeof value.mutated !== "boolean" ||
130
- typeof value.before !== "string" ||
131
- typeof value.after !== "string" ||
132
- value.mutated !== (value.before !== value.after) ||
133
- ("changed" in value && value.changed !== value.mutated)
134
- ) {
135
- throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
136
- }
137
- return {
138
- mutated: value.mutated,
139
- scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
140
- before: value.before,
141
- after: value.after,
142
- };
143
- }
144
-
145
- function readMutationError(value: unknown, fallback: string): string {
146
- if (isRecord(value) && typeof value.error === "string") return value.error;
147
- return fallback;
148
- }
149
-
150
- async function postGsapMutation(
151
- projectId: string,
152
- filePath: string,
153
- mutation: Record<string, unknown>,
154
- fallback: string,
155
- ): Promise<GsapMutationStatus> {
156
- let response: Response;
157
- try {
158
- response = await fetch(
159
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
160
- {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
163
- body: JSON.stringify(mutation),
164
- },
165
- );
166
- } catch (error) {
167
- throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
168
- cause: error,
169
- });
170
- }
171
- const body: unknown = await response.json().catch(() => null);
172
- if (!response.ok) {
173
- throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
174
- }
175
- return readMutationStatus(body);
176
- }
177
-
178
68
  /** Re-derive live timing windows without re-executing composition scripts.
179
69
  * Works for zero-GSAP compositions; false asks the caller to full-reload. */
180
70
  function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
@@ -341,7 +231,7 @@ async function rollbackAfterFailure(
341
231
  // The ledger, reverse rollback, final ownership check, and history fold are one
342
232
  // transaction; extracting phases would obscure which function owns convergence.
343
233
  // fallow-ignore-next-line complexity
344
- async function foldGsapMutationIntoHistory(input: {
234
+ async function foldGsapMutationInQueue(input: {
345
235
  projectId: string;
346
236
  label: string;
347
237
  coalesceKey?: string;
@@ -411,6 +301,18 @@ async function foldGsapMutationIntoHistory(input: {
411
301
  return status;
412
302
  }
413
303
 
304
+ // The mutations, the ownership read and any rollback hold every touched file's queue.
305
+ function foldGsapMutationIntoHistory(
306
+ input: Parameters<typeof foldGsapMutationInQueue>[0] & {
307
+ writeFile: StudioProjectFileWriter;
308
+ paths: readonly string[];
309
+ },
310
+ ): Promise<GsapMutationStatus> {
311
+ return serializeStudioFileMutations(input.writeFile, input.paths, () =>
312
+ foldGsapMutationInQueue(input),
313
+ );
314
+ }
315
+
414
316
  /**
415
317
  * Shift all GSAP animation positions targeting a given element by a time delta.
416
318
  * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -491,6 +393,7 @@ export function finishClipTimingFallback(input: {
491
393
  label: string;
492
394
  coalesceKey?: string;
493
395
  recordEdit: (edit: RecordEditInput) => Promise<void>;
396
+ writeProjectFile: StudioProjectFileWriter;
494
397
  edit: SingleClipGsapEdit;
495
398
  }): Promise<void> {
496
399
  const { projectId, targetPath, domId, edit } = input;
@@ -519,6 +422,8 @@ export function finishClipTimingFallback(input: {
519
422
  timingChanged && domId && projectId
520
423
  ? () =>
521
424
  foldGsapMutationIntoHistory({
425
+ writeFile: input.writeProjectFile,
426
+ paths: [targetPath],
522
427
  projectId,
523
428
  label: input.label,
524
429
  coalesceKey: input.coalesceKey,
@@ -552,6 +457,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
552
457
  errorLabel: string;
553
458
  coalesceKey?: string;
554
459
  recordEdit: (edit: RecordEditInput) => Promise<void>;
460
+ writeProjectFile: StudioProjectFileWriter;
555
461
  activeCompPath: string | null;
556
462
  changes: readonly C[];
557
463
  resolveChangePath: (element: TimelineElement) => string;
@@ -568,6 +474,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
568
474
  reloadPreview: input.reloadPreview,
569
475
  gsapMutation: () =>
570
476
  foldGsapMutationIntoHistory({
477
+ writeFile: input.writeProjectFile,
478
+ paths: input.changes.map((change) => input.resolveChangePath(change.element)),
571
479
  projectId: input.projectId,
572
480
  label: input.label,
573
481
  coalesceKey: input.coalesceKey,
@@ -9,11 +9,11 @@ import {
9
9
  } from "../player/components/timelineTrackDisplay";
10
10
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
11
11
  import { isAudioTimelineElement } from "../utils/timelineInspector";
12
- import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
12
+ import type { PatchOperation } from "../utils/sourcePatcher";
13
13
  import {
14
- applyPatchByTarget,
15
- buildPatchTarget,
16
14
  findTimelineElementInIframe,
15
+ operationChanges,
16
+ patchTimelineChangesInSource,
17
17
  readFileContent,
18
18
  type RecordEditInput,
19
19
  } from "./timelineEditingHelpers";
@@ -149,38 +149,24 @@ async function setElementsHidden({
149
149
  property: "hidden",
150
150
  value: hidden ? "" : null,
151
151
  };
152
- const originalByPath = new Map<string, string>();
153
- const files: Record<string, string> = {};
154
-
155
- try {
156
- for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
157
- let patchedContent = await readFileContent(projectId, targetPath);
158
- originalByPath.set(targetPath, patchedContent);
159
-
160
- for (const element of fileElements) {
161
- const patchTarget = buildPatchTarget(element);
162
- if (!patchTarget) {
163
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
164
- }
165
- if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
166
- throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
167
- }
168
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
169
- }
170
-
171
- files[targetPath] = patchedContent;
152
+ const files: Record<string, (current: string) => string> = {};
153
+ for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
154
+ files[targetPath] = (current) => {
172
155
  pendingTimelineEditPathRef.current.add(targetPath);
173
- }
156
+ return patchTimelineChangesInSource(
157
+ current,
158
+ targetPath,
159
+ operationChanges(fileElements, hiddenOperation),
160
+ );
161
+ };
162
+ }
174
163
 
164
+ try {
175
165
  const changedPaths = await saveProjectFilesWithHistory({
176
166
  projectId,
177
167
  label,
178
168
  files,
179
- readFile: async (path) => {
180
- const original = originalByPath.get(path);
181
- if (original !== undefined) return original;
182
- return readFileContent(projectId, path);
183
- },
169
+ readFile: (path) => readFileContent(projectId, path),
184
170
  writeFile: writeProjectFile,
185
171
  recordEdit,
186
172
  });
@@ -0,0 +1,53 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, it, vi } from "vitest";
6
+ import { useApplyColorGradingScope } from "./useApplyColorGradingScope";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const readProjectFile = vi.fn(async (_path: string) => "<html><body></body></html>");
11
+
12
+ vi.mock("../contexts/StudioContext", () => ({
13
+ useStudioShellContext: () => ({
14
+ projectId: "demo",
15
+ activeCompPath: "index.html",
16
+ showToast: vi.fn(),
17
+ waitForPendingDomEditSaves: async () => {},
18
+ }),
19
+ }));
20
+ vi.mock("../contexts/DomEditContext", () => ({
21
+ useDomEditContext: () => ({ domEditSelection: null }),
22
+ }));
23
+ vi.mock("../contexts/FileManagerContext", () => ({
24
+ useFileManagerContext: () => ({
25
+ readProjectFile,
26
+ writeProjectFile: vi.fn(),
27
+ fileTree: ["index.html", "scene.html", ".hyperframes/preview/4884df6e.html"],
28
+ compositions: ["index.html", "scene.html"],
29
+ }),
30
+ }));
31
+
32
+ it("grades the project's compositions, not Studio's generated preview documents", async () => {
33
+ let apply: ReturnType<typeof useApplyColorGradingScope> | null = null;
34
+ function Probe() {
35
+ apply = useApplyColorGradingScope(
36
+ vi.fn(async () => {}),
37
+ vi.fn(),
38
+ );
39
+ return null;
40
+ }
41
+ const root = createRoot(document.createElement("div"));
42
+ act(() => root.render(<Probe />));
43
+
44
+ await act(async () => {
45
+ await apply!("project", null);
46
+ });
47
+
48
+ expect(readProjectFile.mock.calls.map(([path]) => path).sort()).toEqual([
49
+ "index.html",
50
+ "scene.html",
51
+ ]);
52
+ act(() => root.unmount());
53
+ });
@@ -0,0 +1,54 @@
1
+ import { useCallback } from "react";
2
+ import { useStudioShellContext } from "../contexts/StudioContext";
3
+ import { useDomEditContext } from "../contexts/DomEditContext";
4
+ import { useFileManagerContext } from "../contexts/FileManagerContext";
5
+ import {
6
+ applyColorGradingScopeUpdate,
7
+ EMPTY_COLOR_GRADING_SCOPE_RESULT,
8
+ type ColorGradingScope,
9
+ } from "../components/studioColorGradingScope";
10
+ import type { StudioRightPanelsProps } from "../components/StudioRightPanels.types";
11
+
12
+ export function useApplyColorGradingScope(
13
+ recordEdit: StudioRightPanelsProps["recordEdit"],
14
+ reloadPreview: StudioRightPanelsProps["reloadPreview"],
15
+ ) {
16
+ const { projectId, activeCompPath, showToast, waitForPendingDomEditSaves } =
17
+ useStudioShellContext();
18
+ const { domEditSelection } = useDomEditContext();
19
+ const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
20
+ return useCallback(
21
+ async (scope: ColorGradingScope, value: string | null) =>
22
+ applyColorGradingScopeUpdate({
23
+ scope,
24
+ value,
25
+ selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
26
+ compositionPaths: compositions,
27
+ projectId,
28
+ waitForPendingDomEditSaves,
29
+ readProjectFile,
30
+ writeProjectFile,
31
+ recordEdit,
32
+ reloadPreview,
33
+ showToast,
34
+ }).catch((error) => {
35
+ showToast(
36
+ `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
37
+ "error",
38
+ );
39
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
40
+ }),
41
+ [
42
+ activeCompPath,
43
+ compositions,
44
+ domEditSelection?.sourceFile,
45
+ projectId,
46
+ readProjectFile,
47
+ recordEdit,
48
+ reloadPreview,
49
+ showToast,
50
+ waitForPendingDomEditSaves,
51
+ writeProjectFile,
52
+ ],
53
+ );
54
+ }