@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
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type HfAutomationLane,
|
|
28
28
|
type HfAutomationPoint,
|
|
29
29
|
} from "@hyperframes/core/audio-automation";
|
|
30
|
+
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
30
31
|
import { envelopePath, fromUnit, laneFor, PAD_X, toUnit, withLane } from "./automationLaneGeometry";
|
|
31
32
|
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
|
|
32
33
|
import { AutomationValueInput } from "./AutomationValueInput";
|
|
@@ -180,9 +181,9 @@ export interface TimelineAutomationLaneProps {
|
|
|
180
181
|
/** Clip-local seconds of the playhead, or null when it is outside the clip. */
|
|
181
182
|
playheadSec: number | null;
|
|
182
183
|
/** Continuous write while dragging; does not persist. */
|
|
183
|
-
onPreview(automation: HfAutomation): void;
|
|
184
|
+
onPreview(automation: HfAutomation, ended?: boolean): void;
|
|
184
185
|
/** Gesture-end write; this is the one that persists and lands in undo. */
|
|
185
|
-
onCommit(automation: HfAutomation): void;
|
|
186
|
+
onCommit(automation: HfAutomation): Promise<TimelineEditOutcome | void> | void;
|
|
186
187
|
/**
|
|
187
188
|
* Clip-local times a dragged point snaps to — the beat grid, shifted into this
|
|
188
189
|
* clip's frame. Its own neighbouring points are added on top.
|
|
@@ -241,14 +242,6 @@ export function TimelineAutomationLane({
|
|
|
241
242
|
[draft, target, stored],
|
|
242
243
|
);
|
|
243
244
|
|
|
244
|
-
// The draft is released when the automation it was drawn over actually
|
|
245
|
-
// changes — the persisted edit landing, or an edit from elsewhere. Releasing
|
|
246
|
-
// it merely because the drag ended would snap the point back to where it
|
|
247
|
-
// started for as long as the write takes to come around.
|
|
248
|
-
useEffect(() => {
|
|
249
|
-
if (draft && draft.basedOn !== automation) setDraft(null);
|
|
250
|
-
}, [automation, draft]);
|
|
251
|
-
|
|
252
245
|
// A different parameter is a different envelope; the draft does not carry over.
|
|
253
246
|
useEffect(() => {
|
|
254
247
|
setDraft(null);
|
|
@@ -289,12 +282,16 @@ export function TimelineAutomationLane({
|
|
|
289
282
|
);
|
|
290
283
|
|
|
291
284
|
const commitPoints = useCallback(
|
|
292
|
-
(points: HfAutomationLane["points"], persist: boolean): void => {
|
|
285
|
+
(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void => {
|
|
293
286
|
// Draw from the draft immediately; the write is what eventually agrees.
|
|
294
287
|
setDraft({ points, basedOn: automation });
|
|
295
288
|
const next = withLane(automation, { target, points });
|
|
296
|
-
if (persist)
|
|
297
|
-
|
|
289
|
+
if (!persist) return onPreview(next, ended);
|
|
290
|
+
void Promise.resolve(onCommit(next)).then((outcome) => {
|
|
291
|
+
if (outcome && outcome.status !== "saved") {
|
|
292
|
+
setDraft((current) => (current?.points === points ? null : current));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
298
295
|
},
|
|
299
296
|
[automation, target, onCommit, onPreview],
|
|
300
297
|
);
|
|
@@ -319,6 +316,7 @@ export function TimelineAutomationLane({
|
|
|
319
316
|
duration,
|
|
320
317
|
rangeSelection,
|
|
321
318
|
});
|
|
319
|
+
|
|
322
320
|
const {
|
|
323
321
|
dragIndex,
|
|
324
322
|
curveIndex,
|
|
@@ -329,6 +327,13 @@ export function TimelineAutomationLane({
|
|
|
329
327
|
hint,
|
|
330
328
|
editing,
|
|
331
329
|
} = gestures;
|
|
330
|
+
// Released when the automation it was drawn over changes, not on drag end (the point would
|
|
331
|
+
// snap back until the write lands) and not under a live gesture (its release would commit
|
|
332
|
+
// an older save's points).
|
|
333
|
+
const gestureLive = [dragIndex, curveIndex, segmentDragIndex, edgeDrag].some((g) => g !== null);
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (draft && draft.basedOn !== automation && !gestureLive) setDraft(null);
|
|
336
|
+
}, [automation, draft, gestureLive]);
|
|
332
337
|
|
|
333
338
|
const removeAt = useCallback(
|
|
334
339
|
(index: number): void => {
|
|
@@ -32,7 +32,6 @@ function mountSlot(binding: Partial<AutomationLaneBinding>) {
|
|
|
32
32
|
onCommit: vi.fn(),
|
|
33
33
|
onSelect: vi.fn(),
|
|
34
34
|
readOnly: false,
|
|
35
|
-
commitTargetKey: "bgm",
|
|
36
35
|
selection: null,
|
|
37
36
|
onRangeSelect: vi.fn(),
|
|
38
37
|
onRangeClear,
|
|
@@ -96,7 +95,6 @@ const readingBind = (element: TimelineElement, isSelected: boolean): AutomationL
|
|
|
96
95
|
onCommit: vi.fn(),
|
|
97
96
|
onSelect: vi.fn(),
|
|
98
97
|
readOnly: !isSelected,
|
|
99
|
-
commitTargetKey: null,
|
|
100
98
|
selection: null,
|
|
101
99
|
onRangeSelect: vi.fn(),
|
|
102
100
|
onRangeClear: vi.fn(),
|
|
@@ -33,6 +33,7 @@ function render(
|
|
|
33
33
|
const root = createRoot(host);
|
|
34
34
|
const onSetElementAttributeLive = vi.fn();
|
|
35
35
|
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
const onRevertElementAttributeLive = vi.fn();
|
|
36
37
|
// Stands in for the TimelineClip button the handles live inside.
|
|
37
38
|
const node = (
|
|
38
39
|
<div data-testid="clip" onPointerDown={options.onClipPointerDown}>
|
|
@@ -49,13 +50,25 @@ function render(
|
|
|
49
50
|
options.provide === false ? (
|
|
50
51
|
node
|
|
51
52
|
) : (
|
|
52
|
-
<TimelineEditProvider
|
|
53
|
+
<TimelineEditProvider
|
|
54
|
+
value={{
|
|
55
|
+
onSetElementAttributeLive,
|
|
56
|
+
onSetElementAttributeQuiet,
|
|
57
|
+
onRevertElementAttributeLive,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
53
60
|
{node}
|
|
54
61
|
</TimelineEditProvider>
|
|
55
62
|
),
|
|
56
63
|
);
|
|
57
64
|
});
|
|
58
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
host,
|
|
67
|
+
root,
|
|
68
|
+
onSetElementAttributeLive,
|
|
69
|
+
onSetElementAttributeQuiet,
|
|
70
|
+
onRevertElementAttributeLive,
|
|
71
|
+
};
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
function pointer(type: string, clientX: number, pointerId = 1) {
|
|
@@ -146,14 +159,22 @@ describe("TimelineClipFades", () => {
|
|
|
146
159
|
});
|
|
147
160
|
|
|
148
161
|
it("puts the live value back and writes nothing when the gesture is cancelled", () => {
|
|
149
|
-
const {
|
|
162
|
+
const {
|
|
163
|
+
host,
|
|
164
|
+
root,
|
|
165
|
+
onSetElementAttributeLive,
|
|
166
|
+
onRevertElementAttributeLive,
|
|
167
|
+
onSetElementAttributeQuiet,
|
|
168
|
+
} = render(clip);
|
|
150
169
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
151
170
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
152
171
|
armCapture(handle);
|
|
153
172
|
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
154
173
|
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
155
174
|
act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
|
|
175
|
+
// Written back live for a host without the revert, then ended through the lanes' revert.
|
|
156
176
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
|
177
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
157
178
|
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
158
179
|
act(() => root.unmount());
|
|
159
180
|
});
|
|
@@ -32,7 +32,7 @@ interface TimelineClipFadesProps {
|
|
|
32
32
|
|
|
33
33
|
/** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
|
|
34
34
|
export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
|
|
35
|
-
const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
|
35
|
+
const { onSetElementAttributeLive, onSetElementAttributeQuiet, onRevertElementAttributeLive } =
|
|
36
36
|
useTimelineEditContextOptional();
|
|
37
37
|
const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
|
|
38
38
|
|
|
@@ -126,7 +126,10 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
126
126
|
|
|
127
127
|
/** Puts the live document back where the file has it and drops the draft. */
|
|
128
128
|
const revertGesture = (g: Gesture) => {
|
|
129
|
-
if (g.moved)
|
|
129
|
+
if (g.moved) {
|
|
130
|
+
onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
|
|
131
|
+
onRevertElementAttributeLive?.(el, attrFor(g.edge));
|
|
132
|
+
}
|
|
130
133
|
setDraft(null);
|
|
131
134
|
};
|
|
132
135
|
|
|
@@ -157,7 +157,7 @@ interface TimelineFxButtonChainProps {
|
|
|
157
157
|
onOpenRack: () => void;
|
|
158
158
|
fxChainRaw: string | undefined;
|
|
159
159
|
onChainChange: (next: HfAudioFxChain) => void;
|
|
160
|
-
onChainPreview?: (next: HfAudioFxChain) => void;
|
|
160
|
+
onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
|
|
161
161
|
/** The clips this chain is heard through, so an audition starts where they
|
|
162
162
|
* actually sound rather than from a playhead parked before the first. */
|
|
163
163
|
auditionSpans?: readonly AuditionSpan[];
|
|
@@ -18,7 +18,7 @@ interface TimelineGroupHeaderProps {
|
|
|
18
18
|
/** C1: the group's serialized `data-fx-chain`, when set. */
|
|
19
19
|
fxChain?: string;
|
|
20
20
|
onFxChainChange: (next: HfAudioFxChain) => void;
|
|
21
|
-
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
|
21
|
+
onFxChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
|
|
22
22
|
/** Member clips, so hovering a preset auditions where the group sounds. */
|
|
23
23
|
auditionSpans?: readonly AuditionSpan[];
|
|
24
24
|
onOpenFxRack: () => void;
|
|
@@ -80,9 +80,8 @@ export function TimelineGroupRow({
|
|
|
80
80
|
// The group wearing a clip's shape so the lane machinery can render it — see
|
|
81
81
|
// `groupAutomationElement` for why that beats a second, parallel lane path.
|
|
82
82
|
const groupElement = groupAutomationElement(group, compositionDuration);
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// its name in the header does.
|
|
83
|
+
// A group lane is editable exactly when the group is the selected element,
|
|
84
|
+
// which clicking its name in the header does.
|
|
86
85
|
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
|
|
87
86
|
const isGroupSelected = domSelection?.id === group.id;
|
|
88
87
|
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
|
@@ -90,14 +89,21 @@ export function TimelineGroupRow({
|
|
|
90
89
|
// provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
|
|
91
90
|
// hook took the whole timeline down with it the moment a group existed —
|
|
92
91
|
// not just this row.
|
|
93
|
-
const {
|
|
94
|
-
|
|
92
|
+
const {
|
|
93
|
+
onSetAudioGroupAttributeLive,
|
|
94
|
+
onSetAudioGroupAttributeQuiet,
|
|
95
|
+
onRevertAudioGroupAttributeLive,
|
|
96
|
+
} = useTimelineEditContextOptional();
|
|
95
97
|
const domEditActions = useDomEditActionsContextOptional();
|
|
96
98
|
const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
|
|
97
|
-
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
|
|
99
|
+
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => {
|
|
98
100
|
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
|
99
|
-
if (live)
|
|
100
|
-
|
|
101
|
+
if (!live) {
|
|
102
|
+
void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
|
106
|
+
if (ended) onRevertAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR);
|
|
101
107
|
};
|
|
102
108
|
const openGroupFxRack = (automationTarget?: string) => {
|
|
103
109
|
// Use the guarded timeline-selection path even though the bus is synthetic:
|
|
@@ -157,7 +163,7 @@ export function TimelineGroupRow({
|
|
|
157
163
|
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
|
158
164
|
fxChain={group.fxChain}
|
|
159
165
|
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
|
160
|
-
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
|
166
|
+
onFxChainPreview={(next, ended) => writeGroupFxChain(next, true, ended)}
|
|
161
167
|
auditionSpans={memberElements}
|
|
162
168
|
onOpenFxRack={() => openGroupFxRack()}
|
|
163
169
|
// Same width as every other row's header. The group row needs a real
|
|
@@ -169,8 +175,8 @@ export function TimelineGroupRow({
|
|
|
169
175
|
theme={theme}
|
|
170
176
|
/>
|
|
171
177
|
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
|
172
|
-
clip's:
|
|
173
|
-
|
|
178
|
+
clip's: a lane is editable once the group is selected, which clicking
|
|
179
|
+
its name does. */}
|
|
174
180
|
{/* The label column for those lanes, on the accent rail — inside the
|
|
175
181
|
sticky column above, so they pin with the header. */}
|
|
176
182
|
{isLaneOpen && (
|
|
@@ -214,8 +214,12 @@ export function TimelineTrackHeader({
|
|
|
214
214
|
// track holding several ungrouped ones has no single chain — the design
|
|
215
215
|
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
|
216
216
|
// at grouping (B6's normative rule) instead of a popover.
|
|
217
|
-
const {
|
|
218
|
-
|
|
217
|
+
const {
|
|
218
|
+
onGroupClips,
|
|
219
|
+
onSetElementAttributeLive,
|
|
220
|
+
onSetElementAttributeQuiet,
|
|
221
|
+
onRevertElementAttributeLive,
|
|
222
|
+
} = useTimelineEditContextOptional();
|
|
219
223
|
const domEditActions = useDomEditActionsContextOptional();
|
|
220
224
|
const singleAudioClip =
|
|
221
225
|
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
|
@@ -225,10 +229,19 @@ export function TimelineTrackHeader({
|
|
|
225
229
|
// needs to be TOLD that, so it earns the button and a refusal.
|
|
226
230
|
const isVideoWithAudioTrack =
|
|
227
231
|
!isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
|
|
228
|
-
const writeClipFxChain = (
|
|
232
|
+
const writeClipFxChain = (
|
|
233
|
+
clip: TimelineElement,
|
|
234
|
+
next: HfAudioFxChain,
|
|
235
|
+
live: boolean,
|
|
236
|
+
ended = false,
|
|
237
|
+
) => {
|
|
229
238
|
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
|
230
|
-
if (live)
|
|
231
|
-
|
|
239
|
+
if (!live) {
|
|
240
|
+
void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
|
244
|
+
if (ended) onRevertElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR);
|
|
232
245
|
};
|
|
233
246
|
const openClipFxRack = (clip: TimelineElement) => {
|
|
234
247
|
void domEditActions?.handleTimelineElementSelect(clip);
|
|
@@ -309,7 +322,9 @@ export function TimelineTrackHeader({
|
|
|
309
322
|
fxChainRaw={singleAudioClip.fxChain}
|
|
310
323
|
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
|
311
324
|
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
|
312
|
-
onChainPreview={(next) =>
|
|
325
|
+
onChainPreview={(next, ended) =>
|
|
326
|
+
writeClipFxChain(singleAudioClip, next, true, ended)
|
|
327
|
+
}
|
|
313
328
|
// Muted, an audition is silent — so the hover lifts the mute on
|
|
314
329
|
// the running graph and puts it back on the way out, the same
|
|
315
330
|
// borrow-and-return it already does with the playhead.
|
|
@@ -86,6 +86,19 @@ describe("VideoThumbnail", () => {
|
|
|
86
86
|
expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("spreads the frames across every tile so the strip reaches the clip's end", async () => {
|
|
90
|
+
const urls = Array.from({ length: 8 }, (_, index) => `blob:${index}`);
|
|
91
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
92
|
+
value: { kind: "filmstrip", urls, aspect: 16 / 9 },
|
|
93
|
+
weight: 256,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await render(500);
|
|
97
|
+
|
|
98
|
+
const tiles = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
99
|
+
expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
89
102
|
it("issues a single decode job for a narrow clip", async () => {
|
|
90
103
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
91
104
|
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
|
|
@@ -146,7 +146,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
146
146
|
{urls.length > 0 && (
|
|
147
147
|
<div className="absolute inset-0 flex">
|
|
148
148
|
{Array.from({ length: frameCount }, (_, index) => {
|
|
149
|
-
const src = urls[index
|
|
149
|
+
const src = urls[Math.round((index * (urls.length - 1)) / Math.max(1, frameCount - 1))];
|
|
150
150
|
return (
|
|
151
151
|
<div
|
|
152
152
|
key={index}
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
import type { TimelineElement } from "../store/playerStore";
|
|
17
17
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
18
18
|
|
|
19
|
+
const groupElements = new WeakSet<TimelineElement>();
|
|
20
|
+
|
|
21
|
+
export function isGroupAutomationElement(element: TimelineElement): boolean {
|
|
22
|
+
return groupElements.has(element);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
/** The synthetic element's track number — the group row's own anchor. */
|
|
20
26
|
export function groupAutomationElement(
|
|
21
27
|
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
|
|
22
28
|
compositionDuration: number,
|
|
23
29
|
): TimelineElement {
|
|
24
|
-
|
|
30
|
+
const element: TimelineElement = {
|
|
25
31
|
id: group.id,
|
|
26
32
|
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
|
|
27
33
|
domId: group.id,
|
|
@@ -34,4 +40,6 @@ export function groupAutomationElement(
|
|
|
34
40
|
...(group.automation ? { automation: group.automation } : {}),
|
|
35
41
|
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
|
36
42
|
};
|
|
43
|
+
groupElements.add(element);
|
|
44
|
+
return element;
|
|
37
45
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
2
|
// fallow-ignore-file dead-code
|
|
3
|
+
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
3
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
5
|
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
5
6
|
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
@@ -83,13 +84,14 @@ export interface TimelineEditCallbacks {
|
|
|
83
84
|
) => Promise<void> | void;
|
|
84
85
|
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
|
85
86
|
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
|
87
|
+
onRevertAudioGroupAttributeLive?: (groupId: string, attr: string) => void;
|
|
86
88
|
/** ...and persist one undo entry on release. */
|
|
87
89
|
onSetAudioGroupAttributeQuiet?: (
|
|
88
90
|
groupId: string,
|
|
89
91
|
attr: string,
|
|
90
92
|
value: string | null,
|
|
91
93
|
label: string,
|
|
92
|
-
) => Promise<void>;
|
|
94
|
+
) => Promise<TimelineEditOutcome | void>;
|
|
93
95
|
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
94
96
|
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
95
97
|
* carve auto-grouping uses. */
|
|
@@ -105,12 +107,13 @@ export interface TimelineEditCallbacks {
|
|
|
105
107
|
attr: string,
|
|
106
108
|
value: string | null,
|
|
107
109
|
) => void;
|
|
110
|
+
onRevertElementAttributeLive?: (element: TimelineElement, attr: string) => void;
|
|
108
111
|
onSetElementAttributeQuiet?: (
|
|
109
112
|
element: TimelineElement,
|
|
110
113
|
attr: string,
|
|
111
114
|
value: string | null,
|
|
112
115
|
label: string,
|
|
113
|
-
) => Promise<void>;
|
|
116
|
+
) => Promise<TimelineEditOutcome | void>;
|
|
114
117
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
115
118
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
116
119
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -291,6 +291,47 @@ describe("computeResizePreview — composition source continuity", () => {
|
|
|
291
291
|
previewPlaybackStart: 2,
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
|
|
296
|
+
const result = computeResizePreview(
|
|
297
|
+
{
|
|
298
|
+
element: clip("vo", 0, 2, 1, 0),
|
|
299
|
+
edge: "end",
|
|
300
|
+
originClientX: 0,
|
|
301
|
+
previewStart: 2,
|
|
302
|
+
previewDuration: 1,
|
|
303
|
+
started: true,
|
|
304
|
+
},
|
|
305
|
+
-95,
|
|
306
|
+
{ scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [{ time: 2.07, type: "beat" }] },
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect(result).toMatchObject({ previewDuration: 0.1, snapTime: null });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps a slowed clip's media clock when its head snaps to a beat", () => {
|
|
313
|
+
const element = { ...clip("vo", 0, 2, 4, 0), playbackStart: 3, playbackRate: 0.8 };
|
|
314
|
+
const result = computeResizePreview(
|
|
315
|
+
{
|
|
316
|
+
element,
|
|
317
|
+
edge: "start",
|
|
318
|
+
originClientX: 0,
|
|
319
|
+
previewStart: 2,
|
|
320
|
+
previewDuration: 4,
|
|
321
|
+
started: true,
|
|
322
|
+
},
|
|
323
|
+
30,
|
|
324
|
+
{
|
|
325
|
+
scroll: fakeScroll(),
|
|
326
|
+
pps: 100,
|
|
327
|
+
buildSnapTargets: () => [{ time: 2.3456, type: "beat" }],
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(result.snapTime).toBe(2.3456);
|
|
332
|
+
const clock = result.previewStart - result.previewPlaybackStart! / 0.8;
|
|
333
|
+
expect(clock).toBeCloseTo(2 - 3 / 0.8, 9);
|
|
334
|
+
});
|
|
294
335
|
});
|
|
295
336
|
|
|
296
337
|
describe("getTimelineDragOverlayPosition", () => {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
|
|
2
|
+
import {
|
|
3
|
+
applyClipStartTrimDelta,
|
|
4
|
+
clipStartTrimDeltaBounds,
|
|
5
|
+
resolveTimelineMinDuration,
|
|
6
|
+
} from "./timelineGroupEditing";
|
|
2
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
8
|
import {
|
|
4
9
|
getTimelineInsertBoundaryBand,
|
|
@@ -275,7 +280,6 @@ export function computeResizePreview(
|
|
|
275
280
|
const normalizedTag = resize.element.tag.toLowerCase();
|
|
276
281
|
const canSeedPlaybackStart =
|
|
277
282
|
resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
|
|
278
|
-
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
|
279
283
|
// Trim limit = available source media only — NOT the composition length.
|
|
280
284
|
// Duration is content-driven (the comp grows/shrinks to fit on commit), so
|
|
281
285
|
// capping a trim at the current comp end both blocked extending the last clip
|
|
@@ -319,35 +323,22 @@ export function computeResizePreview(
|
|
|
319
323
|
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
320
324
|
// degenerate clip or run past the source/composition limit.
|
|
321
325
|
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
322
|
-
if (
|
|
326
|
+
if (
|
|
327
|
+
target &&
|
|
328
|
+
snapped <= maxEnd + 1e-6 &&
|
|
329
|
+
snappedDuration >= resolveTimelineMinDuration() - 1e-6
|
|
330
|
+
) {
|
|
323
331
|
// An edge already on the target still owns the guide; only move it when off.
|
|
324
332
|
if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
|
|
325
333
|
snap = target;
|
|
326
334
|
}
|
|
327
335
|
} else {
|
|
328
336
|
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
: Number.POSITIVE_INFINITY;
|
|
335
|
-
// Also require the resulting duration to stay >= minDuration so a rightward
|
|
336
|
-
// snap (delta < 0) can't collapse the clip to zero/negative.
|
|
337
|
-
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
|
|
338
|
-
if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
|
|
339
|
-
if (snapped !== nextResize.start) {
|
|
340
|
-
nextResize = {
|
|
341
|
-
...nextResize,
|
|
342
|
-
start: snapped,
|
|
343
|
-
duration: snappedDuration,
|
|
344
|
-
playbackStart:
|
|
345
|
-
nextResize.playbackStart != null
|
|
346
|
-
? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
|
|
347
|
-
1000
|
|
348
|
-
: undefined,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
337
|
+
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
338
|
+
const delta = snapped - nextResize.start;
|
|
339
|
+
const bounds = clipStartTrimDeltaBounds(clip, 0, resolveTimelineMinDuration());
|
|
340
|
+
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
341
|
+
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
|
|
351
342
|
snap = target;
|
|
352
343
|
}
|
|
353
344
|
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
buildTimelineElementAgentPrompt,
|
|
6
6
|
buildTimelineAgentPrompt,
|
|
7
7
|
clampTimelineGroupResizeDelta,
|
|
8
|
+
formatTimelineAttributeNumber,
|
|
9
|
+
formatTimelineMediaOffset,
|
|
8
10
|
getTimelineEditCapabilities,
|
|
9
11
|
hasPatchableTimelineTarget,
|
|
10
12
|
resolveBlockedTimelineEditIntent,
|
|
@@ -366,6 +368,15 @@ describe("resolveTimelineGroupResize", () => {
|
|
|
366
368
|
],
|
|
367
369
|
});
|
|
368
370
|
});
|
|
371
|
+
|
|
372
|
+
it("derives a slowed member's playback start from its rounded start", () => {
|
|
373
|
+
const [member] = resolveTimelineGroupResize(
|
|
374
|
+
[{ start: 2, duration: 3, playbackStart: 1, playbackRate: 0.8 }],
|
|
375
|
+
"start",
|
|
376
|
+
0.373,
|
|
377
|
+
).members;
|
|
378
|
+
expect(member!.start - member!.playbackStart! / 0.8).toBeCloseTo(2 - 1 / 0.8, 9);
|
|
379
|
+
});
|
|
369
380
|
});
|
|
370
381
|
|
|
371
382
|
describe("hasPatchableTimelineTarget", () => {
|
|
@@ -795,7 +806,7 @@ describe("resolveTimelineResize", () => {
|
|
|
795
806
|
"start",
|
|
796
807
|
0,
|
|
797
808
|
),
|
|
798
|
-
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
|
809
|
+
).toEqual({ start: 0.8, duration: 3.2, playbackStart: expect.closeTo(0, 9) });
|
|
799
810
|
});
|
|
800
811
|
|
|
801
812
|
it("trims generic element start without media offset", () => {
|
|
@@ -831,6 +842,93 @@ describe("resolveTimelineResize", () => {
|
|
|
831
842
|
),
|
|
832
843
|
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
|
|
833
844
|
});
|
|
845
|
+
|
|
846
|
+
it("rounds a fully-left head trim inward so the in-point is never clamped off the media clock", () => {
|
|
847
|
+
const next = resolveTimelineResize(
|
|
848
|
+
{
|
|
849
|
+
start: 2,
|
|
850
|
+
duration: 3,
|
|
851
|
+
originClientX: 0,
|
|
852
|
+
pixelsPerSecond: 100,
|
|
853
|
+
minStart: 0,
|
|
854
|
+
maxEnd: 10,
|
|
855
|
+
playbackStart: 1,
|
|
856
|
+
playbackRate: 1.5,
|
|
857
|
+
},
|
|
858
|
+
"start",
|
|
859
|
+
-1000,
|
|
860
|
+
);
|
|
861
|
+
expect(next.start).toBe(1.34);
|
|
862
|
+
expect(next.start - next.playbackStart! / 1.5).toBeCloseTo(2 - 1 / 1.5, 9);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("lets a fully-left head trim reach a reloaded in-point's media start at a 5-decimal speed", () => {
|
|
866
|
+
const next = resolveTimelineResize(
|
|
867
|
+
{
|
|
868
|
+
start: 2.37,
|
|
869
|
+
duration: 2.63,
|
|
870
|
+
originClientX: 0,
|
|
871
|
+
pixelsPerSecond: 100,
|
|
872
|
+
minStart: 0,
|
|
873
|
+
maxEnd: 10,
|
|
874
|
+
playbackStart: Number(formatTimelineMediaOffset(0.37 * 0.33333)),
|
|
875
|
+
playbackRate: 0.33333,
|
|
876
|
+
},
|
|
877
|
+
"start",
|
|
878
|
+
-1000,
|
|
879
|
+
);
|
|
880
|
+
expect(next.start).toBe(2);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("moves every group member by the same amount when one reaches its media start", () => {
|
|
884
|
+
const members = [
|
|
885
|
+
{ start: 2, duration: 3, playbackStart: 1, playbackRate: 1.5 },
|
|
886
|
+
{ start: 5, duration: 3, playbackStart: 4, playbackRate: 1 },
|
|
887
|
+
];
|
|
888
|
+
const result = resolveTimelineGroupResize(members, "start", -10);
|
|
889
|
+
for (const [i, member] of members.entries()) {
|
|
890
|
+
const next = result.members[i]!;
|
|
891
|
+
expect(next.start - member.start).toBeCloseTo(result.delta, 9);
|
|
892
|
+
expect(next.start - next.playbackStart! / member.playbackRate).toBeCloseTo(
|
|
893
|
+
member.start - member.playbackStart / member.playbackRate,
|
|
894
|
+
9,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("keeps a clip's media clock and end fixed across repeated head trims at any speed", () => {
|
|
900
|
+
const dragsPx = [37.3, -12.9, 81.7, -5.3];
|
|
901
|
+
for (const playbackRate of [0.25, 0.5, 0.8, 1, 1.25, 2]) {
|
|
902
|
+
let clip = { start: 2, duration: 6, playbackStart: 3 };
|
|
903
|
+
const clock = clip.start - clip.playbackStart / playbackRate;
|
|
904
|
+
const end = clip.start + clip.duration;
|
|
905
|
+
for (const clientX of dragsPx) {
|
|
906
|
+
const next = resolveTimelineResize(
|
|
907
|
+
{
|
|
908
|
+
...clip,
|
|
909
|
+
originClientX: 0,
|
|
910
|
+
pixelsPerSecond: 100,
|
|
911
|
+
minStart: 0,
|
|
912
|
+
maxEnd: 20,
|
|
913
|
+
playbackRate,
|
|
914
|
+
},
|
|
915
|
+
"start",
|
|
916
|
+
clientX,
|
|
917
|
+
);
|
|
918
|
+
// Round-trip through the saved attributes, as a reload would.
|
|
919
|
+
const savedInPoint = formatTimelineMediaOffset(next.playbackStart!);
|
|
920
|
+
if (playbackRate === 1)
|
|
921
|
+
expect(savedInPoint).toBe(formatTimelineAttributeNumber(next.playbackStart!));
|
|
922
|
+
clip = {
|
|
923
|
+
start: Number(formatTimelineAttributeNumber(next.start)),
|
|
924
|
+
duration: Number(formatTimelineAttributeNumber(next.duration)),
|
|
925
|
+
playbackStart: Number(savedInPoint),
|
|
926
|
+
};
|
|
927
|
+
expect(clip.start - clip.playbackStart / playbackRate).toBeCloseTo(clock, 9);
|
|
928
|
+
expect(clip.start + clip.duration).toBeCloseTo(end, 9);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
});
|
|
834
932
|
});
|
|
835
933
|
|
|
836
934
|
describe("buildPromptCopyText", () => {
|