@hyperframes/studio 0.7.110 → 0.8.0
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-yDk2KgEb.js → hyperframes-player-BShTvtlv.js} +1 -1
- package/dist/assets/{index-f54_Hm8v.js → index-C8UQR5cB.js} +4 -4
- package/dist/assets/{index-CFEvD03z.js → index-CNMWti50.js} +1 -1
- package/dist/assets/{index-LD9FNUvT.js → index-qKEWQDBH.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +2703 -2697
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +8 -50
- package/src/components/StudioRightPanel.types.ts +55 -0
- package/src/player/components/TimelineAutomationLane.tsx +0 -175
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +187 -0
- package/src/player/components/TimelineLanes.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/parsers": "0.
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.8.0",
|
|
51
|
+
"@hyperframes/player": "0.8.0",
|
|
52
|
+
"@hyperframes/studio-server": "0.8.0",
|
|
53
|
+
"@hyperframes/parsers": "0.8.0",
|
|
54
|
+
"@hyperframes/sdk": "0.8.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vite": "^6.4.2",
|
|
69
69
|
"vitest": "^3.2.4",
|
|
70
70
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.
|
|
71
|
+
"@hyperframes/producer": "0.8.0"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import type { StudioRightPanelProps } from "./StudioRightPanel.types";
|
|
3
|
+
|
|
4
|
+
export type { StudioRightPanelProps };
|
|
2
5
|
import { PropertyPanel } from "./editor/PropertyPanel";
|
|
3
6
|
import { LayersPanel } from "./editor/LayersPanel";
|
|
4
7
|
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
|
5
8
|
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
|
6
9
|
import { RenderQueue } from "./renders/RenderQueue";
|
|
7
10
|
import { SlideshowPanel } from "./panels/SlideshowPanel";
|
|
8
|
-
import { VariablesPanel
|
|
11
|
+
import { VariablesPanel } from "./panels/VariablesPanel";
|
|
9
12
|
import { PanelTabButton } from "./PanelTabButton";
|
|
10
13
|
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
|
|
11
14
|
import type { RenderJob } from "./renders/useRenderQueue";
|
|
12
|
-
import type { BlockParam } from "@hyperframes/core/registry";
|
|
13
15
|
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
|
|
14
|
-
import
|
|
15
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
16
|
-
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
|
|
16
|
+
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
|
|
17
17
|
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
|
|
18
18
|
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
|
|
19
19
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
@@ -27,52 +27,10 @@ import {
|
|
|
27
27
|
EMPTY_COLOR_GRADING_SCOPE_RESULT,
|
|
28
28
|
type ColorGradingScope,
|
|
29
29
|
} from "./studioColorGradingScope";
|
|
30
|
-
import type {
|
|
31
|
-
|
|
32
|
-
BackgroundRemovalProgress,
|
|
33
|
-
} from "./editor/propertyPanelTypes";
|
|
34
|
-
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
|
|
30
|
+
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
|
|
31
|
+
import { timelineKeysForSelections } from "../utils/studioHelpers";
|
|
35
32
|
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
|
|
36
33
|
|
|
37
|
-
export interface StudioRightPanelProps extends StudioEditPersistenceProps {
|
|
38
|
-
designPanelActive: boolean;
|
|
39
|
-
activeBlockParams?: {
|
|
40
|
-
blockName: string;
|
|
41
|
-
blockTitle: string;
|
|
42
|
-
params: BlockParam[];
|
|
43
|
-
compositionPath: string;
|
|
44
|
-
} | null;
|
|
45
|
-
onCloseBlockParams?: () => void;
|
|
46
|
-
recordingState?: "idle" | "recording" | "preview";
|
|
47
|
-
recordingDuration?: number;
|
|
48
|
-
onToggleRecording?: () => void;
|
|
49
|
-
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
|
50
|
-
sdkSession: Composition | null;
|
|
51
|
-
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
|
|
52
|
-
/**
|
|
53
|
-
* Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
|
|
54
|
-
* opens its own separate SDK session scoped to the selected element's own
|
|
55
|
-
* file (needed so promoting inside a sub-composition binds a variable there,
|
|
56
|
-
* not on the host) — for a top-level selection that's the SAME file this
|
|
57
|
-
* session already has open, so a write through that other session leaves
|
|
58
|
-
* this one holding stale in-memory content. The self-write-echo registry
|
|
59
|
-
* that normally suppresses redundant reloads is keyed by file path only, not
|
|
60
|
-
* by session instance, so it wrongly treats the sibling session's write as
|
|
61
|
-
* "our own echo" and never reloads on its own — this must be called
|
|
62
|
-
* explicitly after such a write.
|
|
63
|
-
*/
|
|
64
|
-
forceReloadSdkSession?: () => void;
|
|
65
|
-
reloadPreview: () => void;
|
|
66
|
-
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
67
|
-
recordEdit: (entry: {
|
|
68
|
-
label: string;
|
|
69
|
-
kind: EditHistoryKind;
|
|
70
|
-
files: Record<string, { before: string; after: string }>;
|
|
71
|
-
}) => Promise<void>;
|
|
72
|
-
onToggleElementHidden?: ToggleHiddenHandler;
|
|
73
|
-
onAddMediaOverlay?: AddMediaOverlayHandler;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
34
|
// fallow-ignore-next-line complexity
|
|
77
35
|
export function StudioRightPanel({
|
|
78
36
|
designPanelActive,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props for StudioRightPanel.
|
|
3
|
+
*
|
|
4
|
+
* Kept beside the component rather than inside it: the panel is at the file-size
|
|
5
|
+
* cap, and this block is the part that changes least, so moving it keeps the
|
|
6
|
+
* component's own diffs small and readable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MutableRefObject } from "react";
|
|
10
|
+
import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
|
|
11
|
+
import type { BlockParam } from "@hyperframes/core/registry";
|
|
12
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
13
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
14
|
+
import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
|
|
15
|
+
import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes";
|
|
16
|
+
import type { ToggleHiddenHandler } from "../utils/studioHelpers";
|
|
17
|
+
|
|
18
|
+
export interface StudioRightPanelProps extends StudioEditPersistenceProps {
|
|
19
|
+
designPanelActive: boolean;
|
|
20
|
+
activeBlockParams?: {
|
|
21
|
+
blockName: string;
|
|
22
|
+
blockTitle: string;
|
|
23
|
+
params: BlockParam[];
|
|
24
|
+
compositionPath: string;
|
|
25
|
+
} | null;
|
|
26
|
+
onCloseBlockParams?: () => void;
|
|
27
|
+
recordingState?: "idle" | "recording" | "preview";
|
|
28
|
+
recordingDuration?: number;
|
|
29
|
+
onToggleRecording?: () => void;
|
|
30
|
+
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
|
31
|
+
sdkSession: Composition | null;
|
|
32
|
+
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
|
|
33
|
+
/**
|
|
34
|
+
* Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
|
|
35
|
+
* opens its own separate SDK session scoped to the selected element's own
|
|
36
|
+
* file (needed so promoting inside a sub-composition binds a variable there,
|
|
37
|
+
* not on the host) — for a top-level selection that's the SAME file this
|
|
38
|
+
* session already has open, so a write through that other session leaves
|
|
39
|
+
* this one holding stale in-memory content. The self-write-echo registry
|
|
40
|
+
* that normally suppresses redundant reloads is keyed by file path only, not
|
|
41
|
+
* by session instance, so it wrongly treats the sibling session's write as
|
|
42
|
+
* "our own echo" and never reloads on its own — this must be called
|
|
43
|
+
* explicitly after such a write.
|
|
44
|
+
*/
|
|
45
|
+
forceReloadSdkSession?: () => void;
|
|
46
|
+
reloadPreview: () => void;
|
|
47
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
48
|
+
recordEdit: (entry: {
|
|
49
|
+
label: string;
|
|
50
|
+
kind: EditHistoryKind;
|
|
51
|
+
files: Record<string, { before: string; after: string }>;
|
|
52
|
+
}) => Promise<void>;
|
|
53
|
+
onToggleElementHidden?: ToggleHiddenHandler;
|
|
54
|
+
onAddMediaOverlay?: AddMediaOverlayHandler;
|
|
55
|
+
}
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
type MouseEvent as ReactMouseEvent,
|
|
28
28
|
} from "react";
|
|
29
29
|
import {
|
|
30
|
-
resolveAutomationRange,
|
|
31
30
|
sampleAutomationLane,
|
|
32
31
|
type AutomationRange,
|
|
33
32
|
type HfAutomation,
|
|
@@ -42,13 +41,7 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
|
42
41
|
import { generateShape, type AutomationShapeId } from "./automationShapes";
|
|
43
42
|
import { simplifyPoints } from "./automationSimplify";
|
|
44
43
|
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
|
|
45
|
-
import { getTimelineLaneTop } from "./timelineLayout";
|
|
46
44
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
47
|
-
import { groupAutomationLanes } from "./automationLaneData";
|
|
48
|
-
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
49
|
-
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
50
|
-
import type { TimelineElement } from "../store/playerStore";
|
|
51
|
-
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
|
52
45
|
|
|
53
46
|
/**
|
|
54
47
|
* Drawn radius of a breakpoint.
|
|
@@ -504,171 +497,3 @@ export function TimelineAutomationLane({
|
|
|
504
497
|
</div>
|
|
505
498
|
);
|
|
506
499
|
}
|
|
507
|
-
|
|
508
|
-
/** Which shared rows one clip draws into, and with which of its lanes. */
|
|
509
|
-
interface ClipLaneRow {
|
|
510
|
-
lane: HfAutomationLane;
|
|
511
|
-
rowIndex: number;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* One clip's envelopes, each in the shared row its property owns.
|
|
516
|
-
*
|
|
517
|
-
* Its own component because every clip on the row needs its own binding, its own
|
|
518
|
-
* gestures and its own selection box — a shared row is a shared lane track, not a
|
|
519
|
-
* shared envelope, and two clips' curves must never drag as one thing. Hooks
|
|
520
|
-
* cannot run in a loop, so the loop is over components.
|
|
521
|
-
*/
|
|
522
|
-
function ClipAutomationLanes({
|
|
523
|
-
element,
|
|
524
|
-
rows,
|
|
525
|
-
isSelected,
|
|
526
|
-
lanes,
|
|
527
|
-
pps,
|
|
528
|
-
top,
|
|
529
|
-
accentColor,
|
|
530
|
-
currentTime,
|
|
531
|
-
beatTimes,
|
|
532
|
-
}: {
|
|
533
|
-
element: TimelineElement;
|
|
534
|
-
rows: readonly ClipLaneRow[];
|
|
535
|
-
isSelected: boolean;
|
|
536
|
-
lanes: UseAutomationLanesResult;
|
|
537
|
-
pps: number;
|
|
538
|
-
/** y of the first automation row on this track. */
|
|
539
|
-
top: number;
|
|
540
|
-
accentColor: string;
|
|
541
|
-
currentTime: number;
|
|
542
|
-
beatTimes?: readonly number[];
|
|
543
|
-
}) {
|
|
544
|
-
// Beats inside this clip, in the clip's own frame — the lane's times are
|
|
545
|
-
// clip-local, and a beat outside the clip can never be snapped to anyway.
|
|
546
|
-
const snapTimes = useMemo(
|
|
547
|
-
() =>
|
|
548
|
-
(beatTimes ?? [])
|
|
549
|
-
.filter((t) => t >= element.start && t <= element.start + element.duration)
|
|
550
|
-
.map((t) => t - element.start),
|
|
551
|
-
[beatTimes, element.start, element.duration],
|
|
552
|
-
);
|
|
553
|
-
const bound = lanes.bind(element, isSelected);
|
|
554
|
-
// Stale-selection guard: the selected lane's target can vanish out from under
|
|
555
|
-
// it (e.g. its effect got deleted from the chain, dropping the lane), leaving
|
|
556
|
-
// a rectangle selecting nothing. Clear it rather than let it point at a
|
|
557
|
-
// target that no longer draws. Above the empty-rows return, because a clip
|
|
558
|
-
// that draws nothing is exactly when a selection goes stale.
|
|
559
|
-
useEffect(() => {
|
|
560
|
-
const target = bound.selection?.target;
|
|
561
|
-
if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) {
|
|
562
|
-
bound.onRangeClear();
|
|
563
|
-
}
|
|
564
|
-
}, [bound]);
|
|
565
|
-
if (rows.length === 0) return null;
|
|
566
|
-
const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
|
|
567
|
-
return (
|
|
568
|
-
<>
|
|
569
|
-
{rows.map(({ lane, rowIndex }) => {
|
|
570
|
-
const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
|
|
571
|
-
// A lane whose target no longer resolves was already dropped upstream;
|
|
572
|
-
// this is belt and braces so a row can never draw on the wrong axis.
|
|
573
|
-
if (!range) return null;
|
|
574
|
-
return (
|
|
575
|
-
<TimelineAutomationLane
|
|
576
|
-
key={lane.target}
|
|
577
|
-
duration={element.duration}
|
|
578
|
-
widthPx={Math.max(element.duration * pps, 4)}
|
|
579
|
-
leftPx={element.start * pps}
|
|
580
|
-
topPx={top + rowIndex * AUTOMATION_LANE_H}
|
|
581
|
-
automation={bound.automation}
|
|
582
|
-
target={lane.target}
|
|
583
|
-
range={range}
|
|
584
|
-
accentColor={accentColor}
|
|
585
|
-
playheadSec={inClip ? currentTime - element.start : null}
|
|
586
|
-
onPreview={bound.onPreview}
|
|
587
|
-
onCommit={bound.onCommit}
|
|
588
|
-
onSelect={bound.onSelect}
|
|
589
|
-
snapTimes={snapTimes}
|
|
590
|
-
readOnly={bound.readOnly}
|
|
591
|
-
rangeSelection={
|
|
592
|
-
bound.selection?.target === lane.target
|
|
593
|
-
? {
|
|
594
|
-
t0: bound.selection.t0,
|
|
595
|
-
t1: bound.selection.t1,
|
|
596
|
-
v0: bound.selection.v0,
|
|
597
|
-
v1: bound.selection.v1,
|
|
598
|
-
}
|
|
599
|
-
: null
|
|
600
|
-
}
|
|
601
|
-
onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
|
|
602
|
-
onRangeClear={bound.onRangeClear}
|
|
603
|
-
/>
|
|
604
|
-
);
|
|
605
|
-
})}
|
|
606
|
-
</>
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
export interface TimelineAutomationLaneSlotProps {
|
|
611
|
-
/** Every clip on the track, in row order — not just the selected one. */
|
|
612
|
-
elements: readonly TimelineElement[];
|
|
613
|
-
isSelected: (element: TimelineElement) => boolean;
|
|
614
|
-
lanes: UseAutomationLanesResult;
|
|
615
|
-
pps: number;
|
|
616
|
-
/** Keyframe lanes already stacked above, which automation sits under. */
|
|
617
|
-
laneCount: number;
|
|
618
|
-
accentColor: string;
|
|
619
|
-
/** Composition-time playhead; the slot converts it to clip-local. */
|
|
620
|
-
currentTime: number;
|
|
621
|
-
/** Composition-time beat grid; the slot converts it to clip-local too. */
|
|
622
|
-
beatTimes?: readonly number[];
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Every automated parameter on this TRACK, one lane per row — the way a DAW
|
|
627
|
-
* stacks them, so two envelopes can be read and edited without swapping a
|
|
628
|
-
* control to see either.
|
|
629
|
-
*
|
|
630
|
-
* Rows belong to the track, not to a clip: clips sharing a row share a row per
|
|
631
|
-
* property (see `groupAutomationLanes`), each drawing over its own span, and a
|
|
632
|
-
* clip that does not automate that property leaves its stretch empty. Binding one
|
|
633
|
-
* clip at a time is what made the visible envelopes change with the selection.
|
|
634
|
-
*/
|
|
635
|
-
export function TimelineAutomationLaneSlot({
|
|
636
|
-
elements,
|
|
637
|
-
isSelected,
|
|
638
|
-
lanes,
|
|
639
|
-
pps,
|
|
640
|
-
laneCount,
|
|
641
|
-
accentColor,
|
|
642
|
-
currentTime,
|
|
643
|
-
beatTimes,
|
|
644
|
-
}: TimelineAutomationLaneSlotProps) {
|
|
645
|
-
const clips = elements.filter(isAudioTimelineElement);
|
|
646
|
-
const rowsByClip = new Map<string, ClipLaneRow[]>();
|
|
647
|
-
groupAutomationLanes(clips).forEach((group, rowIndex) => {
|
|
648
|
-
for (const entry of group.entries) {
|
|
649
|
-
const key = getTimelineElementIdentity(entry.element);
|
|
650
|
-
const rows = rowsByClip.get(key);
|
|
651
|
-
if (rows) rows.push({ lane: entry.lane, rowIndex });
|
|
652
|
-
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
|
|
653
|
-
}
|
|
654
|
-
});
|
|
655
|
-
const top = getTimelineLaneTop(laneCount);
|
|
656
|
-
return (
|
|
657
|
-
<>
|
|
658
|
-
{clips.map((element) => (
|
|
659
|
-
<ClipAutomationLanes
|
|
660
|
-
key={getTimelineElementIdentity(element)}
|
|
661
|
-
element={element}
|
|
662
|
-
rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
|
|
663
|
-
isSelected={isSelected(element)}
|
|
664
|
-
lanes={lanes}
|
|
665
|
-
pps={pps}
|
|
666
|
-
top={top}
|
|
667
|
-
accentColor={accentColor}
|
|
668
|
-
currentTime={currentTime}
|
|
669
|
-
beatTimes={beatTimes}
|
|
670
|
-
/>
|
|
671
|
-
))}
|
|
672
|
-
</>
|
|
673
|
-
);
|
|
674
|
-
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { TimelineAutomationLaneSlot } from "./
|
|
5
|
+
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
|
|
6
6
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
7
7
|
import { PAD_X } from "./automationLaneGeometry";
|
|
8
8
|
import { getTimelineLaneTop } from "./timelineLayout";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track-level automation: the stack of shared rows an audio track draws, and
|
|
3
|
+
* one clip's envelopes within them.
|
|
4
|
+
*
|
|
5
|
+
* Split from TimelineAutomationLane.tsx, which owns the single-lane editor this
|
|
6
|
+
* renders many of. The dependency runs one way, slot -> lane, so the editor
|
|
7
|
+
* stays readable on its own and this file keeps the row-layout concern.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useMemo } from "react";
|
|
11
|
+
import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
12
|
+
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
|
13
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
14
|
+
import { getTimelineLaneTop } from "./timelineLayout";
|
|
15
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
16
|
+
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
17
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
18
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
19
|
+
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
|
20
|
+
|
|
21
|
+
/** Which shared rows one clip draws into, and with which of its lanes. */
|
|
22
|
+
interface ClipLaneRow {
|
|
23
|
+
lane: HfAutomationLane;
|
|
24
|
+
rowIndex: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One clip's envelopes, each in the shared row its property owns.
|
|
29
|
+
*
|
|
30
|
+
* Its own component because every clip on the row needs its own binding, its own
|
|
31
|
+
* gestures and its own selection box — a shared row is a shared lane track, not a
|
|
32
|
+
* shared envelope, and two clips' curves must never drag as one thing. Hooks
|
|
33
|
+
* cannot run in a loop, so the loop is over components.
|
|
34
|
+
*/
|
|
35
|
+
function ClipAutomationLanes({
|
|
36
|
+
element,
|
|
37
|
+
rows,
|
|
38
|
+
isSelected,
|
|
39
|
+
lanes,
|
|
40
|
+
pps,
|
|
41
|
+
top,
|
|
42
|
+
accentColor,
|
|
43
|
+
currentTime,
|
|
44
|
+
beatTimes,
|
|
45
|
+
}: {
|
|
46
|
+
element: TimelineElement;
|
|
47
|
+
rows: readonly ClipLaneRow[];
|
|
48
|
+
isSelected: boolean;
|
|
49
|
+
lanes: UseAutomationLanesResult;
|
|
50
|
+
pps: number;
|
|
51
|
+
/** y of the first automation row on this track. */
|
|
52
|
+
top: number;
|
|
53
|
+
accentColor: string;
|
|
54
|
+
currentTime: number;
|
|
55
|
+
beatTimes?: readonly number[];
|
|
56
|
+
}) {
|
|
57
|
+
// Beats inside this clip, in the clip's own frame — the lane's times are
|
|
58
|
+
// clip-local, and a beat outside the clip can never be snapped to anyway.
|
|
59
|
+
const snapTimes = useMemo(
|
|
60
|
+
() =>
|
|
61
|
+
(beatTimes ?? [])
|
|
62
|
+
.filter((t) => t >= element.start && t <= element.start + element.duration)
|
|
63
|
+
.map((t) => t - element.start),
|
|
64
|
+
[beatTimes, element.start, element.duration],
|
|
65
|
+
);
|
|
66
|
+
const bound = lanes.bind(element, isSelected);
|
|
67
|
+
// Stale-selection guard: the selected lane's target can vanish out from under
|
|
68
|
+
// it (e.g. its effect got deleted from the chain, dropping the lane), leaving
|
|
69
|
+
// a rectangle selecting nothing. Clear it rather than let it point at a
|
|
70
|
+
// target that no longer draws. Above the empty-rows return, because a clip
|
|
71
|
+
// that draws nothing is exactly when a selection goes stale.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const target = bound.selection?.target;
|
|
74
|
+
if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) {
|
|
75
|
+
bound.onRangeClear();
|
|
76
|
+
}
|
|
77
|
+
}, [bound]);
|
|
78
|
+
if (rows.length === 0) return null;
|
|
79
|
+
const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{rows.map(({ lane, rowIndex }) => {
|
|
83
|
+
const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
|
|
84
|
+
// A lane whose target no longer resolves was already dropped upstream;
|
|
85
|
+
// this is belt and braces so a row can never draw on the wrong axis.
|
|
86
|
+
if (!range) return null;
|
|
87
|
+
return (
|
|
88
|
+
<TimelineAutomationLane
|
|
89
|
+
key={lane.target}
|
|
90
|
+
duration={element.duration}
|
|
91
|
+
widthPx={Math.max(element.duration * pps, 4)}
|
|
92
|
+
leftPx={element.start * pps}
|
|
93
|
+
topPx={top + rowIndex * AUTOMATION_LANE_H}
|
|
94
|
+
automation={bound.automation}
|
|
95
|
+
target={lane.target}
|
|
96
|
+
range={range}
|
|
97
|
+
accentColor={accentColor}
|
|
98
|
+
playheadSec={inClip ? currentTime - element.start : null}
|
|
99
|
+
onPreview={bound.onPreview}
|
|
100
|
+
onCommit={bound.onCommit}
|
|
101
|
+
onSelect={bound.onSelect}
|
|
102
|
+
snapTimes={snapTimes}
|
|
103
|
+
readOnly={bound.readOnly}
|
|
104
|
+
rangeSelection={
|
|
105
|
+
bound.selection?.target === lane.target
|
|
106
|
+
? {
|
|
107
|
+
t0: bound.selection.t0,
|
|
108
|
+
t1: bound.selection.t1,
|
|
109
|
+
v0: bound.selection.v0,
|
|
110
|
+
v1: bound.selection.v1,
|
|
111
|
+
}
|
|
112
|
+
: null
|
|
113
|
+
}
|
|
114
|
+
onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
|
|
115
|
+
onRangeClear={bound.onRangeClear}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TimelineAutomationLaneSlotProps {
|
|
124
|
+
/** Every clip on the track, in row order — not just the selected one. */
|
|
125
|
+
elements: readonly TimelineElement[];
|
|
126
|
+
isSelected: (element: TimelineElement) => boolean;
|
|
127
|
+
lanes: UseAutomationLanesResult;
|
|
128
|
+
pps: number;
|
|
129
|
+
/** Keyframe lanes already stacked above, which automation sits under. */
|
|
130
|
+
laneCount: number;
|
|
131
|
+
accentColor: string;
|
|
132
|
+
/** Composition-time playhead; the slot converts it to clip-local. */
|
|
133
|
+
currentTime: number;
|
|
134
|
+
/** Composition-time beat grid; the slot converts it to clip-local too. */
|
|
135
|
+
beatTimes?: readonly number[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Every automated parameter on this TRACK, one lane per row — the way a DAW
|
|
140
|
+
* stacks them, so two envelopes can be read and edited without swapping a
|
|
141
|
+
* control to see either.
|
|
142
|
+
*
|
|
143
|
+
* Rows belong to the track, not to a clip: clips sharing a row share a row per
|
|
144
|
+
* property (see `groupAutomationLanes`), each drawing over its own span, and a
|
|
145
|
+
* clip that does not automate that property leaves its stretch empty. Binding one
|
|
146
|
+
* clip at a time is what made the visible envelopes change with the selection.
|
|
147
|
+
*/
|
|
148
|
+
export function TimelineAutomationLaneSlot({
|
|
149
|
+
elements,
|
|
150
|
+
isSelected,
|
|
151
|
+
lanes,
|
|
152
|
+
pps,
|
|
153
|
+
laneCount,
|
|
154
|
+
accentColor,
|
|
155
|
+
currentTime,
|
|
156
|
+
beatTimes,
|
|
157
|
+
}: TimelineAutomationLaneSlotProps) {
|
|
158
|
+
const clips = elements.filter(isAudioTimelineElement);
|
|
159
|
+
const rowsByClip = new Map<string, ClipLaneRow[]>();
|
|
160
|
+
groupAutomationLanes(clips).forEach((group, rowIndex) => {
|
|
161
|
+
for (const entry of group.entries) {
|
|
162
|
+
const key = getTimelineElementIdentity(entry.element);
|
|
163
|
+
const rows = rowsByClip.get(key);
|
|
164
|
+
if (rows) rows.push({ lane: entry.lane, rowIndex });
|
|
165
|
+
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const top = getTimelineLaneTop(laneCount);
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
{clips.map((element) => (
|
|
172
|
+
<ClipAutomationLanes
|
|
173
|
+
key={getTimelineElementIdentity(element)}
|
|
174
|
+
element={element}
|
|
175
|
+
rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
|
|
176
|
+
isSelected={isSelected(element)}
|
|
177
|
+
lanes={lanes}
|
|
178
|
+
pps={pps}
|
|
179
|
+
top={top}
|
|
180
|
+
accentColor={accentColor}
|
|
181
|
+
currentTime={currentTime}
|
|
182
|
+
beatTimes={beatTimes}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -3,7 +3,7 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
4
|
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
5
5
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
6
|
-
import { TimelineAutomationLaneSlot } from "./
|
|
6
|
+
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
|
|
7
7
|
import { useAutomationLanes } from "./useAutomationLanes";
|
|
8
8
|
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
|
|
9
9
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|