@hyperframes/studio 0.8.78 → 0.8.80

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 (99) hide show
  1. package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
  2. package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
  3. package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
  4. package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
  5. package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  6. package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  7. package/dist/index.d.ts +40 -16
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1918 -1408
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -1
  13. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  14. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  15. package/src/components/editor/manualEdits.ts +1 -0
  16. package/src/components/editor/manualEditsParsing.ts +4 -0
  17. package/src/components/editor/useFxAudition.ts +3 -3
  18. package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
  19. package/src/contexts/DomEditContext.tsx +4 -0
  20. package/src/contexts/TimelineEditContext.tsx +2 -0
  21. package/src/hooks/liveLanes.ts +147 -0
  22. package/src/hooks/timelineAudioGroupVolume.ts +97 -53
  23. package/src/hooks/timelineEditPermission.ts +55 -13
  24. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  25. package/src/hooks/timelineEditingHelpers.ts +69 -43
  26. package/src/hooks/timelineElementFxAttribute.ts +92 -40
  27. package/src/hooks/useAppHotkeys.ts +1 -1
  28. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  29. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  30. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  31. package/src/hooks/useBlockHandlers.ts +7 -4
  32. package/src/hooks/useClipboard.source.test.ts +340 -0
  33. package/src/hooks/useClipboard.ts +141 -62
  34. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  35. package/src/hooks/useDomEditSession.ts +1 -0
  36. package/src/hooks/useEditHistoryActions.ts +4 -3
  37. package/src/hooks/useElementPicker.test.ts +172 -0
  38. package/src/hooks/useElementPicker.ts +141 -93
  39. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  40. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  41. package/src/hooks/useFrameCapture.test.tsx +67 -0
  42. package/src/hooks/useFrameCapture.ts +18 -9
  43. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  44. package/src/hooks/usePersistentEditHistory.ts +2 -2
  45. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  46. package/src/hooks/useTimelineEditing.ts +59 -47
  47. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  48. package/src/hooks/useToast.ts +2 -1
  49. package/src/index.ts +1 -0
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +28 -0
  52. package/src/player/components/Player.tsx +5 -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/VideoThumbnail.test.tsx +13 -0
  64. package/src/player/components/VideoThumbnail.tsx +1 -1
  65. package/src/player/components/groupAutomationElement.ts +9 -1
  66. package/src/player/components/timelineCallbacks.ts +5 -2
  67. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  68. package/src/player/components/timelineClipDragPreview.ts +15 -24
  69. package/src/player/components/timelineEditing.test.ts +99 -1
  70. package/src/player/components/timelineEditing.ts +5 -8
  71. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  72. package/src/player/components/timelineGroupEditing.ts +18 -15
  73. package/src/player/components/timelineStackingSync.ts +5 -5
  74. package/src/player/components/timelineZones.ts +2 -3
  75. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  76. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  77. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  78. package/src/player/components/useAutomationLanes.ts +58 -74
  79. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  80. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  81. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  82. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  83. package/src/player/lib/automationStoreSync.ts +30 -3
  84. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  85. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  86. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  87. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  88. package/src/player/lib/timelineDOM.test.ts +28 -8
  89. package/src/player/lib/timelineElementHelpers.ts +3 -4
  90. package/src/player/lib/timelineGroupInfo.ts +23 -23
  91. package/src/utils/authoredSource.ts +92 -0
  92. package/src/utils/gsapSoftReload.test.ts +29 -0
  93. package/src/utils/gsapSoftReload.ts +3 -7
  94. package/src/utils/studioFileHistory.ts +36 -30
  95. package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
  96. package/src/player/components/automationGestureKeys.test.ts +0 -49
  97. package/src/player/components/automationGestureKeys.ts +0 -49
  98. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  99. /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.78",
3
+ "version": "0.8.80",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,12 +52,12 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/parsers": "0.8.78",
56
- "@hyperframes/sdk": "0.8.78",
57
- "@hyperframes/player": "0.8.78",
58
- "@hyperframes/shader-transitions": "0.8.78",
59
- "@hyperframes/studio-server": "0.8.78",
60
- "@hyperframes/core": "0.8.78"
55
+ "@hyperframes/core": "0.8.80",
56
+ "@hyperframes/parsers": "0.8.80",
57
+ "@hyperframes/player": "0.8.80",
58
+ "@hyperframes/sdk": "0.8.80",
59
+ "@hyperframes/shader-transitions": "0.8.80",
60
+ "@hyperframes/studio-server": "0.8.80"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@tailwindcss/vite": "^4.3.3",
@@ -73,7 +73,7 @@
73
73
  "vite": "^6.4.2",
