@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
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "../utils/sourcePatcher";
|
|
9
9
|
import {
|
|
10
10
|
formatTimelineAttributeNumber,
|
|
11
|
+
formatTimelineMediaOffset,
|
|
11
12
|
type TimelineStackingReorderIntent,
|
|
12
13
|
} from "../player/components/timelineEditing";
|
|
13
14
|
import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
@@ -15,7 +16,12 @@ import {
|
|
|
15
16
|
furthestClipEndFromSource,
|
|
16
17
|
getTimelineElementIdentity,
|
|
17
18
|
} from "../player/lib/timelineElementHelpers";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
saveProjectFilesWithHistory,
|
|
21
|
+
writeProjectFilesWithHistoryInQueue,
|
|
22
|
+
type RecordEditInput,
|
|
23
|
+
} from "../utils/studioFileHistory";
|
|
24
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
19
25
|
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
|
20
26
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
21
27
|
import { readFileContent } from "./timelineTimingSync";
|
|
@@ -279,7 +285,7 @@ export function buildTimelineResizeTimingPatch(
|
|
|
279
285
|
patched = applyPatchByTarget(patched, target, {
|
|
280
286
|
type: "attribute",
|
|
281
287
|
property: pbs.attrName,
|
|
282
|
-
value:
|
|
288
|
+
value: formatTimelineMediaOffset(pbs.value),
|
|
283
289
|
});
|
|
284
290
|
}
|
|
285
291
|
// Content-driven duration from the PATCHED SOURCE (raw data-duration) —
|
|
@@ -396,7 +402,32 @@ export async function persistTimelineBatchEdit(
|
|
|
396
402
|
});
|
|
397
403
|
}
|
|
398
404
|
|
|
399
|
-
|
|
405
|
+
/** What the file holds for `attr` once queued writes to it land; undefined when unreadable. */
|
|
406
|
+
export async function readSavedAttribute(
|
|
407
|
+
projectId: string | null,
|
|
408
|
+
targetPath: string,
|
|
409
|
+
patchTarget: PatchTarget | null,
|
|
410
|
+
attr: string,
|
|
411
|
+
writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
412
|
+
): Promise<string | null | undefined> {
|
|
413
|
+
if (!projectId || !patchTarget) return undefined;
|
|
414
|
+
const html = await serializeStudioFileMutations(writeFile, [targetPath], () =>
|
|
415
|
+
readFileContent(projectId, targetPath),
|
|
416
|
+
).catch(() => null);
|
|
417
|
+
return html === null ? undefined : readTargetAttribute(html, patchTarget, attr);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** What `html` holds for `attr` on the target; undefined when the target is not in it. */
|
|
421
|
+
export function readTargetAttribute(
|
|
422
|
+
html: string,
|
|
423
|
+
patchTarget: PatchTarget,
|
|
424
|
+
attr: string,
|
|
425
|
+
): string | null | undefined {
|
|
426
|
+
if (readTagSnippetByTarget(html, patchTarget) === undefined) return undefined;
|
|
427
|
+
return readAttributeByTarget(html, patchTarget, attr) ?? null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export { applyPatchByTarget, formatTimelineAttributeNumber, formatTimelineMediaOffset };
|
|
400
431
|
|
|
401
432
|
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
402
433
|
|
|
@@ -412,6 +443,8 @@ export interface PersistElementAttributeInput {
|
|
|
412
443
|
pendingTimelineEditPathRef: { current: Set<string> };
|
|
413
444
|
/** Write the attribute directly on the live preview DOM node. */
|
|
414
445
|
patchLive: (value: string | null) => void;
|
|
446
|
+
/** What the file held for the attribute, read inside the queue before this write. */
|
|
447
|
+
onFileRead: (value: string | null) => void;
|
|
415
448
|
}
|
|
416
449
|
|
|
417
450
|
/**
|
|
@@ -433,47 +466,40 @@ export async function persistElementAttribute({
|
|
|
433
466
|
recordEdit,
|
|
434
467
|
pendingTimelineEditPathRef,
|
|
435
468
|
patchLive,
|
|
469
|
+
onFileRead,
|
|
436
470
|
}: PersistElementAttributeInput): Promise<string[]> {
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
// time this runs the live DOM already holds the in-progress value. Reading it
|
|
451
|
-
// here made `previousValue === value`, so the unwind below was a no-op, and
|
|
452
|
-
// `setQuiet`'s catch (which deliberately re-mirrors the store from the live
|
|
453
|
-
// DOM) then mirrored that same never-saved value. The group audibly had the
|
|
454
|
-
// preset, the panel agreed, and a reload dropped it — the failure class the
|
|
455
|
-
// target check above was added to close, still open on the live-write path.
|
|
456
|
-
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
|
|
457
|
-
patchLive(value);
|
|
471
|
+
// Joins the file's mutation queue before reading, so saves land in the order they start
|
|
472
|
+
// and each patches what the save before it wrote. Resolve the target before patching
|
|
473
|
+
// the live DOM, so an unresolvable target never leaves an unsaved preview.
|
|
474
|
+
return serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
|
475
|
+
const before = await readFileContent(projectId, targetPath);
|
|
476
|
+
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
|
477
|
+
throw new Error(`Unable to patch element in ${targetPath}`);
|
|
478
|
+
}
|
|
479
|
+
// Unwind to the file's value: live writers already patched the DOM, so `readLive()`
|
|
480
|
+
// would equal `value` and a failed save would keep a never-saved preview.
|
|
481
|
+
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
|
|
482
|
+
onFileRead(previousValue);
|
|
483
|
+
patchLive(value);
|
|
458
484
|
|
|
459
|
-
|
|
460
|
-
|
|
485
|
+
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
|
486
|
+
const patched = applyPatchByTarget(before, patchTarget, operation);
|
|
461
487
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
488
|
+
pendingTimelineEditPathRef.current.add(targetPath);
|
|
489
|
+
try {
|
|
490
|
+
return await writeProjectFilesWithHistoryInQueue({
|
|
491
|
+
projectId,
|
|
492
|
+
label,
|
|
493
|
+
files: { [targetPath]: patched },
|
|
494
|
+
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
|
495
|
+
writeFile: writeProjectFile,
|
|
496
|
+
recordEdit,
|
|
497
|
+
});
|
|
498
|
+
} catch (error) {
|
|
499
|
+
// The optimistic live write already ran; unwind it on a save failure so
|
|
500
|
+
// the preview doesn't show a value that never reached disk.
|
|
501
|
+
patchLive(previousValue);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
479
505
|
}
|
|
@@ -7,18 +7,25 @@
|
|
|
7
7
|
* shared core `setAudioGroupAttribute` also uses.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useCallback
|
|
10
|
+
import { useCallback } from "react";
|
|
11
11
|
import type { TimelineElement } from "../player";
|
|
12
12
|
import {
|
|
13
13
|
buildPatchTarget,
|
|
14
14
|
findTimelineElementInIframe,
|
|
15
15
|
persistElementAttribute,
|
|
16
|
+
readSavedAttribute,
|
|
16
17
|
} from "./timelineEditingHelpers";
|
|
18
|
+
import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
|
|
17
19
|
import type {
|
|
18
20
|
MutableRef,
|
|
19
21
|
UseTimelineElementVisibilityEditingInput,
|
|
20
22
|
} from "./timelineTrackVisibility";
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
failedTimelineSave,
|
|
25
|
+
projectForTimelineSave,
|
|
26
|
+
type TimelineEditOutcome,
|
|
27
|
+
} from "./timelineEditPermission";
|
|
28
|
+
import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
|
|
22
29
|
|
|
23
30
|
function patchLiveElementAttribute(
|
|
24
31
|
iframe: HTMLIFrameElement | null,
|
|
@@ -41,6 +48,22 @@ function elementAttributeLiveKey(
|
|
|
41
48
|
return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
function elementSaveTarget(element: TimelineElement, activeCompPath: string | null) {
|
|
52
|
+
return {
|
|
53
|
+
targetPath: element.sourceFile || activeCompPath || "index.html",
|
|
54
|
+
patchTarget: buildPatchTarget(element),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function elementLiveSource(
|
|
59
|
+
element: TimelineElement,
|
|
60
|
+
activeCompPath: string | null,
|
|
61
|
+
attr: string,
|
|
62
|
+
): LiveLaneSource {
|
|
63
|
+
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
|
64
|
+
return { path: targetPath, target: patchTarget, attr };
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
interface SetElementAttributeInput {
|
|
45
68
|
projectId: string;
|
|
46
69
|
activeCompPath: string | null;
|
|
@@ -48,7 +71,8 @@ interface SetElementAttributeInput {
|
|
|
48
71
|
attr: string;
|
|
49
72
|
value: string | null;
|
|
50
73
|
label: string;
|
|
51
|
-
|
|
74
|
+
patchLive: (value: string | null) => void;
|
|
75
|
+
onFileRead: (value: string | null) => void;
|
|
52
76
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
53
77
|
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
|
|
54
78
|
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
@@ -61,13 +85,13 @@ async function setElementAttribute({
|
|
|
61
85
|
attr,
|
|
62
86
|
value,
|
|
63
87
|
label,
|
|
64
|
-
|
|
88
|
+
patchLive,
|
|
89
|
+
onFileRead,
|
|
65
90
|
writeProjectFile,
|
|
66
91
|
recordEdit,
|
|
67
92
|
pendingTimelineEditPathRef,
|
|
68
93
|
}: SetElementAttributeInput): Promise<string[] | null> {
|
|
69
|
-
const targetPath = element
|
|
70
|
-
const patchTarget = buildPatchTarget(element);
|
|
94
|
+
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
|
71
95
|
if (!patchTarget) return null;
|
|
72
96
|
|
|
73
97
|
return persistElementAttribute({
|
|
@@ -80,7 +104,8 @@ async function setElementAttribute({
|
|
|
80
104
|
writeProjectFile,
|
|
81
105
|
recordEdit,
|
|
82
106
|
pendingTimelineEditPathRef,
|
|
83
|
-
patchLive
|
|
107
|
+
patchLive,
|
|
108
|
+
onFileRead,
|
|
84
109
|
});
|
|
85
110
|
}
|
|
86
111
|
|
|
@@ -102,33 +127,46 @@ export function useSetElementAttribute({
|
|
|
102
127
|
label: string,
|
|
103
128
|
) => Promise<TimelineEditOutcome>;
|
|
104
129
|
revertLive: (element: TimelineElement, attr: string) => void;
|
|
130
|
+
restoreLive: (restore: LiveLaneRestore) => void;
|
|
105
131
|
} {
|
|
106
|
-
const
|
|
132
|
+
const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
|
|
107
133
|
const setLive = useCallback(
|
|
108
134
|
(element: TimelineElement, attr: string, value: string | null) => {
|
|
109
135
|
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
110
136
|
const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
liveLanes.preview(
|
|
138
|
+
key,
|
|
139
|
+
() => target?.getAttribute(attr) ?? null,
|
|
140
|
+
elementLiveSource(element, activeCompPath, attr),
|
|
141
|
+
);
|
|
114
142
|
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
|
115
143
|
},
|
|
144
|
+
[liveLanes, previewIframeRef, activeCompPath],
|
|
145
|
+
);
|
|
146
|
+
const laneApply = useCallback(
|
|
147
|
+
(element: TimelineElement, attr: string) => ({
|
|
148
|
+
preview: (value: string | null) =>
|
|
149
|
+
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath),
|
|
150
|
+
store: (value: string | null) => syncStoredElementAttribute(element, attr, value),
|
|
151
|
+
}),
|
|
116
152
|
[previewIframeRef, activeCompPath],
|
|
117
153
|
);
|
|
154
|
+
const claimLive = useCallback(
|
|
155
|
+
(element: TimelineElement, attr: string) =>
|
|
156
|
+
liveLanes.claim(
|
|
157
|
+
elementAttributeLiveKey(element, activeCompPath, attr),
|
|
158
|
+
laneApply(element, attr),
|
|
159
|
+
elementLiveSource(element, activeCompPath, attr),
|
|
160
|
+
),
|
|
161
|
+
[liveLanes, laneApply, activeCompPath],
|
|
162
|
+
);
|
|
118
163
|
const revertLive = useCallback(
|
|
119
|
-
(element: TimelineElement, attr: string) =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
attr,
|
|
126
|
-
liveBeforeRef.current.get(key) ?? null,
|
|
127
|
-
activeCompPath,
|
|
128
|
-
);
|
|
129
|
-
liveBeforeRef.current.delete(key);
|
|
130
|
-
},
|
|
131
|
-
[previewIframeRef, activeCompPath],
|
|
164
|
+
(element: TimelineElement, attr: string) =>
|
|
165
|
+
liveLanes.revert(
|
|
166
|
+
elementAttributeLiveKey(element, activeCompPath, attr),
|
|
167
|
+
laneApply(element, attr),
|
|
168
|
+
),
|
|
169
|
+
[liveLanes, laneApply, activeCompPath],
|
|
132
170
|
);
|
|
133
171
|
const setQuiet = useCallback(
|
|
134
172
|
async (
|
|
@@ -137,9 +175,17 @@ export function useSetElementAttribute({
|
|
|
137
175
|
value: string | null,
|
|
138
176
|
label: string,
|
|
139
177
|
): Promise<TimelineEditOutcome> => {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const
|
|
178
|
+
const project = projectIdRef.current;
|
|
179
|
+
const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast);
|
|
180
|
+
const live = claimLive(element, attr);
|
|
181
|
+
const unsaved = async (outcome: TimelineEditOutcome): Promise<TimelineEditOutcome> => {
|
|
182
|
+
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
|
183
|
+
live.settle(
|
|
184
|
+
await readSavedAttribute(project, targetPath, patchTarget, attr, writeProjectFile),
|
|
185
|
+
);
|
|
186
|
+
return outcome;
|
|
187
|
+
};
|
|
188
|
+
if (typeof pid !== "string") return unsaved(pid);
|
|
143
189
|
try {
|
|
144
190
|
const written = await setElementAttribute({
|
|
145
191
|
projectId: pid,
|
|
@@ -148,25 +194,25 @@ export function useSetElementAttribute({
|
|
|
148
194
|
attr,
|
|
149
195
|
value,
|
|
150
196
|
label,
|
|
151
|
-
|
|
197
|
+
patchLive: live.preview,
|
|
198
|
+
onFileRead: live.read,
|
|
152
199
|
writeProjectFile,
|
|
153
200
|
recordEdit,
|
|
154
201
|
pendingTimelineEditPathRef,
|
|
155
202
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
if (!written)
|
|
204
|
+
return unsaved(failedTimelineSave("This clip has no id to save it by", showToast));
|
|
205
|
+
live.settle(value);
|
|
206
|
+
return { status: "saved" };
|
|
159
207
|
} catch (error) {
|
|
160
208
|
console.error("[Timeline] Failed to set element attribute", error);
|
|
161
209
|
const message = error instanceof Error ? error.message : "Failed to update effect";
|
|
162
|
-
|
|
163
|
-
liveBeforeRef.current.delete(liveKey);
|
|
164
|
-
return { status: "failed", reason: message };
|
|
210
|
+
return unsaved(failedTimelineSave(message, showToast));
|
|
165
211
|
}
|
|
166
212
|
},
|
|
167
213
|
[
|
|
214
|
+
claimLive,
|
|
168
215
|
activeCompPath,
|
|
169
|
-
previewIframeRef,
|
|
170
216
|
writeProjectFile,
|
|
171
217
|
recordEdit,
|
|
172
218
|
pendingTimelineEditPathRef,
|
|
@@ -175,5 +221,5 @@ export function useSetElementAttribute({
|
|
|
175
221
|
projectIdRef,
|
|
176
222
|
],
|
|
177
223
|
);
|
|
178
|
-
return { setLive, setQuiet, revertLive };
|
|
224
|
+
return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
|
|
179
225
|
}
|
|
@@ -93,7 +93,7 @@ interface UseAppHotkeysParams {
|
|
|
93
93
|
handleDuplicate: () => Promise<boolean>;
|
|
94
94
|
onResetKeyframes: () => boolean;
|
|
95
95
|
onDeleteSelectedKeyframes: () => void;
|
|
96
|
-
onAfterUndoRedo?:
|
|
96
|
+
onAfterUndoRedo?: UseEditHistoryActionsOptions["onAfterUndoRedo"];
|
|
97
97
|
onToggleRecording?: () => void;
|
|
98
98
|
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
|
|
99
99
|
onGroupSelection?: () => void;
|
|
@@ -77,7 +77,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
const setup = (binding: Partial<AutomationLaneBinding>) => {
|
|
80
|
-
const onCommit = vi.fn();
|
|
80
|
+
const onCommit = vi.fn().mockResolvedValue(undefined);
|
|
81
81
|
const automation = {
|
|
82
82
|
version: 1,
|
|
83
83
|
lanes: [
|
|
@@ -102,7 +102,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
102
102
|
onCommit,
|
|
103
103
|
onSelect: vi.fn(),
|
|
104
104
|
readOnly: false,
|
|
105
|
-
commitTargetKey: "bgm",
|
|
106
105
|
selection: null,
|
|
107
106
|
onRangeSelect: vi.fn(),
|
|
108
107
|
onRangeClear: vi.fn(),
|
|
@@ -230,7 +229,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
230
229
|
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
|
|
231
230
|
});
|
|
232
231
|
|
|
233
|
-
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
|
|
232
|
+
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", async () => {
|
|
234
233
|
clearAutomationClipboard();
|
|
235
234
|
// Duration wide enough that the playhead (5s) is not clamped down by the
|
|
236
235
|
// 0..duration-span bound — this is a paste-at-playhead test, not a
|
|
@@ -255,6 +254,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
255
254
|
expect(times).toContain(7); // + clipboard span 2
|
|
256
255
|
|
|
257
256
|
// Pasting again immediately should land right after the first paste.
|
|
257
|
+
await act(async () => {});
|
|
258
258
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
259
259
|
elementKey: "bgm",
|
|
260
260
|
target: "volume",
|
|
@@ -268,7 +268,124 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
-
it("
|
|
271
|
+
it("marks nothing when the paste's save is refused, even for two quick presses", async () => {
|
|
272
|
+
clearAutomationClipboard();
|
|
273
|
+
usePlayerStore.setState({
|
|
274
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
275
|
+
selectedElementId: "bgm",
|
|
276
|
+
});
|
|
277
|
+
const original = wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
|
|
278
|
+
usePlayerStore.getState().setAutomationSelection(original);
|
|
279
|
+
const onCommit = vi.fn().mockResolvedValue({ status: "refused", reason: "Locked" });
|
|
280
|
+
setup({ onCommit });
|
|
281
|
+
combo("c");
|
|
282
|
+
combo("v");
|
|
283
|
+
combo("v");
|
|
284
|
+
await act(async () => {});
|
|
285
|
+
expect(usePlayerStore.getState().automationSelection).toEqual(original);
|
|
286
|
+
combo("v");
|
|
287
|
+
const again = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
288
|
+
(p: { t: number }) => p.t,
|
|
289
|
+
);
|
|
290
|
+
expect(again).toContain(2);
|
|
291
|
+
expect(again).not.toContain(6);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each([{ status: "saved" as const }, { status: "refused" as const, reason: "Locked" }])(
|
|
295
|
+
"keeps a selection drawn while a paste was saving ($status)",
|
|
296
|
+
async (outcome) => {
|
|
297
|
+
clearAutomationClipboard();
|
|
298
|
+
usePlayerStore.setState({
|
|
299
|
+
elements: [{ ...bgmElement, duration: 10 }],
|
|
300
|
+
selectedElementId: "bgm",
|
|
301
|
+
});
|
|
302
|
+
usePlayerStore
|
|
303
|
+
.getState()
|
|
304
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
305
|
+
let land = () => {};
|
|
306
|
+
const onCommit = vi.fn(
|
|
307
|
+
() =>
|
|
308
|
+
new Promise<typeof outcome>((resolve) => {
|
|
309
|
+
land = () => resolve(outcome);
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
setup({ onCommit });
|
|
313
|
+
combo("c");
|
|
314
|
+
combo("v");
|
|
315
|
+
const drawn = wholeAxis({ elementKey: "bgm", target: "volume", t0: 6, t1: 8 });
|
|
316
|
+
usePlayerStore.getState().setAutomationSelection(drawn);
|
|
317
|
+
land();
|
|
318
|
+
await act(async () => {});
|
|
319
|
+
expect(usePlayerStore.getState().automationSelection).toEqual(drawn);
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
it.each(["in order", "reversed"])(
|
|
324
|
+
"marks the latest paste when pastes on two clips resolve %s",
|
|
325
|
+
async (order) => {
|
|
326
|
+
clearAutomationClipboard();
|
|
327
|
+
usePlayerStore.setState({
|
|
328
|
+
elements: [
|
|
329
|
+
{ ...bgmElement, duration: 10 },
|
|
330
|
+
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
|
331
|
+
],
|
|
332
|
+
selectedElementId: "bgm",
|
|
333
|
+
currentTime: 1,
|
|
334
|
+
});
|
|
335
|
+
usePlayerStore
|
|
336
|
+
.getState()
|
|
337
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
338
|
+
const lands: Array<() => void> = [];
|
|
339
|
+
const onCommit = vi.fn(
|
|
340
|
+
() =>
|
|
341
|
+
new Promise<{ status: "saved" }>((resolve) =>
|
|
342
|
+
lands.push(() => resolve({ status: "saved" })),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
setup({ onCommit });
|
|
346
|
+
combo("c");
|
|
347
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
348
|
+
combo("v");
|
|
349
|
+
usePlayerStore.setState({ selectedElementId: "vo" });
|
|
350
|
+
combo("v");
|
|
351
|
+
const order_ = order === "in order" ? [0, 1] : [1, 0];
|
|
352
|
+
for (const i of order_) lands[i]?.();
|
|
353
|
+
await act(async () => {});
|
|
354
|
+
expect(usePlayerStore.getState().automationSelection?.elementKey).toBe("vo");
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
it("does not mark a paste whose clip was left before it landed", async () => {
|
|
359
|
+
clearAutomationClipboard();
|
|
360
|
+
usePlayerStore.setState({
|
|
361
|
+
elements: [
|
|
362
|
+
{ ...bgmElement, duration: 10 },
|
|
363
|
+
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
|
364
|
+
],
|
|
365
|
+
selectedElementId: "bgm",
|
|
366
|
+
currentTime: 1,
|
|
367
|
+
});
|
|
368
|
+
usePlayerStore
|
|
369
|
+
.getState()
|
|
370
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
371
|
+
let land = () => {};
|
|
372
|
+
const onCommit = vi.fn(
|
|
373
|
+
() =>
|
|
374
|
+
new Promise<{ status: "saved" }>((resolve) => {
|
|
375
|
+
land = () => resolve({ status: "saved" });
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
setup({ onCommit });
|
|
379
|
+
combo("c");
|
|
380
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
381
|
+
combo("v");
|
|
382
|
+
usePlayerStore.setState({ selectedElementId: "vo" });
|
|
383
|
+
land();
|
|
384
|
+
await act(async () => {});
|
|
385
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("chains a second Cmd+V after the first instead of overwriting it", async () => {
|
|
272
389
|
// The regression this pins: paste leaves its own span selected, so anchoring
|
|
273
390
|
// at sel.t0 unconditionally made every later press recompute the same atT.
|
|
274
391
|
clearAutomationClipboard();
|
|
@@ -288,6 +405,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
288
405
|
);
|
|
289
406
|
expect(first).toContain(2);
|
|
290
407
|
expect(first).toContain(4);
|
|
408
|
+
await act(async () => {});
|
|
291
409
|
|
|
292
410
|
combo("v");
|
|
293
411
|
const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
|
@@ -295,6 +413,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
295
413
|
);
|
|
296
414
|
expect(second).toContain(4);
|
|
297
415
|
expect(second).toContain(6);
|
|
416
|
+
await act(async () => {});
|
|
298
417
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
299
418
|
elementKey: "bgm",
|
|
300
419
|
target: "volume",
|
|
@@ -308,7 +427,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
308
427
|
});
|
|
309
428
|
});
|
|
310
429
|
|
|
311
|
-
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
|
|
430
|
+
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", async () => {
|
|
312
431
|
// The playhead branch already clamps to duration - span; the
|
|
313
432
|
// selection-start branch didn't, so pasting a 2s clip at a selection
|
|
314
433
|
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
|
|
@@ -334,6 +453,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
334
453
|
expect(t).toBeLessThanOrEqual(bgmElement.duration);
|
|
335
454
|
}
|
|
336
455
|
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
|
|
456
|
+
await act(async () => {});
|
|
337
457
|
expect(usePlayerStore.getState().automationSelection).toEqual({
|
|
338
458
|
elementKey: "bgm",
|
|
339
459
|
target: "volume",
|
|
@@ -347,22 +467,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
347
467
|
});
|
|
348
468
|
});
|
|
349
469
|
|
|
350
|
-
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
|
351
|
-
// selectedElementId says "bgm" but the commit channel is still on the
|
|
352
|
-
// previously selected clip — writing here would serialize bgm's automation
|
|
353
|
-
// onto that other clip and leave bgm untouched.
|
|
354
|
-
clearAutomationClipboard();
|
|
355
|
-
copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
|
|
356
|
-
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
357
|
-
usePlayerStore
|
|
358
|
-
.getState()
|
|
359
|
-
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
|
360
|
-
const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
|
|
361
|
-
const e = combo("v");
|
|
362
|
-
expect(e.defaultPrevented).toBe(false);
|
|
363
|
-
expect(onCommit).not.toHaveBeenCalled();
|
|
364
|
-
});
|
|
365
|
-
|
|
366
470
|
it("does not paste from a playhead outside the clip", () => {
|
|
367
471
|
// No selection on this clip, and the playhead is past its end — there is no
|
|
368
472
|
// anchor. This used to collapse to the clip's own t=0.
|