@hyperframes/studio 0.7.107 → 0.7.109
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-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -9,7 +9,8 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
|
9
9
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
10
|
import type { TimelineElement } from "../store/playerStore";
|
|
11
11
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
12
|
-
import { LABEL_COL_W } from "./timelineLayout";
|
|
12
|
+
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
|
|
13
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
13
14
|
|
|
14
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
16
|
|
|
@@ -61,6 +62,8 @@ const OPACITY = animation("opacity-tween", "visual", [
|
|
|
61
62
|
|
|
62
63
|
interface RenderHeaderOptions {
|
|
63
64
|
keyframeClip?: TimelineElement;
|
|
65
|
+
/** Every clip on the track; defaults to just the keyframe clip. */
|
|
66
|
+
trackElements?: readonly TimelineElement[];
|
|
64
67
|
animations?: GsapAnimation[];
|
|
65
68
|
clipCount?: number;
|
|
66
69
|
currentTime?: number;
|
|
@@ -68,6 +71,7 @@ interface RenderHeaderOptions {
|
|
|
68
71
|
onSeek?: (time: number) => void;
|
|
69
72
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
70
73
|
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
74
|
+
onRemoveAutomationLane?: (target: string) => void;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -90,6 +94,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
90
94
|
lanesId="timeline-lanes-track-0"
|
|
91
95
|
contentOrigin={LABEL_COL_W}
|
|
92
96
|
keyframeClip={next.keyframeClip ?? ELEMENT}
|
|
97
|
+
trackElements={next.trackElements ?? [next.keyframeClip ?? ELEMENT]}
|
|
93
98
|
clipCount={next.clipCount ?? 1}
|
|
94
99
|
isExpanded={next.expanded !== false}
|
|
95
100
|
animations={next.animations ?? [POSITION, OPACITY]}
|
|
@@ -100,6 +105,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
100
105
|
onToggleClipExpanded={vi.fn()}
|
|
101
106
|
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
|
|
102
107
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
108
|
+
onRemoveAutomationLane={next.onRemoveAutomationLane}
|
|
103
109
|
onSeek={next.onSeek}
|
|
104
110
|
/>,
|
|
105
111
|
);
|
|
@@ -414,4 +420,209 @@ describe("TimelineTrackHeader", () => {
|
|
|
414
420
|
assertAligned([POSITION, OPACITY]);
|
|
415
421
|
act(() => view.root.unmount());
|
|
416
422
|
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Automation lanes are named in the label column, on the same tree as the
|
|
426
|
+
* keyframe rows — not painted inside the lane, where the name sat on top of
|
|
427
|
+
* the envelope it belonged to and scrolled away from its own row.
|
|
428
|
+
*/
|
|
429
|
+
describe("audio automation rows", () => {
|
|
430
|
+
const BED: TimelineElement = {
|
|
431
|
+
id: "bed",
|
|
432
|
+
label: "Music Bed",
|
|
433
|
+
tag: "audio",
|
|
434
|
+
start: 0,
|
|
435
|
+
duration: 10,
|
|
436
|
+
track: 0,
|
|
437
|
+
fxChain: JSON.stringify({
|
|
438
|
+
version: 1,
|
|
439
|
+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }],
|
|
440
|
+
}),
|
|
441
|
+
automation: JSON.stringify({
|
|
442
|
+
version: 1,
|
|
443
|
+
lanes: [
|
|
444
|
+
{
|
|
445
|
+
target: "volume",
|
|
446
|
+
points: [
|
|
447
|
+
{ t: 0, v: 1 },
|
|
448
|
+
{ t: 5, v: 0.4 },
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
target: "fx.n1.gain",
|
|
453
|
+
points: [
|
|
454
|
+
{ t: 0, v: 0 },
|
|
455
|
+
{ t: 5, v: -6 },
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
}),
|
|
460
|
+
} as TimelineElement;
|
|
461
|
+
|
|
462
|
+
it("names every envelope in the label column", () => {
|
|
463
|
+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
|
|
464
|
+
const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
|
|
465
|
+
// The attribute is the ROW's identity, which is the label: a row can hold
|
|
466
|
+
// several clips' envelopes, whose lane targets differ from each other.
|
|
467
|
+
expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([
|
|
468
|
+
"Peaking EQ 1.6 kHz · Gain",
|
|
469
|
+
"Volume",
|
|
470
|
+
]);
|
|
471
|
+
// A band is named by its frequency: with several of them, "Peaking EQ" says
|
|
472
|
+
// nothing about which is which. Bands sit above the level lanes.
|
|
473
|
+
// Two lines per row: what the effect is, then which knob the envelope
|
|
474
|
+
// drives. One line truncated mid-word in a column this narrow.
|
|
475
|
+
expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([
|
|
476
|
+
"Peaking EQ 1.6 kHz",
|
|
477
|
+
"Volume",
|
|
478
|
+
]);
|
|
479
|
+
expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual(
|
|
480
|
+
[
|
|
481
|
+
"Gain",
|
|
482
|
+
// Volume has no effect behind it, so it has no second line at all.
|
|
483
|
+
undefined,
|
|
484
|
+
],
|
|
485
|
+
);
|
|
486
|
+
act(() => root.unmount());
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("hides them when the track is collapsed", () => {
|
|
490
|
+
const { host, root } = renderHeader({
|
|
491
|
+
keyframeClip: BED,
|
|
492
|
+
animations: [],
|
|
493
|
+
expanded: false,
|
|
494
|
+
});
|
|
495
|
+
expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0);
|
|
496
|
+
act(() => root.unmount());
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("removes just that envelope from the label column", () => {
|
|
500
|
+
// The panel's automate toggle can only reach a parameter it still shows; a
|
|
501
|
+
// carve's own lanes are not in it at all, so without this an envelope could
|
|
502
|
+
// be created and never deleted.
|
|
503
|
+
const onRemoveAutomationLane = vi.fn();
|
|
504
|
+
const { host, root } = renderHeader({
|
|
505
|
+
keyframeClip: BED,
|
|
506
|
+
animations: [],
|
|
507
|
+
onRemoveAutomationLane,
|
|
508
|
+
});
|
|
509
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
510
|
+
'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]',
|
|
511
|
+
);
|
|
512
|
+
expect(button).not.toBeNull();
|
|
513
|
+
act(() => button?.click());
|
|
514
|
+
expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
|
|
515
|
+
act(() => root.unmount());
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("offers no remove button when the lanes are read-only", () => {
|
|
519
|
+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
|
|
520
|
+
expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0);
|
|
521
|
+
act(() => root.unmount());
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("stacks each envelope's row where its lane is drawn", () => {
|
|
525
|
+
// Same rhythm the canvas uses: automation begins below the keyframe lanes
|
|
526
|
+
// and steps by its own taller row height.
|
|
527
|
+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] });
|
|
528
|
+
const tops = Array.from(
|
|
529
|
+
host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"),
|
|
530
|
+
).map((r) => r.style.top);
|
|
531
|
+
const base = getTimelineLaneTop(1);
|
|
532
|
+
expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]);
|
|
533
|
+
act(() => root.unmount());
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Several clips on one row share a lane row per property, so the label column
|
|
539
|
+
* has to name the row for the property and the header for the track — not for
|
|
540
|
+
* whichever clip happens to be selected.
|
|
541
|
+
*/
|
|
542
|
+
describe("a track several clips share", () => {
|
|
543
|
+
const clip = (id: string, over: Partial<TimelineElement>): TimelineElement =>
|
|
544
|
+
({
|
|
545
|
+
id,
|
|
546
|
+
key: id,
|
|
547
|
+
label: id,
|
|
548
|
+
tag: "audio",
|
|
549
|
+
start: 0,
|
|
550
|
+
duration: 4,
|
|
551
|
+
track: 0,
|
|
552
|
+
...over,
|
|
553
|
+
}) as TimelineElement;
|
|
554
|
+
const PEAKING = (gain: number) =>
|
|
555
|
+
JSON.stringify({
|
|
556
|
+
version: 1,
|
|
557
|
+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }],
|
|
558
|
+
});
|
|
559
|
+
const NARRATION_1 = clip("narration-1", {
|
|
560
|
+
fxChain: PEAKING(-3),
|
|
561
|
+
automation: JSON.stringify({
|
|
562
|
+
version: 1,
|
|
563
|
+
lanes: [{ target: "fx.n1.gain", points: [{ t: 0, v: -3 }] }],
|
|
564
|
+
}),
|
|
565
|
+
});
|
|
566
|
+
const NARRATION_2 = clip("narration-2", {
|
|
567
|
+
start: 4,
|
|
568
|
+
fxChain: PEAKING(-6),
|
|
569
|
+
automation: JSON.stringify({
|
|
570
|
+
version: 1,
|
|
571
|
+
lanes: [
|
|
572
|
+
{ target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
|
|
573
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
574
|
+
],
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
const ROW = { trackElements: [NARRATION_1, NARRATION_2], clipCount: 2, animations: [] };
|
|
578
|
+
|
|
579
|
+
it("lists every clip's envelopes, whichever clip is selected", () => {
|
|
580
|
+
const labels = (host: HTMLElement) =>
|
|
581
|
+
Array.from(host.querySelectorAll("[data-automation-lane-label]")).map((r) =>
|
|
582
|
+
r.getAttribute("data-automation-lane-label"),
|
|
583
|
+
);
|
|
584
|
+
const first = renderHeader({ ...ROW, keyframeClip: NARRATION_1 });
|
|
585
|
+
const second = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
|
|
586
|
+
// narration-1 has no volume envelope, but the row is still there — it is
|
|
587
|
+
// the track's, and it was its sibling's before the selection moved.
|
|
588
|
+
expect(labels(first.host)).toEqual(["Peaking EQ 1 kHz · Gain", "Volume"]);
|
|
589
|
+
expect(labels(second.host)).toEqual(labels(first.host));
|
|
590
|
+
act(() => first.root.unmount());
|
|
591
|
+
act(() => second.root.unmount());
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("removes only from the clip it is showing, and offers nothing where it has no lane", () => {
|
|
595
|
+
// A write can only reach the selected clip, so a button on a row that clip
|
|
596
|
+
// is absent from could only remove nothing, or somebody else's envelope.
|
|
597
|
+
const onRemoveAutomationLane = vi.fn();
|
|
598
|
+
const { host, root } = renderHeader({
|
|
599
|
+
...ROW,
|
|
600
|
+
keyframeClip: NARRATION_1,
|
|
601
|
+
onRemoveAutomationLane,
|
|
602
|
+
});
|
|
603
|
+
expect(
|
|
604
|
+
Array.from(host.querySelectorAll('button[aria-label$="automation"]')).map((b) =>
|
|
605
|
+
b.getAttribute("aria-label"),
|
|
606
|
+
),
|
|
607
|
+
).toEqual(["Remove Peaking EQ 1 kHz · Gain automation"]);
|
|
608
|
+
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$="automation"]')?.click());
|
|
609
|
+
expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
|
|
610
|
+
act(() => root.unmount());
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("names the header for the track, not for one of the clips on it", () => {
|
|
614
|
+
const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
|
|
615
|
+
expect(view.host.textContent).not.toContain("narration-2");
|
|
616
|
+
expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1");
|
|
617
|
+
// Alone on the track it is still named for itself.
|
|
618
|
+
view.rerender({
|
|
619
|
+
...ROW,
|
|
620
|
+
keyframeClip: NARRATION_2,
|
|
621
|
+
trackElements: [NARRATION_2],
|
|
622
|
+
clipCount: 1,
|
|
623
|
+
});
|
|
624
|
+
expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2");
|
|
625
|
+
act(() => view.root.unmount());
|
|
626
|
+
});
|
|
627
|
+
});
|
|
417
628
|
});
|
|
@@ -4,6 +4,8 @@ import { Music } from "../../icons/SystemIcons";
|
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
5
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
6
6
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
8
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
7
9
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
8
10
|
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
|
9
11
|
import { TrackClipCount } from "./TrackClipCount";
|
|
@@ -28,13 +30,19 @@ interface TimelineTrackHeaderProps {
|
|
|
28
30
|
* from the label rather than inventing one (see trackDisplayNumber). */
|
|
29
31
|
trackDisplayNumber: number | null;
|
|
30
32
|
trackLabel: string;
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
+
/** Ids of the canvas-side lane regions the disclosure caret expands, space
|
|
34
|
+
* separated as `aria-controls` takes them: the active clip's keyframe lanes
|
|
35
|
+
* and the track's automation lanes are separate elements, one owned by a
|
|
36
|
+
* clip and one by the row. Minted by TimelineLanes, the one place that sees
|
|
37
|
+
* every subtree. */
|
|
33
38
|
lanesId: string;
|
|
34
39
|
contentOrigin: number;
|
|
35
40
|
/** The track's active keyframe clip (selected, else primary) — the one whose
|
|
36
41
|
* disclosure + property rows this header shows, whether expanded or not. */
|
|
37
42
|
keyframeClip: TimelineElement | null;
|
|
43
|
+
/** Every clip on this track. Automation rows are the track's, unioned over
|
|
44
|
+
* these, so they stop changing with the selection. */
|
|
45
|
+
trackElements: readonly TimelineElement[];
|
|
38
46
|
/** Clips on this track, so the header can say how many the row holds. */
|
|
39
47
|
clipCount: number;
|
|
40
48
|
isExpanded: boolean;
|
|
@@ -47,6 +55,9 @@ interface TimelineTrackHeaderProps {
|
|
|
47
55
|
onToggleClipExpanded: () => void;
|
|
48
56
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
49
57
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
58
|
+
/** Drop one envelope. Absent while the lanes are read-only, which is what
|
|
59
|
+
* hides the control rather than offering a button that cannot act. */
|
|
60
|
+
onRemoveAutomationLane?: (target: string) => void;
|
|
50
61
|
onSeek?: (time: number) => void;
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -291,6 +302,110 @@ function PropertyGroupHeaderRow({
|
|
|
291
302
|
);
|
|
292
303
|
}
|
|
293
304
|
|
|
305
|
+
/**
|
|
306
|
+
* One envelope's row in the label column.
|
|
307
|
+
*
|
|
308
|
+
* Named here rather than inside the lane, on the same tree connector the
|
|
309
|
+
* keyframe rows use: an automation lane is a child of its clip exactly as a
|
|
310
|
+
* property group is, and drawing its name over the envelope put the label on top
|
|
311
|
+
* of the curve it describes and scrolled it away from its own row.
|
|
312
|
+
*/
|
|
313
|
+
function AutomationLaneHeaderRow({
|
|
314
|
+
target,
|
|
315
|
+
label,
|
|
316
|
+
name,
|
|
317
|
+
param,
|
|
318
|
+
top,
|
|
319
|
+
isLastLane,
|
|
320
|
+
gutterBackground,
|
|
321
|
+
columnWidth,
|
|
322
|
+
onRemove,
|
|
323
|
+
}: {
|
|
324
|
+
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
|
|
325
|
+
* the row belongs to the property, and a clip may be absent from it. */
|
|
326
|
+
target: string | null;
|
|
327
|
+
/** The whole thing on one line: the row's identity, its tooltip, and the
|
|
328
|
+
* remove button's name. */
|
|
329
|
+
label: string;
|
|
330
|
+
/** What the effect is — "Peaking EQ 1.6 kHz". */
|
|
331
|
+
name: string;
|
|
332
|
+
/** Which knob the envelope drives. Empty when there is no second line to draw. */
|
|
333
|
+
param: string;
|
|
334
|
+
top: number;
|
|
335
|
+
isLastLane: boolean;
|
|
336
|
+
gutterBackground: string;
|
|
337
|
+
columnWidth: number;
|
|
338
|
+
onRemove?: (target: string) => void;
|
|
339
|
+
}) {
|
|
340
|
+
return (
|
|
341
|
+
<div
|
|
342
|
+
data-automation-lane-label={label}
|
|
343
|
+
data-timeline-lane-top={top}
|
|
344
|
+
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
|
345
|
+
style={{
|
|
346
|
+
top,
|
|
347
|
+
width: columnWidth,
|
|
348
|
+
height: AUTOMATION_LANE_H,
|
|
349
|
+
background: gutterBackground,
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
|
|
353
|
+
tick at the name's own height. */}
|
|
354
|
+
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
|
355
|
+
<span
|
|
356
|
+
className="absolute left-1.5 top-0 w-px bg-white/15"
|
|
357
|
+
style={{ height: isLastLane ? "50%" : "100%" }}
|
|
358
|
+
/>
|
|
359
|
+
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
|
360
|
+
</span>
|
|
361
|
+
{/* Two lines: what the effect is, then which knob the envelope drives. On
|
|
362
|
+
one line a band's own name was the first thing truncated in a column this
|
|
363
|
+
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
|
|
364
|
+
bands apart. */}
|
|
365
|
+
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
|
|
366
|
+
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
|
|
367
|
+
{name}
|
|
368
|
+
</span>
|
|
369
|
+
{param ? (
|
|
370
|
+
<span
|
|
371
|
+
data-automation-lane-param=""
|
|
372
|
+
className="truncate font-mono text-[9px] text-white/40"
|
|
373
|
+
>
|
|
374
|
+
{param}
|
|
375
|
+
</span>
|
|
376
|
+
) : null}
|
|
377
|
+
</span>
|
|
378
|
+
{/* Beside the name it labels, because that is the only place an envelope is
|
|
379
|
+
named at all: a carve writes its own lanes, and the FX panel's automate
|
|
380
|
+
toggle can only reach a parameter it still lists — so without this an
|
|
381
|
+
envelope could be created and never removed.
|
|
382
|
+
|
|
383
|
+
Only for the clip the header is showing, since that is the only one a
|
|
384
|
+
write can reach: a shared row's other envelopes belong to clips that
|
|
385
|
+
are not selected, and a button that silently removed one of them (or
|
|
386
|
+
none) would be worse than no button. */}
|
|
387
|
+
{onRemove && target !== null && (
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
aria-label={`Remove ${label} automation`}
|
|
391
|
+
title={`Remove ${label} automation`}
|
|
392
|
+
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
|
|
393
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
|
394
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
395
|
+
onClick={(event) => {
|
|
396
|
+
// A control in the label column owns its click; it does not also hit
|
|
397
|
+
// the track row behind it and change the selection.
|
|
398
|
+
event.stopPropagation();
|
|
399
|
+
onRemove(target);
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
×
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
294
409
|
export function TimelineTrackHeader({
|
|
295
410
|
trackNumber,
|
|
296
411
|
trackDisplayNumber,
|
|
@@ -298,6 +413,7 @@ export function TimelineTrackHeader({
|
|
|
298
413
|
lanesId,
|
|
299
414
|
contentOrigin,
|
|
300
415
|
keyframeClip,
|
|
416
|
+
trackElements,
|
|
301
417
|
clipCount,
|
|
302
418
|
isExpanded,
|
|
303
419
|
animations,
|
|
@@ -308,6 +424,7 @@ export function TimelineTrackHeader({
|
|
|
308
424
|
onToggleClipExpanded,
|
|
309
425
|
onToggleTrackHidden,
|
|
310
426
|
onTogglePropertyGroupKeyframe,
|
|
427
|
+
onRemoveAutomationLane,
|
|
311
428
|
onSeek,
|
|
312
429
|
rovingTargetId = null,
|
|
313
430
|
}: TimelineTrackHeaderProps) {
|
|
@@ -322,7 +439,24 @@ export function TimelineTrackHeader({
|
|
|
322
439
|
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
|
323
440
|
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
|
324
441
|
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
|
325
|
-
|
|
442
|
+
// One row per automated property across the whole track, in the order the
|
|
443
|
+
// canvas draws them — a name beside the wrong envelope is worse than an awkward
|
|
444
|
+
// order. `target` is the ACTIVE clip's lane in that row, which is the only one
|
|
445
|
+
// the remove button can write to; null when the row belongs to its siblings.
|
|
446
|
+
const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
|
|
447
|
+
const automationRows = groupAutomationLanes(trackElements).map((group) => ({
|
|
448
|
+
key: group.key,
|
|
449
|
+
label: group.key,
|
|
450
|
+
name: group.name,
|
|
451
|
+
param: group.param,
|
|
452
|
+
target:
|
|
453
|
+
group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
|
|
454
|
+
.target ?? null,
|
|
455
|
+
}));
|
|
456
|
+
// Automation counts as something to disclose: gating the caret on tweens alone
|
|
457
|
+
// left an audio clip's envelopes unreachable, since the track could not expand.
|
|
458
|
+
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
|
459
|
+
const isKeyframeLayer = !!keyframeClip && disclosable;
|
|
326
460
|
|
|
327
461
|
return (
|
|
328
462
|
<div
|
|
@@ -341,7 +475,7 @@ export function TimelineTrackHeader({
|
|
|
341
475
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
342
476
|
}}
|
|
343
477
|
>
|
|
344
|
-
{!keyframeClip ||
|
|
478
|
+
{!keyframeClip || !disclosable ? (
|
|
345
479
|
<PlainTrackHeader
|
|
346
480
|
trackNumber={trackNumber}
|
|
347
481
|
trackDisplayNumber={trackDisplayNumber}
|
|
@@ -355,7 +489,15 @@ export function TimelineTrackHeader({
|
|
|
355
489
|
) : (
|
|
356
490
|
<>
|
|
357
491
|
<LayerDisclosureRow
|
|
358
|
-
|
|
492
|
+
name={
|
|
493
|
+
// A row holding several clips is named for the track, not for
|
|
494
|
+
// whichever of them is selected — the lanes below it are the
|
|
495
|
+
// track's, shared per property, and naming it "Narration 2" read
|
|
496
|
+
// as if they all belonged to that one slice.
|
|
497
|
+
clipCount > 1
|
|
498
|
+
? `Track${trackDisplaySuffix(trackDisplayNumber)}`
|
|
499
|
+
: (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
|
|
500
|
+
}
|
|
359
501
|
clipCount={clipCount}
|
|
360
502
|
isExpanded={isExpanded}
|
|
361
503
|
gutterBackground={theme.gutterBackground}
|
|
@@ -391,7 +533,7 @@ export function TimelineTrackHeader({
|
|
|
391
533
|
lanesId={lanesId}
|
|
392
534
|
lane={lane}
|
|
393
535
|
laneIndex={laneIndex}
|
|
394
|
-
isLastLane={laneIndex === lanes.length - 1}
|
|
536
|
+
isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
|
|
395
537
|
expandedElement={keyframeClip}
|
|
396
538
|
currentTime={currentTime}
|
|
397
539
|
clipPercentage={clipPercentage}
|
|
@@ -402,6 +544,24 @@ export function TimelineTrackHeader({
|
|
|
402
544
|
rovingTargetId={rovingTargetId}
|
|
403
545
|
/>
|
|
404
546
|
))}
|
|
547
|
+
{/* Below the keyframe rows and stepping by its own height, which is how
|
|
548
|
+
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
|
|
549
|
+
two have to agree or a name labels the wrong curve. */}
|
|
550
|
+
{isExpanded &&
|
|
551
|
+
automationRows.map((row, index) => (
|
|
552
|
+
<AutomationLaneHeaderRow
|
|
553
|
+
key={row.key}
|
|
554
|
+
target={row.target}
|
|
555
|
+
label={row.label}
|
|
556
|
+
name={row.name}
|
|
557
|
+
param={row.param}
|
|
558
|
+
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
|
|
559
|
+
isLastLane={index === automationRows.length - 1}
|
|
560
|
+
gutterBackground={theme.gutterBackground}
|
|
561
|
+
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
|
562
|
+
onRemove={onRemoveAutomationLane}
|
|
563
|
+
/>
|
|
564
|
+
))}
|
|
405
565
|
</>
|
|
406
566
|
)}
|
|
407
567
|
</div>
|
|
@@ -7,7 +7,13 @@ interface TimelineTrackRowProps {
|
|
|
7
7
|
rowKey: number;
|
|
8
8
|
logicalRow: TimelineLogicalRow;
|
|
9
9
|
propertyRows: readonly TimelineLogicalRow[];
|
|
10
|
+
/** Names the canvas-side content cell — the active clip's own property lanes,
|
|
11
|
+
* minted with this single id in TimelinePropertyLanes. */
|
|
10
12
|
lanesId: string;
|
|
13
|
+
/** Names the header cell. Space-separated because the caret it lives under
|
|
14
|
+
* expands two disjoint subtrees (the clip's keyframe lanes AND the track's
|
|
15
|
+
* automation lanes) — see TimelineTrackHeader for why they cannot share one id. */
|
|
16
|
+
headerLanesId: string;
|
|
11
17
|
top: number;
|
|
12
18
|
height: number;
|
|
13
19
|
virtualized: boolean;
|
|
@@ -24,6 +30,7 @@ export function TimelineTrackRow({
|
|
|
24
30
|
logicalRow,
|
|
25
31
|
propertyRows,
|
|
26
32
|
lanesId,
|
|
33
|
+
headerLanesId,
|
|
27
34
|
top,
|
|
28
35
|
height,
|
|
29
36
|
virtualized,
|
|
@@ -78,7 +85,7 @@ export function TimelineTrackRow({
|
|
|
78
85
|
<div
|
|
79
86
|
role="rowheader"
|
|
80
87
|
aria-colindex={1}
|
|
81
|
-
aria-owns={timelineLogicalRowCellId(
|
|
88
|
+
aria-owns={timelineLogicalRowCellId(headerLanesId, row.id, "header")}
|
|
82
89
|
>
|
|
83
90
|
{group}
|
|
84
91
|
</div>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearAutomationClipboard,
|
|
4
|
+
copyRange,
|
|
5
|
+
pastePoints,
|
|
6
|
+
readClipboard,
|
|
7
|
+
} from "./automationClipboard";
|
|
8
|
+
import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
9
|
+
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
10
|
+
|
|
11
|
+
const duck: HfAutomationLane = {
|
|
12
|
+
target: "volume",
|
|
13
|
+
points: [
|
|
14
|
+
{ t: 2, v: 1, curve: -0.4 },
|
|
15
|
+
{ t: 3, v: 0.25 },
|
|
16
|
+
{ t: 4, v: 1 },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeEach(clearAutomationClipboard);
|
|
21
|
+
|
|
22
|
+
describe("automation clipboard", () => {
|
|
23
|
+
it("copies the range rebased to zero", () => {
|
|
24
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
25
|
+
const entry = readClipboard("project-a");
|
|
26
|
+
expect(entry?.span).toBe(2);
|
|
27
|
+
expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]);
|
|
28
|
+
expect(entry?.points[0]?.curve).toBe(-0.4);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("pastes at a new time on the same axis unchanged", () => {
|
|
32
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
33
|
+
const entry = readClipboard("project-a");
|
|
34
|
+
expect(entry).not.toBeNull();
|
|
35
|
+
if (!entry) return;
|
|
36
|
+
const pts = pastePoints(entry, VOLUME_RANGE, 10);
|
|
37
|
+
expect(pts.map((p) => p.t)).toEqual([10, 11, 12]);
|
|
38
|
+
expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("maps values through unit space onto a different parameter", () => {
|
|
42
|
+
// fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
|
|
43
|
+
// linear unit math and a literal copy of the source value would both
|
|
44
|
+
// read as a passing test on a range that happens to be numerically
|
|
45
|
+
// identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
|
|
46
|
+
// genuinely log for the test to discriminate real unit-space mapping.
|
|
47
|
+
const frequency = resolveAutomationRange("fx.n1.frequency", {
|
|
48
|
+
version: 1,
|
|
49
|
+
nodes: [{ type: "lowpass", id: "n1", params: {} }],
|
|
50
|
+
});
|
|
51
|
+
expect(frequency).toBeTruthy();
|
|
52
|
+
if (!frequency) return;
|
|
53
|
+
expect(frequency.scale).toBe("log");
|
|
54
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
55
|
+
const entry = readClipboard("project-a");
|
|
56
|
+
if (!entry) return;
|
|
57
|
+
const pts = pastePoints(entry, frequency, 0);
|
|
58
|
+
// volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
|
|
59
|
+
// up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
|
|
60
|
+
// NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
|
|
61
|
+
// literal copy of 0.25.
|
|
62
|
+
expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
|
|
63
|
+
const expectedLog = Math.exp(
|
|
64
|
+
Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
|
|
65
|
+
);
|
|
66
|
+
const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
|
|
67
|
+
expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
|
|
68
|
+
expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
|
|
69
|
+
expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reads null when nothing was copied", () => {
|
|
73
|
+
expect(readClipboard("project-a")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not hand a range copied in one project to another", () => {
|
|
77
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
78
|
+
expect(readClipboard("project-b")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("drops the entry for good once another project has read past it", () => {
|
|
82
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
83
|
+
readClipboard("project-b");
|
|
84
|
+
// Not merely hidden from B: switching back must not resurrect a shape whose
|
|
85
|
+
// source clip may have been edited or deleted while the project was closed.
|
|
86
|
+
expect(readClipboard("project-a")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps serving the entry inside its own project", () => {
|
|
90
|
+
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
|
91
|
+
expect(readClipboard("project-a")?.span).toBe(2);
|
|
92
|
+
expect(readClipboard("project-a")?.span).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
});
|