@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.
- package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /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.
|
|
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/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/player": "0.8.
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/
|
|
60
|
-
"@hyperframes/
|
|
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.
|
|
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: () =>
|
|
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. */
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
180
|
-
|
|
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
|
|
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: (
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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 (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
}
|