@hyperframes/studio 0.8.79 → 0.8.81

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 (86) hide show
  1. package/dist/assets/hyperframes-player-C68UawR3.js +464 -0
  2. package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
  3. package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
  4. package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1673 -1348
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +13 -10
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  13. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  14. package/src/components/editor/manualEdits.ts +1 -0
  15. package/src/components/editor/manualEditsParsing.ts +4 -0
  16. package/src/components/editor/useFxAudition.ts +3 -3
  17. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  18. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  19. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  20. package/src/components/panels/VariablesPanel.tsx +3 -3
  21. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  22. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  23. package/src/components/studioColorGradingScope.ts +5 -7
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/contexts/TimelineEditContext.tsx +2 -0
  26. package/src/hooks/liveLanes.ts +147 -0
  27. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  28. package/src/hooks/timelineEditPermission.ts +9 -1
  29. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  30. package/src/hooks/timelineEditingHelpers.ts +69 -43
  31. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  32. package/src/hooks/useAppHotkeys.ts +1 -1
  33. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  34. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  35. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  36. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  37. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -4
  39. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  40. package/src/hooks/useDomEditSession.ts +1 -0
  41. package/src/hooks/useEditHistoryActions.ts +4 -3
  42. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  43. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  44. package/src/hooks/useLintModal.test.tsx +15 -0
  45. package/src/hooks/useLintModal.ts +5 -2
  46. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  47. package/src/hooks/useTimelineEditing.ts +19 -16
  48. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  49. package/src/hooks/useToast.ts +2 -1
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +23 -0
  52. package/src/player/components/Player.tsx +4 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/groupAutomationElement.ts +9 -1
  64. package/src/player/components/timelineCallbacks.ts +2 -0
  65. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  66. package/src/player/components/timelineClipDragPreview.ts +15 -24
  67. package/src/player/components/timelineEditing.test.ts +99 -1
  68. package/src/player/components/timelineEditing.ts +5 -8
  69. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  70. package/src/player/components/timelineGroupEditing.ts +18 -15
  71. package/src/player/components/timelineStackingSync.ts +5 -5
  72. package/src/player/components/timelineZones.ts +2 -3
  73. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  74. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  75. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  76. package/src/player/components/useAutomationLanes.ts +58 -74
  77. package/src/player/lib/automationStoreSync.ts +30 -3
  78. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  79. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -8
  81. package/src/player/lib/timelineElementHelpers.ts +3 -4
  82. package/src/player/lib/timelineGroupInfo.ts +23 -23
  83. package/src/utils/studioFileHistory.ts +36 -30
  84. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  85. package/src/player/components/automationGestureKeys.test.ts +0 -49
  86. package/src/player/components/automationGestureKeys.ts +0 -49
