@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.
- package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2567 -2314
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -9
- package/src/components/StudioRightPanels.tsx +4 -48
- 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/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +16 -42
- 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/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- 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/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- 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
|
@@ -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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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 {
|
|
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
|
|
153
|
-
const
|
|
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:
|
|
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
|
+
}
|