@hyperframes/studio 0.8.79 → 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-DBKySIo7.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-CJP92F4-.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-ORZXYB0k.js → index-J9HXe7mU.js} +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1491 -1177
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -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 +4 -0
- 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 +81 -47
- package/src/hooks/timelineEditPermission.ts +9 -1
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +82 -36
- 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/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useTimelineEditing.ts +19 -16
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +23 -0
- package/src/player/components/Player.tsx +4 -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/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +2 -0
- 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/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/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
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/core": "0.8.
|
|
56
|
-
"@hyperframes/parsers": "0.8.
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
60
|
-
"@hyperframes/studio-server": "0.8.
|
|
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,
|
|
@@ -260,7 +261,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
260
261
|
handleDuplicate,
|
|
261
262
|
onResetKeyframes: () => resetKeyframesRef.current(),
|
|
262
263
|
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
|
263
|
-
onAfterUndoRedo: () =>
|
|
264
|
+
onAfterUndoRedo: (restore) => {
|
|
265
|
+
invalidateGsapCacheRef.current();
|
|
266
|
+
timelineEditing.restoreLiveLanes(restore);
|
|
267
|
+
},
|
|
264
268
|
onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
|
|
265
269
|
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
|
|
266
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
|
[],
|
|
@@ -41,6 +41,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
41
41
|
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
|
42
42
|
setAudioGroupAttribute: {
|
|
43
43
|
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
44
|
+
revertLive?: (groupId: string, attr: string) => void;
|
|
44
45
|
setQuiet: (
|
|
45
46
|
groupId: string,
|
|
46
47
|
attr: string,
|
|
@@ -61,6 +62,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
61
62
|
/** C1's single-clip FX write, addressed by the clip itself. */
|
|
62
63
|
setElementFxAttribute?: {
|
|
63
64
|
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
|
65
|
+
revertLive?: (element: TimelineElement, attr: string) => void;
|
|
64
66
|
setQuiet: (
|
|
65
67
|
element: TimelineElement,
|
|
66
68
|
attr: string,
|
|
@@ -216,9 +218,11 @@ export function useTimelineEditCallbacks({
|
|
|
216
218
|
onToggleTrackHidden: handleToggleTrackHidden,
|
|
217
219
|
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
|
218
220
|
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
|
221
|
+
onRevertAudioGroupAttributeLive: setAudioGroupAttribute.revertLive,
|
|
219
222
|
onGroupClips: handleGroupClips,
|
|
220
223
|
onSetElementAttributeLive: setElementFxAttribute?.setLive,
|
|
221
224
|
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
|
|
225
|
+
onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
|
|
222
226
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
223
227
|
onSplitElement: handleTimelineElementSplit,
|
|
224
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,5 +1,9 @@
|
|
|
1
|
-
import { useCallback
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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 ??
|
|
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),
|