@@ -0,0 +1,147 @@
1
+ import { useRef, useState } from "react";
2
+ import { readTargetAttribute, type PatchTarget } from "./timelineEditingHelpers";
3
+
4
+ /** Where a lane's value lives in source, so an undo can re-read it. */
5
+ export interface LiveLaneSource {
6
+ path: string;
7
+ target: PatchTarget | null;
8
+ attr: string;
9
+ }
10
+
11
+ /** The files an undo or redo rewrote. */
12
+ export interface LiveLaneRestore {
13
+ paths?: string[];
14
+ files?: Record<string, { restored: string }>;
15
+ }
16
+
17
+ /** Per-lane live-preview state: the value before a gesture, pending saves, the last verified value. */
18
+ function createLiveLanes(project: () => string, composition: () => string) {
19
+ const before = new Map<string, string | null>();
20
+ const sources = new Map<string, LiveLaneSource>();
21
+ const pending = new Map<string, Set<number>>();
22
+ const verified = new Map<string, string | null>();
23
+ // Lanes whose before-value was read with no save pending, so it is what the file holds.
24
+ const clean = new Set<string>();
25
+ let saves = 0;
26
+ const take = (key: string): string | null | undefined => {
27
+ const claimed = before.has(key) ? (before.get(key) ?? null) : undefined;
28
+ before.delete(key);
29
+ clean.delete(key);
30
+ if (!pending.has(key)) sources.delete(key);
31
+ return claimed;
32
+ };
33
+ const overtaken = (key: string, save: number): boolean =>
34
+ [...(pending.get(key) ?? [])].some((newer) => newer > save);
35
+ const rebase = (key: string, value: string | null | undefined): void => {
36
+ if (value === undefined) {
37
+ clean.delete(key);
38
+ verified.delete(key);
39
+ return;
40
+ }
41
+ if (before.has(key)) before.set(key, value);
42
+ if (pending.has(key)) verified.set(key, value);
43
+ if (pending.has(key)) clean.delete(key);
44
+ else clean.add(key);
45
+ };
46
+ const scoped = (laneKey: string): string => `${project()}\n${composition()}\n${laneKey}`;
47
+ return {
48
+ preview(laneKey: string, readCurrent: () => string | null, source: LiveLaneSource): void {
49
+ const key = scoped(laneKey);
50
+ if (before.has(key)) return;
51
+ before.set(key, readCurrent());
52
+ sources.set(key, source);
53
+ if (!pending.has(key)) clean.add(key);
54
+ },
55
+ // A save. Its settle records what the file holds: `saved`, else the last value verified on
56
+ // this lane (a landed save, a read-back, or a clean before-value), never an unsaved one.
57
+ // Store and preview follow only while no newer save is pending and its composition is open,
58
+ // and the preview only while no drag is live.
59
+ claim(laneKey: string, apply: LiveLaneApply, source: LiveLaneSource): LiveLaneSave {
60
+ const key = scoped(laneKey);
61
+ const open = () => scoped(laneKey) === key;
62
+ const trusted = clean.has(key);
63
+ const claimed = take(key);
64
+ const mine = ++saves;
65
+ sources.set(key, source);
66
+ if (claimed !== undefined && trusted) verified.set(key, claimed);
67
+ pending.set(key, (pending.get(key) ?? new Set<number>()).add(mine));
68
+ const preview = (value: string | null) => {
69
+ if (open() && !before.has(key) && !overtaken(key, mine)) apply.preview(value);
70
+ };
71
+ return {
72
+ preview,
73
+ read(value) {
74
+ verified.set(key, value);
75
+ },
76
+ settle(saved) {
77
+ const inFlight = pending.get(key);
78
+ inFlight?.delete(mine);
79
+ if (saved !== undefined) verified.set(key, saved);
80
+ const value = verified.get(key);
81
+ // With no save left in flight the file can change under it (undo, an outside edit).
82
+ if (!inFlight?.size) {
83
+ pending.delete(key);
84
+ verified.delete(key);
85
+ if (!before.has(key)) sources.delete(key);
86
+ }
87
+ if (value === undefined || overtaken(key, mine) || !open()) return;
88
+ if (before.has(key)) {
89
+ before.set(key, value);
90
+ clean.add(key);
91
+ }
92
+ apply.store(value);
93
+ preview(value);
94
+ },
95
+ };
96
+ },
97
+ // A gesture refused before it saved: put its before-value back, which hands the lane
98
+ // back to any save still pending on it.
99
+ revert(laneKey: string, apply: LiveLaneApply): void {
100
+ const claimed = take(scoped(laneKey));
101
+ if (claimed === undefined) return;
102
+ apply.preview(claimed);
103
+ apply.store(claimed);
104
+ },
105
+ // An undo or redo rewrote files under the project's live gestures and pending saves, in any
106
+ // composition: each takes what the restored file holds, or stops trusting a value it cannot read.
107
+ restore({ paths = [], files }: LiveLaneRestore): void {
108
+ const inProject = `${project()}\n`;
109
+ for (const [key, source] of sources) {
110
+ if (key.startsWith(inProject) && paths.includes(source.path)) {
111
+ rebase(key, restoredValue(source, files));
112
+ }
113
+ }
114
+ },
115
+ };
116
+ }
117
+
118
+ function restoredValue({ path, target, attr }: LiveLaneSource, files: LiveLaneRestore["files"]) {
119
+ const html = files?.[path]?.restored;
120
+ return html === undefined || !target ? undefined : readTargetAttribute(html, target, attr);
121
+ }
122
+
123
+ /** A hook's live lanes, scoped to the project and composition open when each save starts. */
124
+ export function useLiveLanes(
125
+ projectIdRef: { readonly current: string | null },
126
+ activeCompPath: string | null,
127
+ ) {
128
+ const compositionRef = useRef(activeCompPath);
129
+ compositionRef.current = activeCompPath;
130
+ const [lanes] = useState(() =>
131
+ createLiveLanes(
132
+ () => projectIdRef.current ?? "",
133
+ () => compositionRef.current ?? "",
134
+ ),
135
+ );
136
+ return lanes;
137
+ }
138
+
139
+ interface LiveLaneSave {
140
+ preview: (value: string | null) => void;
141
+ read: (value: string | null) => void;
142
+ settle: (saved?: string | null) => void;
143
+ }
144
+ interface LiveLaneApply {
145
+ preview: (value: string | null) => void;
146
+ store: (value: string | null) => void;
147
+ }
@@ -1,5 +1,9 @@
1
- import { useCallback, useRef } from "react";
2
- import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
1
+ import { useCallback } from "react";
2
+ import {
3
+ failedTimelineSave,
4
+ projectForTimelineSave,
5
+ type TimelineEditOutcome,
6
+ } from "./timelineEditPermission";
3
7
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
4
8
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
5
9
  import { usePlayerStore } from "../player";