74
74
  "vitest": "^4.1.11",
75
75
  "zustand": "^5.0.0",
76
- "@hyperframes/producer": "0.8.78"
76
+ "@hyperframes/producer": "0.8.80"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "react": "19",
package/src/App.tsx CHANGED
@@ -209,6 +209,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
209
209
  refreshFileTree: fileManager.refreshFileTree,
210
210
  reloadPreview,
211
211
  showToast,
212
+ dismissToast,
212
213
  },
213
214
  previewIframeRef,
214
215
  setRightCollapsed: panelLayout.setRightCollapsed,
@@ -239,6 +240,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
239
240
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
240
241
  handleDomEditElementDelete: domEditDeleteBridge,
241
242
  previewIframeRef,
243
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
242
244
  });
243
245
  const appHotkeys = useAppHotkeys({
244
246
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
@@ -259,7 +261,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
259
261
  handleDuplicate,
260
262
  onResetKeyframes: () => resetKeyframesRef.current(),
261
263
  onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
262
- onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
264
+ onAfterUndoRedo: (restore) => {
265
+ invalidateGsapCacheRef.current();
266
+ timelineEditing.restoreLiveLanes(restore);
267
+ },
263
268
  onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
264
269
  onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
265
270
  activeCompPath,
@@ -87,7 +87,7 @@ describe("TimelineFxPopover", () => {
87
87
  act(() => {
88
88
  button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
89
89
  });
90
- expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
90
+ expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN, true);
91
91
  });
92
92
 
93
93
  it("Escape closes without letting the keystroke propagate past the popover", () => {
@@ -75,7 +75,7 @@ export interface TimelineFxPopoverProps {
75
75
  * selected clip's attribute — the caller resolves which). */
76
76
  onChainChange: (next: HfAudioFxChain) => void;
77
77
  /** Preview a hypothetical chain on the running graph without persisting. */
78
- onChainPreview?: (next: HfAudioFxChain) => void;
78
+ onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
79
79
  onAuditionTransport?: (on: boolean) => void;
80
80
  /** Select the target the way clicking it in the timeline does, and ensure
81
81
  * the property panel's Audio FX group is expanded. */
@@ -11,6 +11,7 @@ export {
11
11
 
12
12
  export {
13
13
  readFileChangeAffectedCompositions,
14
+ readFileChangeAffectsPreview,
14
15
  readFileChangeField,
15
16
  readStudioFileChangePath,
16
17
  } from "./manualEditsParsing";
@@ -34,6 +34,10 @@ export function readStudioFileChangePath(payload: unknown): string | null {
34
34
  return path === null ? null : normalizeStudioFileChangePath(path);
35
35
  }
36
36
 
37
+ export function readFileChangeAffectsPreview(payload: unknown): boolean {
38
+ return asPayloadRecord(payload)?.affectsPreview !== false;
39
+ }
40
+
37
41
  /**
38
42
  * The compositions whose thumbnails a change can alter, or `null` for all of them. Anything
39
43
  * but a list of paths (an older server, the Vite dev host) reads as "all".
@@ -11,7 +11,7 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
11
11
 
12
12
  export function useFxAudition(
13
13
  chain: HfAudioFxChain,
14
- onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
14
+ onChainPreview: ((chain: HfAudioFxChain, ended?: boolean) => void) | undefined,
15
15
  onAuditionTransport: ((on: boolean) => void) | undefined,
16
16
  ) {
17
17
  /**
@@ -45,7 +45,7 @@ export function useFxAudition(
45
45
  // Stop before reverting, for the mirror of that reason — the last thing
46
46
  // heard should be the preset, not a frame of the chain coming back.
47
47
  onAuditionTransport?.(false);
48
- onChainPreview(auditionBase.current);
48
+ onChainPreview(auditionBase.current, true);
49
49
  auditionBase.current = null;
50
50
  }
51
51
  },
@@ -96,7 +96,7 @@ export function useFxAudition(
96
96
  () => () => {
97
97
  if (auditionBase.current) {
98
98
  transportRef.current?.(false);
99
- previewRef.current?.(auditionBase.current);
99
+ previewRef.current?.(auditionBase.current, true);
100
100
  }
101
101
  },
102
102
  [],
@@ -1,3 +1,4 @@
1
+ import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
1
2
  import { useCallback, useMemo } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import type { TimelineElement } from "../../player";
@@ -40,7 +41,13 @@ export interface TimelineEditCallbackDeps {
40
41
  handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
41
42
  setAudioGroupAttribute: {
42
43
  setLive: (groupId: string, attr: string, value: string | null) => void;
43
- setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
44
+ revertLive?: (groupId: string, attr: string) => void;
45
+ setQuiet: (
46
+ groupId: string,
47
+ attr: string,
48
+ value: string | null,
49
+ label: string,
50
+ ) => Promise<TimelineEditOutcome | void>;
44
51
  };
45
52
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
46
53
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -55,12 +62,13 @@ export interface TimelineEditCallbackDeps {
55
62
  /** C1's single-clip FX write, addressed by the clip itself. */
56
63
  setElementFxAttribute?: {
57
64
  setLive: (element: TimelineElement, attr: string, value: string | null) => void;
65
+ revertLive?: (element: TimelineElement, attr: string) => void;
58
66
  setQuiet: (
59
67
  element: TimelineElement,
60
68
  attr: string,
61
69
  value: string | null,
62
70
  label: string,
63
- ) => Promise<void>;
71
+ ) => Promise<TimelineEditOutcome | void>;
64
72
  };
65
73
  }
66
74
 
@@ -210,9 +218,11 @@ export function useTimelineEditCallbacks({
210
218
  onToggleTrackHidden: handleToggleTrackHidden,
211
219
  onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
212
220
  onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
221
+ onRevertAudioGroupAttributeLive: setAudioGroupAttribute.revertLive,
213
222
  onGroupClips: handleGroupClips,
214
223
  onSetElementAttributeLive: setElementFxAttribute?.setLive,
215
224
  onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
225
+ onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
216
226
  onBlockedEditAttempt: handleBlockedTimelineEdit,
217
227
  onSplitElement: handleTimelineElementSplit,
218
228
  onRazorSplit: handleRazorSplit,
@@ -13,6 +13,7 @@ export interface DomEditActionsValue extends Pick<
13
13
  | "handlePreviewCanvasPointerLeave"
14
14
  | "applyDomSelection"
15
15
  | "clearDomSelection"
16
+ | "refreshDomEditSelectionFromPreview"
16
17
  | "handleDomStyleCommit"
17
18
  | "handleDomStyleCommitForSelection"
18
19
  | "handleDomAttributeCommit"
@@ -155,6 +156,7 @@ export function DomEditProvider({
155
156
  handlePreviewCanvasPointerLeave,
156
157
  applyDomSelection,
157
158
  clearDomSelection,
159
+ refreshDomEditSelectionFromPreview,
158
160
  handleDomStyleCommit,
159
161
  handleDomStyleCommitForSelection,
160
162
  handleDomAttributeCommit,
@@ -248,6 +250,7 @@ export function DomEditProvider({
248
250
  handlePreviewCanvasPointerLeave,
249
251
  applyDomSelection,
250
252
  clearDomSelection,
253
+ refreshDomEditSelectionFromPreview,
251
254
  handleDomStyleCommit,
252
255
  handleDomStyleCommitForSelection,
253
256
  handleDomAttributeCommit,
@@ -322,6 +325,7 @@ export function DomEditProvider({
322
325
  handlePreviewCanvasPointerLeave,
323
326
  applyDomSelection,
324
327
  clearDomSelection,
328
+ refreshDomEditSelectionFromPreview,
325
329
  handleDomStyleCommit,
326
330
  handleDomStyleCommitForSelection,
327
331
  handleDomAttributeCommit,
@@ -44,9 +44,11 @@ export function TimelineEditProvider({
44
44
  value.onToggleTrackHidden,
45
45
  value.onSetAudioGroupAttributeLive,
46
46
  value.onSetAudioGroupAttributeQuiet,
47
+ value.onRevertAudioGroupAttributeLive,
47
48
  value.onGroupClips,
48
49
  value.onSetElementAttributeLive,
49
50
  value.onSetElementAttributeQuiet,
51
+ value.onRevertElementAttributeLive,
50
52
  value.onBlockedEditAttempt,
51
53
  value.onSplitElement,
52
54
  value.onRazorSplit,
@@ -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,4 +1,9 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback } from "react";
2
+ import {
3
+ failedTimelineSave,
4
+ projectForTimelineSave,
5
+ type TimelineEditOutcome,
6
+ } from "./timelineEditPermission";
2
7
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
8
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
4
9
  import { usePlayerStore } from "../player";
@@ -7,8 +12,10 @@ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
7
12
  import {
8
13
  buildPatchTarget,
9
14
  persistElementAttribute,
15
+ readSavedAttribute,
10
16
  type RecordEditInput,
11
17
  } from "./timelineEditingHelpers";
18
+ import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
12
19
  import type {
13
20
  MutableRef,
14
21
  UseTimelineElementVisibilityEditingInput,
@@ -145,6 +152,8 @@ interface SetAudioGroupAttributeInput {
145
152
  value: string | null;
146
153
  label: string;
147
154
  previewIframe: HTMLIFrameElement | null;
155
+ patchLive: (value: string | null) => void;
156
+ onFileRead: (value: string | null) => void;
148
157
  writeProjectFile: (path: string, content: string) => Promise<void>;
149
158
  recordEdit: (input: RecordEditInput) => Promise<void>;
150
159
  pendingTimelineEditPathRef: MutableRef<Set<string>>;
@@ -156,6 +165,28 @@ interface SetAudioGroupAttributeInput {
156
165
  * `createAudioGroupAndAssignMembers`'s save shape but for a single element
157
166
  * and attribute rather than a member-assignment sweep.
158
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
+
159
190
  async function setAudioGroupAttribute({
160
191
  projectId,
161
192
  activeCompPath,
@@ -164,10 +195,12 @@ async function setAudioGroupAttribute({
164
195
  value,
165
196
  label,
166
197
  previewIframe,
198
+ patchLive,
199
+ onFileRead,
167
200
  writeProjectFile,
168
201
  recordEdit,
169
202
  pendingTimelineEditPathRef,
170
- }: SetAudioGroupAttributeInput): Promise<string[]> {
203
+ }: SetAudioGroupAttributeInput): Promise<string[] | null> {
171
204
  // The file that actually CONTAINS the group element, not just the active
172
205
  // composition. A hand-authored sub-composition can declare both the members
173
206
  // and their `<hf-audio-group>`, and until sub-comp children inherited
@@ -176,10 +209,8 @@ async function setAudioGroupAttribute({
176
209
  // means `readTagSnippetByTarget` finds nothing and every mute, fader move and
177
210
  // FX preset throws "Unable to patch element in index.html". Every sibling
178
211
  // timeline writer already routes `element.sourceFile || activeCompPath`.
179
- const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
180
- const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
181
- const patchTarget = buildPatchTarget({ domId: groupId });
182
- if (!patchTarget) return [];
212
+ const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath);
213
+ if (!patchTarget) return null;
183
214
 
184
215
  return persistElementAttribute({
185
216
  projectId,
@@ -191,7 +222,8 @@ async function setAudioGroupAttribute({
191
222
  writeProjectFile,
192
223
  recordEdit,
193
224
  pendingTimelineEditPathRef,
194
- patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
225
+ patchLive,
226
+ onFileRead,
195
227
  });
196
228
  }
197
229
 
@@ -213,55 +245,73 @@ export function useSetAudioGroupAttribute({
213
245
  isRecordingRef,
214
246
  }: UseTimelineElementVisibilityEditingInput): {
215
247
  setLive: (groupId: string, attr: string, value: string | null) => void;
216
- setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
248
+ setQuiet: (
249
+ groupId: string,
250
+ attr: string,
251
+ value: string | null,
252
+ label: string,
253
+ ) => Promise<TimelineEditOutcome>;
217
254
  revertLive: (groupId: string, attr: string) => void;
255
+ restoreLive: (restore: LiveLaneRestore) => void;
218
256
  } {
219
- const liveBeforeRef = useRef(new Map<string, string | null>());
257
+ const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
220
258
  const setLive = useCallback(
221
259
  (groupId: string, attr: string, value: string | null) => {
222
260
  const key = audioGroupAttributeLiveKey(groupId, attr);
223
261
  const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
224
- if (!liveBeforeRef.current.has(key)) {
225
- liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
226
- }
262
+ liveLanes.preview(
263
+ key,
264
+ () => target?.getAttribute(attr) ?? null,
265
+ groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr),
266
+ );
227
267
  patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
228
268
  // Live too, not just on commit: a fader drag is `setLive` per frame and
229
269
  // `setQuiet` once on release, so without this the strip's own readout
230
270
  // fights the drag.
231
271
  syncStoredGroupAttribute(groupId, attr, value);
232
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
+ }),
233
281
  [previewIframeRef],
234
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
+ );
235
288
  const revertLive = useCallback(
236
- (groupId: string, attr: string) => {
237
- const key = audioGroupAttributeLiveKey(groupId, attr);
238
- if (!liveBeforeRef.current.has(key)) return;
239
- patchLiveGroupAttribute(
240
- previewIframeRef.current,
241
- groupId,
242
- attr,
243
- liveBeforeRef.current.get(key) ?? null,
244
- );
245
- syncStoredGroupAttribute(
246
- groupId,
247
- attr,
248
- previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
249
- null,
250
- );
251
- liveBeforeRef.current.delete(key);
252
- },
253
- [previewIframeRef],
289
+ (groupId: string, attr: string) =>
290
+ liveLanes.revert(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr)),
291
+ [liveLanes, laneApply],
254
292
  );
255
293
  const setQuiet = useCallback(
256
- async (groupId: string, attr: string, value: string | null, label: string) => {
257
- if (isRecordingRef?.current) {
258
- showToast("Cannot edit timeline while recording", "error");
259
- return;
260
- }
261
- const pid = projectIdRef.current;
262
- if (!pid) return;
294
+ async (
295
+ groupId: string,
296
+ attr: string,
297
+ value: string | null,
298
+ label: string,
299
+ ): Promise<TimelineEditOutcome> => {
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);
263
313
  try {
264
- await setAudioGroupAttribute({
314
+ const written = await setAudioGroupAttribute({
265
315
  projectId: pid,
266
316
  activeCompPath,
267
317
  groupId,
@@ -269,30 +319,24 @@ export function useSetAudioGroupAttribute({
269
319
  value,
270
320
  label,
271
321
  previewIframe: previewIframeRef.current,
322
+ patchLive: live.preview,
323
+ onFileRead: live.read,
272
324
  writeProjectFile,
273
325
  recordEdit,
274
326
  pendingTimelineEditPathRef,
275
327
  });
276
- liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
277
- 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);
331
+ return { status: "saved" };
278
332
  } catch (error) {
279
- // `persistElementAttribute` leaves the live DOM at the previous value
280
- // however it failed — it unwinds a failed save, and an unresolvable
281
- // target now throws before patching at all. But `setLive` mirrored the
282
- // in-progress value into the store on every drag frame — so without
283
- // this the fader reads 0.4 while
284
- // the preview and the file are both back at 1.0, and nothing re-parses
285
- // to correct it (a live patch causing no parse is this mirror's whole
286
- // premise). Re-mirror from the DOM, which is now authoritative again.
287
- const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
288
- syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
289
333
  console.error("[Timeline] Failed to set group attribute", error);
290
334
  const message = error instanceof Error ? error.message : "Failed to update group";
291
- showToast(message);
292
- liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
335
+ return unsaved(failedTimelineSave(message, showToast));
293
336
  }
294
337
  },
295
338
  [
339
+ claimLive,
296
340
  activeCompPath,
297
341
  previewIframeRef,
298
342
  writeProjectFile,
@@ -303,5 +347,5 @@ export function useSetAudioGroupAttribute({
303
347
  projectIdRef,
304
348
  ],
305
349
  );
306
- return { setLive, setQuiet, revertLive };
350
+ return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
307
351
  }