@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 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 originalByPath = new Map<string, string>();
201
- const files: Record<string, string> = {};
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: async (path) => {
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 raw =
223
- readAttributeByTarget(original, target, "playback-start") ??
224
- readAttributeByTarget(original, target, "media-start");
225
- const current = raw != null ? parseFloat(raw) : undefined;
226
- if (current == null || !Number.isFinite(current)) return null;
227
- const attrName = playbackStartAttributeForElement(element).slice("data-".length);
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(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
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: { [targetPath]: patchedContent },
329
- readFile: async () => originalContent,
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 originals = new Map<string, string>();
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
- const original =
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: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
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 { applyPatchByTarget, formatTimelineAttributeNumber, formatTimelineMediaOffset };
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 (path) => (path === targetPath ? before : readFileContent(projectId, path)),
498
+ files: { [targetPath]: () => patched },
499
+ readFile: async () => before,
495
500
  writeFile: writeProjectFile,
496
501
  recordEdit,
497
502
  });