@@ -8,8 +12,10 @@ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
8
12
  import {
9
13
  buildPatchTarget,
10
14
  persistElementAttribute,
15
+ readSavedAttribute,
11
16
  type RecordEditInput,
12
17
  } from "./timelineEditingHelpers";
18
+ import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
13
19
  import type {
14
20
  MutableRef,
15
21
  UseTimelineElementVisibilityEditingInput,
@@ -146,6 +152,8 @@ interface SetAudioGroupAttributeInput {
146
152
  value: string | null;
147
153
  label: string;
148
154
  previewIframe: HTMLIFrameElement | null;
155
+ patchLive: (value: string | null) => void;
156
+ onFileRead: (value: string | null) => void;
149
157
  writeProjectFile: (path: string, content: string) => Promise<void>;
150
158
  recordEdit: (input: RecordEditInput) => Promise<void>;
151
159
  pendingTimelineEditPathRef: MutableRef<Set<string>>;
@@ -157,6 +165,28 @@ interface SetAudioGroupAttributeInput {
157
165
  * `createAudioGroupAndAssignMembers`'s save shape but for a single element
158
166
  * and attribute rather than a member-assignment sweep.
159
167
  */
168
+ function groupSaveTarget(
169
+ previewIframe: HTMLIFrameElement | null,
170
+ groupId: string,
171
+ activeCompPath: string | null,
172
+ ) {
173
+ const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
174
+ return {
175
+ targetPath: resolveGroupSourceFile(groupEl) || activeCompPath || "index.html",
176
+ patchTarget: buildPatchTarget({ domId: groupId }),
177
+ };
178
+ }
179
+
180
+ function groupLiveSource(
181
+ previewIframe: HTMLIFrameElement | null,
182
+ groupId: string,
183
+ activeCompPath: string | null,
184
+ attr: string,
185
+ ): LiveLaneSource {
186
+ const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath);
187
+ return { path: targetPath, target: patchTarget, attr };
188
+ }
189
+
160
190
  async function setAudioGroupAttribute({
161
191
  projectId,
162
192
  activeCompPath,
@@ -165,6 +195,8 @@ async function setAudioGroupAttribute({
165
195
  value,
166
196
  label,
167
197
  previewIframe,
198
+ patchLive,
199
+ onFileRead,
168
200
  writeProjectFile,
169
201
  recordEdit,
170
202
  pendingTimelineEditPathRef,
@@ -177,9 +209,7 @@ async function setAudioGroupAttribute({
177
209
  // means `readTagSnippetByTarget` finds nothing and every mute, fader move and
178
210
  // FX preset throws "Unable to patch element in index.html". Every sibling
179
211
  // timeline writer already routes `element.sourceFile || activeCompPath`.
180
- const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
181
- const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
182
- const patchTarget = buildPatchTarget({ domId: groupId });
212
+ const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath);
183
213
  if (!patchTarget) return null;
184
214
 
185
215
  return persistElementAttribute({
@@ -192,7 +222,8 @@ async function setAudioGroupAttribute({
192
222
  writeProjectFile,
193
223
  recordEdit,
194
224
  pendingTimelineEditPathRef,
195
- patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
225
+ patchLive,
226
+ onFileRead,
196
227
  });
197
228
  }
198
229
 
@@ -221,42 +252,43 @@ export function useSetAudioGroupAttribute({
221
252
  label: string,
222
253
  ) => Promise<TimelineEditOutcome>;
223
254
  revertLive: (groupId: string, attr: string) => void;
255
+ restoreLive: (restore: LiveLaneRestore) => void;
224
256
  } {
225
- const liveBeforeRef = useRef(new Map<string, string | null>());
257
+ const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
226
258
  const setLive = useCallback(
227
259
  (groupId: string, attr: string, value: string | null) => {
228
260
  const key = audioGroupAttributeLiveKey(groupId, attr);
229
261
  const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
230
- if (!liveBeforeRef.current.has(key)) {
231
- liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
232
- }
262
+ liveLanes.preview(
263
+ key,
264
+ () => target?.getAttribute(attr) ?? null,
265
+ groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr),
266
+ );
233
267
  patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
234
268
  // Live too, not just on commit: a fader drag is `setLive` per frame and
235
269
  // `setQuiet` once on release, so without this the strip's own readout
236
270
  // fights the drag.
237
271
  syncStoredGroupAttribute(groupId, attr, value);
238
272
  },
273
+ [liveLanes, previewIframeRef, activeCompPath],
274
+ );
275
+ const laneApply = useCallback(
276
+ (groupId: string, attr: string) => ({
277
+ preview: (value: string | null) =>
278
+ patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value),
279
+ store: (value: string | null) => syncStoredGroupAttribute(groupId, attr, value),
280
+ }),
239
281
  [previewIframeRef],
240
282
  );
283
+ const claimLive = useCallback(
284
+ (groupId: string, attr: string, source: LiveLaneSource) =>
285
+ liveLanes.claim(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr), source),
286
+ [liveLanes, laneApply],
287
+ );
241
288
  const revertLive = useCallback(
242
- (groupId: string, attr: string) => {
243
- const key = audioGroupAttributeLiveKey(groupId, attr);
244
- if (!liveBeforeRef.current.has(key)) return;
245
- patchLiveGroupAttribute(
246
- previewIframeRef.current,
247
- groupId,
248
- attr,
249
- liveBeforeRef.current.get(key) ?? null,
250
- );
251
- syncStoredGroupAttribute(
252
- groupId,
253
- attr,
254
- previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
255
- null,
256
- );
257
- liveBeforeRef.current.delete(key);
258
- },
259
- [previewIframeRef],
289
+ (groupId: string, attr: string) =>
290
+ liveLanes.revert(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr)),
291
+ [liveLanes, laneApply],
260
292
  );
261
293
  const setQuiet = useCallback(
262
294
  async (
@@ -265,8 +297,19 @@ export function useSetAudioGroupAttribute({
265
297
  value: string | null,
266
298
  label: string,
267
299
  ): Promise<TimelineEditOutcome> => {
268
- const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
269
- if (typeof pid !== "string") return pid;
300
+ const project = projectIdRef.current;
301
+ const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast);
302
+ // Settles on what the file holds, so overlapping saves that fail cannot leave
303
+ // the preview or the store on a value that never landed.
304
+ const source = groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr);
305
+ const live = claimLive(groupId, attr, source);
306
+ const unsaved = async (outcome: TimelineEditOutcome): Promise<TimelineEditOutcome> => {
307
+ live.settle(
308
+ await readSavedAttribute(project, source.path, source.target, attr, writeProjectFile),
309
+ );
310
+ return outcome;
311
+ };
312
+ if (typeof pid !== "string") return unsaved(pid);
270
313
  try {
271
314
  const written = await setAudioGroupAttribute({
272
315
  projectId: pid,
@@ -276,33 +319,24 @@ export function useSetAudioGroupAttribute({
276
319
  value,
277
320
  label,
278
321
  previewIframe: previewIframeRef.current,
322
+ patchLive: live.preview,
323
+ onFileRead: live.read,
279
324
  writeProjectFile,
280
325
  recordEdit,
281
326
  pendingTimelineEditPathRef,
282
327
  });
283
- liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
284
- if (!written) return { status: "failed", reason: "This group has no id to save it by" };
285
- syncStoredGroupAttribute(groupId, attr, value);
328
+ if (!written)
329
+ return unsaved(failedTimelineSave("This group has no id to save it by", showToast));
330
+ live.settle(value);
286
331
  return { status: "saved" };
287
332
  } catch (error) {
288
- // `persistElementAttribute` leaves the live DOM at the previous value
289
- // however it failed — it unwinds a failed save, and an unresolvable
290
- // target now throws before patching at all. But `setLive` mirrored the
291
- // in-progress value into the store on every drag frame — so without
292
- // this the fader reads 0.4 while
293
- // the preview and the file are both back at 1.0, and nothing re-parses
294
- // to correct it (a live patch causing no parse is this mirror's whole
295
- // premise). Re-mirror from the DOM, which is now authoritative again.
296
- const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
297
- syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
298
333
  console.error("[Timeline] Failed to set group attribute", error);
299
334
  const message = error instanceof Error ? error.message : "Failed to update group";
300
- showToast(message);
301
- liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
302
- return { status: "failed", reason: message };
335
+ return unsaved(failedTimelineSave(message, showToast));
303
336
  }
304
337
  },
305
338
  [
339
+ claimLive,
306
340
  activeCompPath,
307
341
  previewIframeRef,
308
342
  writeProjectFile,
@@ -313,5 +347,5 @@ export function useSetAudioGroupAttribute({
313
347
  projectIdRef,
314
348
  ],
315
349
  );
316
- return { setLive, setQuiet, revertLive };
350
+ return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
317
351
  }
@@ -21,7 +21,15 @@ export function projectForTimelineSave(
21
21
  showToast("Cannot edit timeline while recording", "error");
22
22
  return { status: "refused", reason: "Cannot edit timeline while recording" };
23
23
  }
24
- return projectId ?? { status: "failed", reason: "No project is open" };
24
+ return projectId ?? failedTimelineSave("No project is open", showToast);
25
+ }
26
+
27
+ export function failedTimelineSave(
28
+ reason: string,
29
+ showToast: (message: string, tone?: "error" | "info") => void,
30
+ ): TimelineEditOutcome {
31
+ showToast(reason);
32
+ return { status: "failed", reason };
25
33
  }
26
34
 
27
35
  function timelineEditRefusal(
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  applyTimelineStackingReorder,
5
5
  buildTimelineMoveTimingPatch,
6
+ buildTimelineResizeTimingPatch,
6
7
  deleteSelectedKeyframes,
7
8
  extendRootDurationIfNeeded,
8
9
  patchIframeDomTiming,
@@ -230,6 +231,19 @@ describe("extendRootDurationIfNeeded", () => {
230
231
  });
231
232
  });
232
233
 
234
+ describe("buildTimelineResizeTimingPatch", () => {
235
+ it("moves a source-only in-point by the caller's own start change", () => {
236
+ const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" data-media-start="0.337"></video></div>`;
237
+ const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
238
+ const patched = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
239
+ start: 3,
240
+ duration: 5,
241
+ playbackStart: undefined,
242
+ });
243
+ expect(patched).toContain('data-media-start="0"');
244
+ });
245
+ });
246
+
233
247
  describe("persistTimelineBatchEdit", () => {
234
248
  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>`;
235
249
 
@@ -452,6 +466,7 @@ describe("persistElementAttribute", () => {
452
466
  writeProjectFile,
453
467
  recordEdit: vi.fn(),
454
468
  pendingTimelineEditPathRef: { current: new Set() },
469
+ onFileRead: vi.fn(),
455
470
  patchLive,
456
471
  }),
457
472
  ).rejects.toThrow("Unable to patch element in index.html");
@@ -495,6 +510,7 @@ describe("persistElementAttribute — unwind value", () => {
495
510
  writeProjectFile,
496
511
  recordEdit: vi.fn(),
497
512
  pendingTimelineEditPathRef: { current: new Set() },
513
+ onFileRead: vi.fn(),
498
514
  // The live DOM is ALREADY at the new value when the commit runs — that
499
515
  // is what `setLive` does on every drag frame.
500
516
  patchLive: (v) => patched.push(v),
@@ -8,6 +8,7 @@ import {
8
8
  } from "../utils/sourcePatcher";
9
9
  import {
10
10
  formatTimelineAttributeNumber,
11
+ formatTimelineMediaOffset,
11
12
  type TimelineStackingReorderIntent,
12
13
  } from "../player/components/timelineEditing";
13
14
  import { getElementZIndex } from "../player/lib/layerOrdering";
@@ -15,7 +16,12 @@ import {
15
16
  furthestClipEndFromSource,
16
17
  getTimelineElementIdentity,
17
18
  } from "../player/lib/timelineElementHelpers";
18
- import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
19
+ import {
20
+ saveProjectFilesWithHistory,
21
+ writeProjectFilesWithHistoryInQueue,
22
+ type RecordEditInput,
23
+ } from "../utils/studioFileHistory";
24
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
19
25
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
20
26
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
21
27
  import { readFileContent } from "./timelineTimingSync";
@@ -279,7 +285,7 @@ export function buildTimelineResizeTimingPatch(
279
285
  patched = applyPatchByTarget(patched, target, {
280
286
  type: "attribute",
281
287
  property: pbs.attrName,
282
- value: formatTimelineAttributeNumber(pbs.value),
288
+ value: formatTimelineMediaOffset(pbs.value),
283
289
  });
284
290
  }
285
291
  // Content-driven duration from the PATCHED SOURCE (raw data-duration) —
@@ -396,7 +402,32 @@ export async function persistTimelineBatchEdit(
396
402
  });
397
403
  }
398
404
 
399
- export { applyPatchByTarget, formatTimelineAttributeNumber };
405
+ /** What the file holds for `attr` once queued writes to it land; undefined when unreadable. */
406
+ export async function readSavedAttribute(
407
+ projectId: string | null,
408
+ targetPath: string,
409
+ patchTarget: PatchTarget | null,
410
+ attr: string,
411
+ writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
412
+ ): Promise<string | null | undefined> {
413
+ if (!projectId || !patchTarget) return undefined;
414
+ const html = await serializeStudioFileMutations(writeFile, [targetPath], () =>
415
+ readFileContent(projectId, targetPath),
416
+ ).catch(() => null);
417
+ return html === null ? undefined : readTargetAttribute(html, patchTarget, attr);
418
+ }
419
+
420
+ /** What `html` holds for `attr` on the target; undefined when the target is not in it. */
421
+ export function readTargetAttribute(
422
+ html: string,
423
+ patchTarget: PatchTarget,
424
+ attr: string,
425
+ ): string | null | undefined {
426
+ if (readTagSnippetByTarget(html, patchTarget) === undefined) return undefined;
427
+ return readAttributeByTarget(html, patchTarget, attr) ?? null;
428
+ }
429
+
430
+ export { applyPatchByTarget, formatTimelineAttributeNumber, formatTimelineMediaOffset };
400
431
 
401
432
  export { patchDocumentRootDuration } from "./timelineEditingGsap";
402
433
 
@@ -412,6 +443,8 @@ export interface PersistElementAttributeInput {
412
443
  pendingTimelineEditPathRef: { current: Set<string> };
413
444
  /** Write the attribute directly on the live preview DOM node. */
414
445
  patchLive: (value: string | null) => void;
446
+ /** What the file held for the attribute, read inside the queue before this write. */
447
+ onFileRead: (value: string | null) => void;
415
448
  }
416
449
 
417
450
  /**
@@ -433,47 +466,40 @@ export async function persistElementAttribute({
433
466
  recordEdit,
434
467
  pendingTimelineEditPathRef,
435
468
  patchLive,
469
+ onFileRead,
436
470
  }: PersistElementAttributeInput): Promise<string[]> {
437
- // Resolve the target BEFORE patching the live DOM. The optimistic patch used
438
- // to run first, and only the save was wrapped in the unwind — so an
439
- // unresolvable target threw with the live preview (and, through the callers'
440
- // catch, the store mirrored off it) holding a value that never reached disk.
441
- // The write then read as successful until a reload dropped it.
442
- const before = await readFileContent(projectId, targetPath);
443
- if (readTagSnippetByTarget(before, patchTarget) === undefined) {
444
- throw new Error(`Unable to patch element in ${targetPath}`);
445
- }
446
- // The unwind value comes from the FILE, not from `readLive()`.
447
- //
448
- // Every live-write caller patches the DOM before committing — a fader drag is
449
- // `setLive` per frame, hovering a preset auditions the whole chain — so by the
450
- // time this runs the live DOM already holds the in-progress value. Reading it
451
- // here made `previousValue === value`, so the unwind below was a no-op, and
452
- // `setQuiet`'s catch (which deliberately re-mirrors the store from the live
453
- // DOM) then mirrored that same never-saved value. The group audibly had the
454
- // preset, the panel agreed, and a reload dropped it — the failure class the
455
- // target check above was added to close, still open on the live-write path.
456
- const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
457
- patchLive(value);
471
+ // Joins the file's mutation queue before reading, so saves land in the order they start
472
+ // and each patches what the save before it wrote. Resolve the target before patching
473
+ // the live DOM, so an unresolvable target never leaves an unsaved preview.
474
+ return serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
475
+ const before = await readFileContent(projectId, targetPath);
476
+ if (readTagSnippetByTarget(before, patchTarget) === undefined) {
477
+ throw new Error(`Unable to patch element in ${targetPath}`);
478
+ }
479
+ // Unwind to the file's value: live writers already patched the DOM, so `readLive()`
480
+ // would equal `value` and a failed save would keep a never-saved preview.
481
+ const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
482
+ onFileRead(previousValue);
483
+ patchLive(value);
458
484
 
459
- const operation: PatchOperation = { type: "attribute", property: attr, value };
460
- const patched = applyPatchByTarget(before, patchTarget, operation);
485
+ const operation: PatchOperation = { type: "attribute", property: attr, value };
486
+ const patched = applyPatchByTarget(before, patchTarget, operation);
461
487
 
462
- pendingTimelineEditPathRef.current.add(targetPath);
463
- try {
464
- const changedPaths = await saveProjectFilesWithHistory({
465
- projectId,
466
- label,
467
- files: { [targetPath]: patched },
468
- readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
469
- writeFile: writeProjectFile,
470
- recordEdit,
471
- });
472
- return changedPaths;
473
- } catch (error) {
474
- // The optimistic live write already ran; unwind it on a save failure so
475
- // the preview doesn't show a value that never reached disk.
476
- patchLive(previousValue);
477
- throw error;
478
- }
488
+ pendingTimelineEditPathRef.current.add(targetPath);
489
+ try {
490
+ return await writeProjectFilesWithHistoryInQueue({
491
+ projectId,
492
+ label,
493
+ files: { [targetPath]: patched },
494
+ readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
495
+ writeFile: writeProjectFile,
496
+ recordEdit,
497
+ });
498
+ } catch (error) {
499
+ // The optimistic live write already ran; unwind it on a save failure so
500
+ // the preview doesn't show a value that never reached disk.
501
+ patchLive(previousValue);
502
+ throw error;
503
+ }
504
+ });
479
505
  }