@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 happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
6
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
7
|
+
import { buildTimelineMoveTimingPatch, persistTimelineEdit } from "./timelineEditingHelpers";
|
|
8
|
+
import { useDomEditCommits } from "./useDomEditCommits";
|
|
9
|
+
|
|
10
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
11
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
const SOURCE =
|
|
14
|
+
'<!doctype html><html><head></head><body><div data-hf-id="hf-card" style="color: red">Card</div>' +
|
|
15
|
+
'<div id="clip" class="clip" data-start="1" data-duration="5" data-track-index="0"></div></body></html>';
|
|
16
|
+
|
|
17
|
+
// The server patches whatever is on disk; the writer refuses a stale base like If-Match does.
|
|
18
|
+
// The first write can be held mid-flight.
|
|
19
|
+
function fakeProject() {
|
|
20
|
+
let disk = SOURCE;
|
|
21
|
+
const refused: string[] = [];
|
|
22
|
+
let writes = 0;
|
|
23
|
+
let release = () => {};
|
|
24
|
+
const firstWrite = new Promise<void>((resolve) => (release = resolve));
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
"fetch",
|
|
27
|
+
vi.fn(async (input: string, init?: RequestInit) => {
|
|
28
|
+
if (input.includes("/file-mutations/patch-element/")) {
|
|
29
|
+
const { operations } = JSON.parse(String(init?.body)) as {
|
|
30
|
+
operations: Array<{ property?: string; value?: string }>;
|
|
31
|
+
};
|
|
32
|
+
for (const op of operations) {
|
|
33
|
+
if (op.property === "color") disk = disk.replace(/color: \w+/, `color: ${op.value}`);
|
|
34
|
+
}
|
|
35
|
+
return Response.json({ ok: true, changed: true, matched: true, content: disk });
|
|
36
|
+
}
|
|
37
|
+
return Response.json({ content: disk });
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const writeProjectFile = async (path: string, content: string, expected?: string) => {
|
|
41
|
+
if (++writes === 1) await firstWrite;
|
|
42
|
+
if (expected !== undefined && expected !== disk && content !== disk) {
|
|
43
|
+
refused.push(path);
|
|
44
|
+
throw new Error(`409 conflict on ${path}`);
|
|
45
|
+
}
|
|
46
|
+
disk = content;
|
|
47
|
+
};
|
|
48
|
+
return { writeProjectFile, refused, release: () => release(), read: () => disk };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Project = ReturnType<typeof fakeProject>;
|
|
52
|
+
|
|
53
|
+
function mountCanvasCommits(project: Project) {
|
|
54
|
+
const iframe = document.createElement("iframe");
|
|
55
|
+
document.body.append(iframe);
|
|
56
|
+
iframe.contentDocument!.body.innerHTML =
|
|
57
|
+
'<div data-hf-id="hf-card" style="color: red">Card</div>';
|
|
58
|
+
const element = iframe.contentDocument!.querySelector<HTMLElement>('[data-hf-id="hf-card"]')!;
|
|
59
|
+
const selection = {
|
|
60
|
+
element,
|
|
61
|
+
label: "Card",
|
|
62
|
+
tagName: "div",
|
|
63
|
+
sourceFile: "index.html",
|
|
64
|
+
compositionPath: "index.html",
|
|
65
|
+
isCompositionHost: false,
|
|
66
|
+
isInsideLockedComposition: false,
|
|
67
|
+
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
68
|
+
textContent: "Card",
|
|
69
|
+
dataAttributes: {},
|
|
70
|
+
inlineStyles: { color: "red" },
|
|
71
|
+
computedStyles: {},
|
|
72
|
+
textFields: [],
|
|
73
|
+
capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true },
|
|
74
|
+
hfId: "hf-card",
|
|
75
|
+
selector: '[data-hf-id="hf-card"]',
|
|
76
|
+
selectorIndex: 0,
|
|
77
|
+
} as unknown as DomEditSelection;
|
|
78
|
+
const captured: { current: ReturnType<typeof useDomEditCommits> | null } = { current: null };
|
|
79
|
+
const toasts: string[] = [];
|
|
80
|
+
function Probe() {
|
|
81
|
+
captured.current = useDomEditCommits({
|
|
82
|
+
activeCompPath: "index.html",
|
|
83
|
+
previewIframeRef: { current: iframe },
|
|
84
|
+
showToast: (message) => toasts.push(message),
|
|
85
|
+
queueDomEditSave: (save) => save(),
|
|
86
|
+
writeProjectFile: project.writeProjectFile,
|
|
87
|
+
editHistory: { recordEdit: async () => {} },
|
|
88
|
+
fileTree: [],
|
|
89
|
+
importedFontAssetsRef: { current: [] },
|
|
90
|
+
projectId: "p1",
|
|
91
|
+
projectIdRef: { current: "p1" },
|
|
92
|
+
reloadPreview: () => {},
|
|
93
|
+
domEditSelection: selection,
|
|
94
|
+
applyDomSelection: () => {},
|
|
95
|
+
clearDomSelection: () => {},
|
|
96
|
+
refreshDomEditSelectionFromPreview: () => {},
|
|
97
|
+
buildDomSelectionFromTarget: async () => null,
|
|
98
|
+
readOnlyPreview: false,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const root = createRoot(document.createElement("div"));
|
|
103
|
+
act(() => root.render(createElement(Probe)));
|
|
104
|
+
return { hook: captured.current!, selection, toasts };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const selfText: DomEditTextField = {
|
|
108
|
+
key: "self",
|
|
109
|
+
label: "self",
|
|
110
|
+
value: "Card",
|
|
111
|
+
tagName: "div",
|
|
112
|
+
attributes: [],
|
|
113
|
+
inlineStyles: {},
|
|
114
|
+
computedStyles: {},
|
|
115
|
+
source: "self",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const moveClip = (project: Project) =>
|
|
119
|
+
persistTimelineEdit({
|
|
120
|
+
projectId: "p1",
|
|
121
|
+
activeCompPath: "index.html",
|
|
122
|
+
element: {
|
|
123
|
+
id: "clip",
|
|
124
|
+
key: "clip",
|
|
125
|
+
domId: "clip",
|
|
126
|
+
tag: "div",
|
|
127
|
+
start: 1,
|
|
128
|
+
duration: 5,
|
|
129
|
+
track: 0,
|
|
130
|
+
} as TimelineElement,
|
|
131
|
+
label: "Move clip",
|
|
132
|
+
buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, 3, 5),
|
|
133
|
+
writeProjectFile: project.writeProjectFile,
|
|
134
|
+
recordEdit: async () => {},
|
|
135
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Starts the second save while the first one's write is in flight; both must land unrefused.
|
|
139
|
+
async function expectBothLand(
|
|
140
|
+
project: Project,
|
|
141
|
+
first: () => Promise<unknown>,
|
|
142
|
+
second: () => Promise<unknown>,
|
|
143
|
+
) {
|
|
144
|
+
await act(async () => {
|
|
145
|
+
const saves = [first()];
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
147
|
+
saves.push(second());
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
149
|
+
project.release();
|
|
150
|
+
await Promise.allSettled(saves);
|
|
151
|
+
});
|
|
152
|
+
expect(project.refused).toEqual([]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
document.body.innerHTML = "";
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("canvas edit commits share the file queue", () => {
|
|
161
|
+
it("lands a second canvas commit started while the first one's font write is held", async () => {
|
|
162
|
+
const project = fakeProject();
|
|
163
|
+
const { hook, selection, toasts } = mountCanvasCommits(project);
|
|
164
|
+
const font = {
|
|
165
|
+
family: "Imported",
|
|
166
|
+
path: "fonts/Imported.woff2",
|
|
167
|
+
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
await expectBothLand(
|
|
171
|
+
project,
|
|
172
|
+
() => hook.commitDomTextFields(selection, [selfText], { importedFont: font }),
|
|
173
|
+
() => hook.handleDomStyleCommit("color", "green"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(toasts).toEqual([]);
|
|
177
|
+
expect(project.read()).toContain("@font-face");
|
|
178
|
+
expect(project.read()).toContain("color: green");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("lands a canvas commit started while a timeline save is writing", async () => {
|
|
182
|
+
const project = fakeProject();
|
|
183
|
+
const { hook, toasts } = mountCanvasCommits(project);
|
|
184
|
+
|
|
185
|
+
await expectBothLand(
|
|
186
|
+
project,
|
|
187
|
+
() => moveClip(project),
|
|
188
|
+
() => hook.handleDomStyleCommit("color", "green"),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(toasts).toEqual([]);
|
|
192
|
+
expect(project.read()).toContain('id="clip" class="clip" data-start="3"');
|
|
193
|
+
expect(project.read()).toContain("color: green");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
2
|
+
|
|
3
|
+
export class GsapPreviewConvergenceError extends Error {}
|
|
4
|
+
export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
|
5
|
+
|
|
6
|
+
/** Verify rollback ownership support before any GSAP mutation can land. */
|
|
7
|
+
export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
|
8
|
+
const response = await fetch(
|
|
9
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
|
10
|
+
);
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
|
|
13
|
+
}
|
|
14
|
+
const body = await response.json().catch(() => null);
|
|
15
|
+
if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
|
|
16
|
+
throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Atomically restore one GSAP mutation only while its exact output still owns
|
|
21
|
+
* the file. The server performs compare + write synchronously, eliminating the
|
|
22
|
+
* client GET→PUT window that could overwrite a successor edit. */
|
|
23
|
+
export async function rollbackOwnedMutation(
|
|
24
|
+
projectId: string,
|
|
25
|
+
targetPath: string,
|
|
26
|
+
expected: string,
|
|
27
|
+
restore: string,
|
|
28
|
+
): Promise<"restored" | "conflict"> {
|
|
29
|
+
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
30
|
+
throw new Error(`Unsafe path: ${targetPath}`);
|
|
31
|
+
}
|
|
32
|
+
const response = await fetch(
|
|
33
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
|
34
|
+
{
|
|
35
|
+
method: "POST",
|
|
36
|
+
// Deliberately unclaimed: a rollback runs because a mutation did not
|
|
37
|
+
// converge, so let the restored file reload the preview.
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ expected, restore }),
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to restore ${targetPath}`);
|
|
44
|
+
}
|
|
45
|
+
const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
|
|
46
|
+
if (result.restored === true && result.conflict === false) return "restored";
|
|
47
|
+
if (result.restored === false && result.conflict === true) return "conflict";
|
|
48
|
+
throw new Error(`Invalid restore response for ${targetPath}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server's GSAP-mutation response. `scriptText` is the rewritten root script for a soft
|
|
52
|
+
* reload, null when none came back: the caller then full-reloads when `mutated`, or rebinds
|
|
53
|
+
* the runtime timing in place when nothing was rewritten (see syncTimingEditPreview). */
|
|
54
|
+
export type GsapMutationStatus = {
|
|
55
|
+
mutated: boolean;
|
|
56
|
+
scriptText: string | null;
|
|
57
|
+
/** Atomic whole-file ownership pair returned by the mutation endpoint. */
|
|
58
|
+
before?: string;
|
|
59
|
+
after?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
67
|
+
if (
|
|
68
|
+
!isRecord(value) ||
|
|
69
|
+
typeof value.mutated !== "boolean" ||
|
|
70
|
+
typeof value.before !== "string" ||
|
|
71
|
+
typeof value.after !== "string" ||
|
|
72
|
+
value.mutated !== (value.before !== value.after) ||
|
|
73
|
+
("changed" in value && value.changed !== value.mutated)
|
|
74
|
+
) {
|
|
75
|
+
throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
mutated: value.mutated,
|
|
79
|
+
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
|
80
|
+
before: value.before,
|
|
81
|
+
after: value.after,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readMutationError(value: unknown, fallback: string): string {
|
|
86
|
+
if (isRecord(value) && typeof value.error === "string") return value.error;
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function postGsapMutation(
|
|
91
|
+
projectId: string,
|
|
92
|
+
filePath: string,
|
|
93
|
+
mutation: Record<string, unknown>,
|
|
94
|
+
fallback: string,
|
|
95
|
+
): Promise<GsapMutationStatus> {
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(
|
|
99
|
+
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
103
|
+
body: JSON.stringify(mutation),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const body: unknown = await response.json().catch(() => null);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
|
|
114
|
+
}
|
|
115
|
+
return readMutationStatus(body);
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
|
|
2
|
+
let resizeCallback: ResizeObserverCallback | null = null;
|
|
3
|
+
|
|
4
|
+
export function MockResizeObserver(callback: ResizeObserverCallback) {
|
|
5
|
+
resizeCallback = callback;
|
|
6
|
+
return { observe() {}, disconnect() {}, unobserve() {} };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function reportResize(width: number, height: number): void {
|
|
10
|
+
resizeCallback!(
|
|
11
|
+
[{ contentRect: { width, height } } as ResizeObserverEntry],
|
|
12
|
+
{} as ResizeObserver,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -14,9 +14,9 @@ import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
|
14
14
|
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
15
15
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
16
16
|
import {
|
|
17
|
-
applyPatchByTarget,
|
|
18
|
-
buildPatchTarget,
|
|
19
17
|
findTimelineElementInIframe,
|
|
18
|
+
operationChanges,
|
|
19
|
+
patchTimelineChangesInSource,
|
|
20
20
|
readFileContent,
|
|
21
21
|
type RecordEditInput,
|
|
22
22
|
} from "./timelineEditingHelpers";
|
|
@@ -197,52 +197,30 @@ export async function createAudioGroupAndAssignMembers({
|
|
|
197
197
|
property: HF_AUDIO_GROUP_ATTR,
|
|
198
198
|
value: groupId,
|
|
199
199
|
};
|
|
200
|
-
const
|
|
201
|
-
const
|
|
200
|
+
const groupPath = activeCompPath || "index.html";
|
|
201
|
+
const byPath = groupElementsByTargetPath(elements, activeCompPath);
|
|
202
|
+
const files: Record<string, (current: string) => string> = {};
|
|
203
|
+
for (const targetPath of new Set([...byPath.keys(), groupPath])) {
|
|
204
|
+
files[targetPath] = (current) => {
|
|
205
|
+
let patched = patchTimelineChangesInSource(
|
|
206
|
+
current,
|
|
207
|
+
targetPath,
|
|
208
|
+
operationChanges(byPath.get(targetPath) ?? [], groupOperation),
|
|
209
|
+
);
|
|
210
|
+
if (targetPath === groupPath) patched = insertGroupElement(patched, groupId, groupLabel);
|
|
211
|
+
if (patched !== current) pendingTimelineEditPathRef.current.add(targetPath);
|
|
212
|
+
return patched;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
202
215
|
|
|
203
216
|
try {
|
|
204
|
-
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
205
|
-
let patchedContent = await readFileContent(projectId, targetPath);
|
|
206
|
-
originalByPath.set(targetPath, patchedContent);
|
|
207
|
-
|
|
208
|
-
for (const element of fileElements) {
|
|
209
|
-
const patchTarget = buildPatchTarget(element);
|
|
210
|
-
if (!patchTarget) {
|
|
211
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
212
|
-
}
|
|
213
|
-
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
214
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
215
|
-
}
|
|
216
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
files[targetPath] = patchedContent;
|
|
220
|
-
pendingTimelineEditPathRef.current.add(targetPath);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const groupPath = activeCompPath || "index.html";
|
|
224
|
-
let groupContent = files[groupPath];
|
|
225
|
-
if (groupContent === undefined) {
|
|
226
|
-
groupContent = await readFileContent(projectId, groupPath);
|
|
227
|
-
originalByPath.set(groupPath, groupContent);
|
|
228
|
-
}
|
|
229
|
-
const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
|
|
230
|
-
if (withGroupElement !== groupContent) {
|
|
231
|
-
files[groupPath] = withGroupElement;
|
|
232
|
-
pendingTimelineEditPathRef.current.add(groupPath);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
217
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
236
218
|
projectId,
|
|
237
219
|
label: groupLabel
|
|
238
220
|
? `Group ${elements.length} clips as ${groupLabel}`
|
|
239
221
|
: `Group ${elements.length} voice clips`,
|
|
240
222
|
files,
|
|
241
|
-
readFile:
|
|
242
|
-
const original = originalByPath.get(path);
|
|
243
|
-
if (original !== undefined) return original;
|
|
244
|
-
return readFileContent(projectId, path);
|
|
245
|
-
},
|
|
223
|
+
readFile: (path) => readFileContent(projectId, path),
|
|
246
224
|
writeFile: writeProjectFile,
|
|
247
225
|
recordEdit,
|
|
248
226
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PersistTimelineBatchChange,
|
|
13
13
|
} from "./timelineEditingHelpers";
|
|
14
14
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
15
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
15
16
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
17
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
17
18
|
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
|
@@ -244,6 +245,26 @@ describe("buildTimelineResizeTimingPatch", () => {
|
|
|
244
245
|
});
|
|
245
246
|
});
|
|
246
247
|
|
|
248
|
+
describe("buildTimelineResizeTimingPatch — source in-point read as playback reads it", () => {
|
|
249
|
+
it.each([
|
|
250
|
+
['data-playback-start="-1" data-media-start="2"', 4.5, 1.5],
|
|
251
|
+
['data-playback-start="2"', 4.5, 1.5],
|
|
252
|
+
['data-media-start="junk"', 5.5, 0.5],
|
|
253
|
+
['data-media-start="1.5s"', 5.5, 0.5],
|
|
254
|
+
])("%s, head to %s", (inPoint, start, expected) => {
|
|
255
|
+
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" ${inPoint}></video></div>`;
|
|
256
|
+
const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
|
|
257
|
+
const host = document.createElement("div");
|
|
258
|
+
host.innerHTML = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
|
|
259
|
+
start,
|
|
260
|
+
duration: 8 - start,
|
|
261
|
+
playbackStart: undefined,
|
|
262
|
+
});
|
|
263
|
+
const video = host.querySelector("#a")!;
|
|
264
|
+
expect(readMediaOffsetSeconds((name) => video.getAttribute(name))).toBe(expected);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
247
268
|
describe("persistTimelineBatchEdit", () => {
|
|
248
269
|
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
|
|
249
270
|
|
|
@@ -15,7 +15,10 @@ import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
|
15
15
|
import {
|
|
16
16
|
furthestClipEndFromSource,
|
|
17
17
|
getTimelineElementIdentity,
|
|
18
|
+
playbackStartAttributeForElement,
|
|
19
|
+
readPlaybackStartAttributes,
|
|
18
20
|
} from "../player/lib/timelineElementHelpers";
|
|
21
|
+
import { resolveTimelinePlaybackRate } from "../player/components/timelineGroupEditing";
|
|
19
22
|
import {
|
|
20
23
|
saveProjectFilesWithHistory,
|
|
21
24
|
writeProjectFilesWithHistoryInQueue,
|
|
@@ -199,13 +202,6 @@ export function removeIframeTimelineElements(
|
|
|
199
202
|
findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
export function playbackStartAttributeForElement(
|
|
203
|
-
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
204
|
-
): "data-media-start" | "data-playback-start" {
|
|
205
|
-
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
|
|
206
|
-
? "data-playback-start"
|
|
207
|
-
: "data-media-start";
|
|
208
|
-
}
|
|
209
205
|
// fallow-ignore-next-line complexity
|
|
210
206
|
function resolveResizePlaybackStart(
|
|
211
207
|
original: string,
|
|
@@ -219,15 +215,19 @@ function resolveResizePlaybackStart(
|
|
|
219
215
|
}
|
|
220
216
|
const trimDelta = updates.start - element.start;
|
|
221
217
|
if (trimDelta === 0) return null;
|
|
222
|
-
const
|
|
223
|
-
readAttributeByTarget(original, target,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
218
|
+
const source = readPlaybackStartAttributes((name) =>
|
|
219
|
+
readAttributeByTarget(original, target, name),
|
|
220
|
+
);
|
|
221
|
+
if (source.playbackStart == null) return null;
|
|
222
|
+
const attrName = playbackStartAttributeForElement({ kind: element.kind, ...source }).slice(
|
|
223
|
+
"data-".length,
|
|
224
|
+
);
|
|
228
225
|
return {
|
|
229
226
|
attrName,
|
|
230
|
-
value: Math.max(
|
|
227
|
+
value: Math.max(
|
|
228
|
+
0,
|
|
229
|
+
source.playbackStart + trimDelta * resolveTimelinePlaybackRate(element.playbackRate),
|
|
230
|
+
),
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -308,25 +308,26 @@ export interface PersistTimelineEditInput {
|
|
|
308
308
|
|
|
309
309
|
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
310
310
|
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
|
|
311
|
-
const originalContent = await readFileContent(input.projectId, targetPath);
|
|
312
|
-
|
|
313
311
|
const patchTarget = buildPatchTarget(input.element);
|
|
314
312
|
if (!patchTarget) {
|
|
315
313
|
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
|
|
316
314
|
}
|
|
317
315
|
|
|
318
|
-
const patchedContent = input.buildPatches(originalContent, patchTarget);
|
|
319
|
-
if (patchedContent === originalContent) {
|
|
320
|
-
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
316
|
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
324
317
|
await saveProjectFilesWithHistory({
|
|
325
318
|
projectId: input.projectId,
|
|
326
319
|
label: input.label,
|
|
327
320
|
coalesceKey: input.coalesceKey,
|
|
328
|
-
files: {
|
|
329
|
-
|
|
321
|
+
files: {
|
|
322
|
+
[targetPath]: (current) => {
|
|
323
|
+
const patched = input.buildPatches(current, patchTarget);
|
|
324
|
+
if (patched === current) {
|
|
325
|
+
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
326
|
+
}
|
|
327
|
+
return patched;
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
readFile: (path) => readFileContent(input.projectId, path),
|
|
330
331
|
writeFile: input.writeProjectFile,
|
|
331
332
|
recordEdit: input.recordEdit,
|
|
332
333
|
});
|
|
@@ -337,6 +338,36 @@ export interface PersistTimelineBatchChange {
|
|
|
337
338
|
buildPatches: (original: string, target: PatchTarget) => string;
|
|
338
339
|
}
|
|
339
340
|
|
|
341
|
+
/** One batch change per element, each applying the same patch operation. */
|
|
342
|
+
export function operationChanges(
|
|
343
|
+
elements: readonly TimelineElement[],
|
|
344
|
+
operation: PatchOperation,
|
|
345
|
+
): PersistTimelineBatchChange[] {
|
|
346
|
+
return elements.map((element) => ({
|
|
347
|
+
element,
|
|
348
|
+
buildPatches: (html, target) => applyPatchByTarget(html, target, operation),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Patches each change into `source`, failing loudly on a target the file does not hold. */
|
|
353
|
+
export function patchTimelineChangesInSource(
|
|
354
|
+
source: string,
|
|
355
|
+
targetPath: string,
|
|
356
|
+
changes: readonly PersistTimelineBatchChange[],
|
|
357
|
+
): string {
|
|
358
|
+
let current = source;
|
|
359
|
+
for (const { element, buildPatches } of changes) {
|
|
360
|
+
const target = buildPatchTarget(element);
|
|
361
|
+
if (!target) throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
362
|
+
// Resolve first: a member already at its target values patches to the same string, a missing one must throw.
|
|
363
|
+
if (!findTagByTarget(current, target)) {
|
|
364
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
365
|
+
}
|
|
366
|
+
current = buildPatches(current, target);
|
|
367
|
+
}
|
|
368
|
+
return current;
|
|
369
|
+
}
|
|
370
|
+
|
|
340
371
|
export interface PersistTimelineBatchEditInput {
|
|
341
372
|
projectId: string;
|
|
342
373
|
activeCompPath: string | null;
|
|
@@ -353,50 +384,24 @@ export interface PersistTimelineBatchEditInput {
|
|
|
353
384
|
export async function persistTimelineBatchEdit(
|
|
354
385
|
input: PersistTimelineBatchEditInput,
|
|
355
386
|
): Promise<void> {
|
|
356
|
-
const
|
|
357
|
-
const patchedByPath = new Map<string, string>();
|
|
358
|
-
|
|
387
|
+
const changesByPath = new Map<string, PersistTimelineBatchChange[]>();
|
|
359
388
|
for (const change of input.changes) {
|
|
360
389
|
const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
|
|
361
|
-
|
|
362
|
-
originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
|
|
363
|
-
originals.set(targetPath, original);
|
|
364
|
-
|
|
365
|
-
const patchTarget = buildPatchTarget(change.element);
|
|
366
|
-
if (!patchTarget) {
|
|
367
|
-
throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const current = patchedByPath.get(targetPath) ?? original;
|
|
371
|
-
// Resolve the target FIRST: byte-identical output below is only a legit
|
|
372
|
-
// no-op when the member actually resolved in the source. A mistargeted
|
|
373
|
-
// member (stale id/selector) must fail loudly like the single-edit path,
|
|
374
|
-
// not be silently dropped as "already at target".
|
|
375
|
-
if (!findTagByTarget(current, patchTarget)) {
|
|
376
|
-
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
|
|
377
|
-
}
|
|
378
|
-
const patched = change.buildPatches(current, patchTarget);
|
|
379
|
-
// The target resolved, so a member whose attributes already hold the target
|
|
380
|
-
// values patches to the identical string — e.g. a track-insert renumber
|
|
381
|
-
// where one clip's lane is already correct. That is a legitimate no-op:
|
|
382
|
-
// skip it instead of aborting (and rolling back) the whole batch.
|
|
383
|
-
if (patched === current) continue;
|
|
384
|
-
patchedByPath.set(targetPath, patched);
|
|
390
|
+
changesByPath.set(targetPath, [...(changesByPath.get(targetPath) ?? []), change]);
|
|
385
391
|
}
|
|
392
|
+
const buildFile = (targetPath: string) => (original: string) => {
|
|
393
|
+
const next = patchTimelineChangesInSource(original, targetPath, changesByPath.get(targetPath)!);
|
|
394
|
+
if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
395
|
+
return next;
|
|
396
|
+
};
|
|
386
397
|
|
|
387
|
-
if (patchedByPath.size === 0) return;
|
|
388
|
-
|
|
389
|
-
const files = Object.fromEntries(patchedByPath);
|
|
390
|
-
for (const targetPath of Object.keys(files)) {
|
|
391
|
-
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
392
|
-
}
|
|
393
398
|
await saveProjectFilesWithHistory({
|
|
394
399
|
projectId: input.projectId,
|
|
395
400
|
label: input.label,
|
|
396
401
|
coalesceKey: input.coalesceKey,
|
|
397
402
|
coalesceMs: input.coalesceMs,
|
|
398
|
-
files,
|
|
399
|
-
readFile:
|
|
403
|
+
files: Object.fromEntries([...changesByPath.keys()].map((path) => [path, buildFile(path)])),
|
|
404
|
+
readFile: (path) => readFileContent(input.projectId, path),
|
|
400
405
|
writeFile: input.writeProjectFile,
|
|
401
406
|
recordEdit: input.recordEdit,
|
|
402
407
|
});
|
|
@@ -427,7 +432,7 @@ export function readTargetAttribute(
|
|
|
427
432
|
return readAttributeByTarget(html, patchTarget, attr) ?? null;
|
|
428
433
|
}
|
|
429
434
|
|
|
430
|
-
export {
|
|
435
|
+
export { formatTimelineAttributeNumber, formatTimelineMediaOffset };
|
|
431
436
|
|
|
432
437
|
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
433
438
|
|
|
@@ -490,8 +495,8 @@ export async function persistElementAttribute({
|
|
|
490
495
|
return await writeProjectFilesWithHistoryInQueue({
|
|
491
496
|
projectId,
|
|
492
497
|
label,
|
|
493
|
-
files: { [targetPath]: patched },
|
|
494
|
-
readFile: async (
|
|
498
|
+
files: { [targetPath]: () => patched },
|
|
499
|
+
readFile: async () => before,
|
|
495
500
|
writeFile: writeProjectFile,
|
|
496
501
|
recordEdit,
|
|
497
502
|
});
|