@hyperframes/studio 0.8.85 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
- package/dist/chunk-7PLTC6SV.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +207 -161
- package/dist/index.html +2 -2
- package/dist/index.js +2084 -1705
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +53 -2
- package/src/components/TimelineToolbar.tsx +119 -101
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +179 -0
- package/src/components/nle/useCompositionStack.ts +26 -20
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
- package/src/player/components/Timeline.test.ts +32 -19
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineLayout.ts +16 -23
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineGeometry.test.tsx +105 -0
- package/src/player/components/useTimelineGeometry.ts +18 -10
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelinePlayhead.ts +1 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/liveTime.ts +6 -1
- package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7VDQIGOU.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -213,7 +213,9 @@ describe("Timeline provider boundary", () => {
|
|
|
213
213
|
100,
|
|
214
214
|
).start,
|
|
215
215
|
).toBe(10);
|
|
216
|
-
expect(getTimelineFitPps(640, 11, GUTTER)).
|
|
216
|
+
expect(getTimelineFitPps(640, 11, GUTTER)).toBeCloseTo(
|
|
217
|
+
(640 - GUTTER - 2) / (11 * FIT_ZOOM_HEADROOM),
|
|
218
|
+
);
|
|
217
219
|
|
|
218
220
|
act(() => root.unmount());
|
|
219
221
|
});
|
|
@@ -313,7 +315,7 @@ describe("Timeline provider boundary", () => {
|
|
|
313
315
|
expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
|
|
314
316
|
expect(collapsedHeader.textContent).toContain("Outro");
|
|
315
317
|
expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
|
|
316
|
-
(640 - (LABEL_COL_W + GUTTER) - 2) /
|
|
318
|
+
(640 - (LABEL_COL_W + GUTTER) - 2) / (20 * FIT_ZOOM_HEADROOM),
|
|
317
319
|
);
|
|
318
320
|
expect(
|
|
319
321
|
resolveTimelineAssetDrop(
|
|
@@ -948,15 +950,13 @@ describe("shouldAutoScrollTimeline", () => {
|
|
|
948
950
|
});
|
|
949
951
|
});
|
|
950
952
|
|
|
951
|
-
describe("getTimelineFitPps (
|
|
953
|
+
describe("getTimelineFitPps (fit headroom, 60s floor only without a duration)", () => {
|
|
952
954
|
const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
|
|
953
955
|
|
|
954
|
-
it("
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
|
|
959
|
-
expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
|
|
956
|
+
it("fits a short film to the width, not to a 60s ruler", () => {
|
|
957
|
+
const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
|
|
958
|
+
const pps = getTimelineFitPps(viewport, 17, GUTTER + TRACKS_LEFT_PAD);
|
|
959
|
+
expect(17 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
|
|
960
960
|
});
|
|
961
961
|
|
|
962
962
|
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
|
|
@@ -1000,11 +1000,10 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
|
|
1000
1000
|
});
|
|
1001
1001
|
|
|
1002
1002
|
describe("getTimelineDisplayContentWidth", () => {
|
|
1003
|
-
it("
|
|
1004
|
-
// 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
|
|
1003
|
+
it("spans MIN_TIMELINE_EXTENT_S seconds while the duration is unknown", () => {
|
|
1005
1004
|
expect(
|
|
1006
1005
|
getTimelineDisplayContentWidth({
|
|
1007
|
-
|
|
1006
|
+
effectiveDuration: 0,
|
|
1008
1007
|
viewportWidth: 400,
|
|
1009
1008
|
contentOrigin: GUTTER,
|
|
1010
1009
|
pps: 20,
|
|
@@ -1012,10 +1011,24 @@ describe("getTimelineDisplayContentWidth", () => {
|
|
|
1012
1011
|
).toBe(MIN_TIMELINE_EXTENT_S * 20);
|
|
1013
1012
|
});
|
|
1014
1013
|
|
|
1015
|
-
it("
|
|
1014
|
+
it("renders a short film at fit exactly as wide as the viewport, so fit never scrolls", () => {
|
|
1015
|
+
const viewport = 632;
|
|
1016
|
+
const origin = GUTTER + TRACKS_LEFT_PAD;
|
|
1017
|
+
const pps = getTimelineFitPps(viewport, 17, origin);
|
|
1018
|
+
expect(
|
|
1019
|
+
getTimelineDisplayContentWidth({
|
|
1020
|
+
effectiveDuration: 17,
|
|
1021
|
+
viewportWidth: viewport,
|
|
1022
|
+
contentOrigin: origin,
|
|
1023
|
+
pps,
|
|
1024
|
+
}),
|
|
1025
|
+
).toBeCloseTo(viewport - origin - 2);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("still fills the viewport when that is larger than the fit span", () => {
|
|
1016
1029
|
expect(
|
|
1017
1030
|
getTimelineDisplayContentWidth({
|
|
1018
|
-
|
|
1031
|
+
effectiveDuration: 40,
|
|
1019
1032
|
viewportWidth: 2000,
|
|
1020
1033
|
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
|
1021
1034
|
pps: 5,
|
|
@@ -1026,7 +1039,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
|
|
1026
1039
|
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
|
|
1027
1040
|
expect(
|
|
1028
1041
|
getTimelineDisplayContentWidth({
|
|
1029
|
-
|
|
1042
|
+
effectiveDuration: 100,
|
|
1030
1043
|
viewportWidth: 400,
|
|
1031
1044
|
contentOrigin: GUTTER,
|
|
1032
1045
|
pps: 5,
|
|
@@ -1038,7 +1051,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
|
|
1038
1051
|
it("tracks a resize (trim) ghost past every other bound (trim-to-extend)", () => {
|
|
1039
1052
|
expect(
|
|
1040
1053
|
getTimelineDisplayContentWidth({
|
|
1041
|
-
|
|
1054
|
+
effectiveDuration: 100,
|
|
1042
1055
|
viewportWidth: 400,
|
|
1043
1056
|
contentOrigin: GUTTER,
|
|
1044
1057
|
pps: 5,
|
|
@@ -1047,15 +1060,15 @@ describe("getTimelineDisplayContentWidth", () => {
|
|
|
1047
1060
|
).toBe(4200);
|
|
1048
1061
|
});
|
|
1049
1062
|
|
|
1050
|
-
it("keeps
|
|
1063
|
+
it("keeps the fit headroom past the end when zoomed in", () => {
|
|
1051
1064
|
expect(
|
|
1052
1065
|
getTimelineDisplayContentWidth({
|
|
1053
|
-
|
|
1066
|
+
effectiveDuration: 180,
|
|
1054
1067
|
viewportWidth: 400,
|
|
1055
1068
|
contentOrigin: GUTTER,
|
|
1056
1069
|
pps: 50,
|
|
1057
1070
|
}),
|
|
1058
|
-
).
|
|
1071
|
+
).toBeCloseTo(180 * FIT_ZOOM_HEADROOM * 50);
|
|
1059
1072
|
});
|
|
1060
1073
|
});
|
|
1061
1074
|
|
|
@@ -24,6 +24,7 @@ interface TimelineGroupHeaderProps {
|
|
|
24
24
|
onOpenFxRack: () => void;
|
|
25
25
|
columnWidth: number;
|
|
26
26
|
theme: TimelineTheme;
|
|
27
|
+
showAudioEffects?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -41,17 +42,19 @@ function GroupNameButton({
|
|
|
41
42
|
label,
|
|
42
43
|
memberCount,
|
|
43
44
|
onOpenFxRack,
|
|
45
|
+
showAudioEffects,
|
|
44
46
|
}: {
|
|
45
47
|
label: string;
|
|
46
48
|
memberCount: number;
|
|
47
49
|
onOpenFxRack: () => void;
|
|
50
|
+
showAudioEffects: boolean;
|
|
48
51
|
}) {
|
|
49
52
|
return (
|
|
50
53
|
<button
|
|
51
54
|
type="button"
|
|
52
55
|
tabIndex={-1}
|
|
53
|
-
aria-label={`Open ${label} effects`}
|
|
54
|
-
title="Open effects"
|
|
56
|
+
aria-label={showAudioEffects ? `Open ${label} effects` : `Select ${label}`}
|
|
57
|
+
title={showAudioEffects ? "Open effects" : "Select group"}
|
|
55
58
|
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
|
|
56
59
|
// claiming it here would push the controls off the right edge — and the
|
|
57
60
|
// count with them, since it rides inside this button.
|
|
@@ -97,6 +100,7 @@ export function TimelineGroupHeader({
|
|
|
97
100
|
onOpenFxRack,
|
|
98
101
|
columnWidth,
|
|
99
102
|
theme,
|
|
103
|
+
showAudioEffects = true,
|
|
100
104
|
}: TimelineGroupHeaderProps) {
|
|
101
105
|
return (
|
|
102
106
|
<div
|
|
@@ -137,17 +141,24 @@ export function TimelineGroupHeader({
|
|
|
137
141
|
▸ sits off-centre in its box because the glyph is not square. */}
|
|
138
142
|
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
|
|
139
143
|
</button>
|
|
140
|
-
<GroupNameButton
|
|
144
|
+
<GroupNameButton
|
|
145
|
+
label={label}
|
|
146
|
+
memberCount={memberCount}
|
|
147
|
+
onOpenFxRack={onOpenFxRack}
|
|
148
|
+
showAudioEffects={showAudioEffects}
|
|
149
|
+
/>
|
|
141
150
|
{/* `ml-auto` absorbs the slack the truncating name leaves, so the controls
|
|
142
151
|
sit on the edge whatever the name's length. */}
|
|
143
152
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
{showAudioEffects && (
|
|
154
|
+
<TimelineFxButton
|
|
155
|
+
fxChainRaw={fxChain}
|
|
156
|
+
onChainChange={onFxChainChange}
|
|
157
|
+
onChainPreview={onFxChainPreview}
|
|
158
|
+
auditionSpans={auditionSpans}
|
|
159
|
+
onOpenRack={onOpenFxRack}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
151
162
|
{/* No lanes, no control: an author who opens it meets an empty row and
|
|
152
163
|
learns nothing. A track header already gates its own `∿` this way
|
|
153
164
|
(`disclosable`); the group's was the one that still offered a
|
|
@@ -63,8 +63,13 @@ export function TimelineGroupLaneLabels({
|
|
|
63
63
|
tabIndex={-1}
|
|
64
64
|
key={lane.target}
|
|
65
65
|
data-group-lane-label={lane.target}
|
|
66
|
-
aria-label={
|
|
67
|
-
|
|
66
|
+
aria-label={
|
|
67
|
+
onReveal
|
|
68
|
+
? `Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`
|
|
69
|
+
: undefined
|
|
70
|
+
}
|
|
71
|
+
disabled={!onReveal}
|
|
72
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] enabled:hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
68
73
|
style={{
|
|
69
74
|
top: top + index * AUTOMATION_LANE_H,
|
|
70
75
|
width: columnWidth,
|
|
@@ -45,9 +45,21 @@ const GROUP: TimelineTrackGroupInfo = {
|
|
|
45
45
|
hidden: false,
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const GAIN_AUTOMATED: Partial<TimelineTrackGroupInfo> = {
|
|
49
|
+
fxChain: JSON.stringify({
|
|
50
|
+
version: 1,
|
|
51
|
+
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
52
|
+
}),
|
|
53
|
+
automation: JSON.stringify({
|
|
54
|
+
version: 1,
|
|
55
|
+
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
48
59
|
function renderRow(
|
|
49
60
|
overrides: Partial<TimelineTrackGroupInfo> = {},
|
|
50
61
|
expandedLaneOwnerIds = new Set<string>(),
|
|
62
|
+
showAudioEffects?: boolean,
|
|
51
63
|
) {
|
|
52
64
|
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
53
65
|
const onSetElementAttributeQuiet = vi.fn();
|
|
@@ -66,6 +78,7 @@ function renderRow(
|
|
|
66
78
|
virtualized={false}
|
|
67
79
|
contentOrigin={232}
|
|
68
80
|
theme={defaultTimelineTheme}
|
|
81
|
+
showAudioEffects={showAudioEffects}
|
|
69
82
|
collapsedGroupIds={new Set()}
|
|
70
83
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
71
84
|
toggleGroupExpanded={vi.fn()}
|
|
@@ -118,19 +131,7 @@ describe("TimelineGroupRow", () => {
|
|
|
118
131
|
});
|
|
119
132
|
|
|
120
133
|
it("opens a group automation lane on its exact rack parameter", async () => {
|
|
121
|
-
const { host } = renderRow(
|
|
122
|
-
{
|
|
123
|
-
fxChain: JSON.stringify({
|
|
124
|
-
version: 1,
|
|
125
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
126
|
-
}),
|
|
127
|
-
automation: JSON.stringify({
|
|
128
|
-
version: 1,
|
|
129
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
130
|
-
}),
|
|
131
|
-
},
|
|
132
|
-
new Set(["voiceover"]),
|
|
133
|
-
);
|
|
134
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]));
|
|
134
135
|
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
135
136
|
|
|
136
137
|
await act(async () => {
|
|
@@ -147,6 +148,14 @@ describe("TimelineGroupRow", () => {
|
|
|
147
148
|
});
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
it("keeps a group lane's name off the rack while audio effects are hidden", () => {
|
|
152
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]), false);
|
|
153
|
+
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
154
|
+
|
|
155
|
+
expect(laneTitle?.disabled).toBe(true);
|
|
156
|
+
expect(laneTitle?.getAttribute("aria-label")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
150
159
|
// C1 names this as the step's own definition of done: "opening the popover on
|
|
151
160
|
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
|
|
152
161
|
// on the group element, and zero writes on members". A group IS a bus — a
|
|
@@ -180,16 +189,7 @@ describe("TimelineGroupRow", () => {
|
|
|
180
189
|
|
|
181
190
|
expect(laneToggle(renderRow().host)).toBeUndefined();
|
|
182
191
|
|
|
183
|
-
const automated = renderRow(
|
|
184
|
-
fxChain: JSON.stringify({
|
|
185
|
-
version: 1,
|
|
186
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
187
|
-
}),
|
|
188
|
-
automation: JSON.stringify({
|
|
189
|
-
version: 1,
|
|
190
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
192
|
+
const automated = renderRow(GAIN_AUTOMATED);
|
|
193
193
|
expect(laneToggle(automated.host)).toBeDefined();
|
|
194
194
|
});
|
|
195
195
|
|
|
@@ -197,16 +197,7 @@ describe("TimelineGroupRow", () => {
|
|
|
197
197
|
// one right-anchored group. It was two lines — name, then controls — which is
|
|
198
198
|
// what let a stray child overflow the 48px box on the track side.
|
|
199
199
|
it("keeps the caret, the name and every control on one line", () => {
|
|
200
|
-
const { host } = renderRow(
|
|
201
|
-
fxChain: JSON.stringify({
|
|
202
|
-
version: 1,
|
|
203
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
204
|
-
}),
|
|
205
|
-
automation: JSON.stringify({
|
|
206
|
-
version: 1,
|
|
207
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
208
|
-
}),
|
|
209
|
-
});
|
|
200
|
+
const { host } = renderRow(GAIN_AUTOMATED);
|
|
210
201
|
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
|
|
211
202
|
// Caret, name, control group — no second line.
|
|
212
203
|
expect(header?.children).toHaveLength(3);
|
|
@@ -33,6 +33,7 @@ interface TimelineGroupRowProps {
|
|
|
33
33
|
virtualized: boolean;
|
|
34
34
|
contentOrigin: number;
|
|
35
35
|
theme: TimelineTheme;
|
|
36
|
+
showAudioEffects?: boolean;
|
|
36
37
|
rovingTargetId?: string | null;
|
|
37
38
|
collapsedGroupIds: ReadonlySet<string>;
|
|
38
39
|
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
@@ -59,6 +60,7 @@ export function TimelineGroupRow({
|
|
|
59
60
|
virtualized,
|
|
60
61
|
contentOrigin,
|
|
61
62
|
theme,
|
|
63
|
+
showAudioEffects = true,
|
|
62
64
|
rovingTargetId = null,
|
|
63
65
|
collapsedGroupIds,
|
|
64
66
|
expandedLaneOwnerIds,
|
|
@@ -173,6 +175,7 @@ export function TimelineGroupRow({
|
|
|
173
175
|
// stays pinned there through horizontal scroll.
|
|
174
176
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
175
177
|
theme={theme}
|
|
178
|
+
showAudioEffects={showAudioEffects}
|
|
176
179
|
/>
|
|
177
180
|
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
|
178
181
|
clip's: a lane is editable once the group is selected, which clicking
|
|
@@ -187,7 +190,7 @@ export function TimelineGroupRow({
|
|
|
187
190
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
188
191
|
gutterBackground={theme.gutterBackground}
|
|
189
192
|
accentColor={GROUP_LANE_ACCENT}
|
|
190
|
-
onReveal={openGroupFxRack}
|
|
193
|
+
onReveal={showAudioEffects ? openGroupFxRack : undefined}
|
|
191
194
|
/>
|
|
192
195
|
)}
|
|
193
196
|
</div>
|
|
@@ -41,6 +41,7 @@ export function TimelineLanes({
|
|
|
41
41
|
contentGutter,
|
|
42
42
|
trackContentWidth,
|
|
43
43
|
theme,
|
|
44
|
+
showAudioEffects,
|
|
44
45
|
displayTrackOrder,
|
|
45
46
|
rowGeometry,
|
|
46
47
|
virtualRows,
|
|
@@ -115,11 +116,8 @@ export function TimelineLanes({
|
|
|
115
116
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
116
117
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
117
118
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
118
|
-
const
|
|
119
|
-
const groupMemberTracks =
|
|
120
|
-
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
121
|
-
[groups],
|
|
122
|
-
);
|
|
119
|
+
const rowIndexes = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
120
|
+
const { logicalRowsByTrack, groupByAnchor, groupMemberTracks } = rowIndexes;
|
|
123
121
|
const {
|
|
124
122
|
toggleRowExpanded: toggleRowExpandedTracked,
|
|
125
123
|
toggleClipExpanded: toggleClipExpandedTracked,
|
|
@@ -168,6 +166,7 @@ export function TimelineLanes({
|
|
|
168
166
|
virtualized={rowsVirtualized}
|
|
169
167
|
contentOrigin={contentOrigin}
|
|
170
168
|
theme={theme}
|
|
169
|
+
showAudioEffects={showAudioEffects}
|
|
171
170
|
rovingTargetId={keyboard.rovingTargetId}
|
|
172
171
|
collapsedGroupIds={collapsedGroupIds}
|
|
173
172
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
@@ -292,6 +291,7 @@ export function TimelineLanes({
|
|
|
292
291
|
isAudioTrack={isAudioTrack}
|
|
293
292
|
isGroupMember={groupMemberTracks.has(trackNum)}
|
|
294
293
|
theme={theme}
|
|
294
|
+
showAudioEffects={showAudioEffects}
|
|
295
295
|
onToggleClipExpanded={() => {
|
|
296
296
|
const keys = els.map(getTimelineElementIdentity);
|
|
297
297
|
if (keys.length > 0) toggleRowExpandedTracked(keys);
|
|
@@ -75,6 +75,7 @@ interface RenderHeaderOptions {
|
|
|
75
75
|
isAudioTrack?: boolean;
|
|
76
76
|
isGroupMember?: boolean;
|
|
77
77
|
isTrackHidden?: boolean;
|
|
78
|
+
showAudioEffects?: boolean;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -119,6 +120,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
119
120
|
isAudioTrack={next.isAudioTrack}
|
|
120
121
|
isGroupMember={next.isGroupMember}
|
|
121
122
|
theme={defaultTimelineTheme}
|
|
123
|
+
showAudioEffects={next.showAudioEffects}
|
|
122
124
|
onToggleClipExpanded={vi.fn()}
|
|
123
125
|
onToggleTrackHidden={next.onToggleTrackHidden}
|
|
124
126
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
@@ -583,6 +585,31 @@ describe("TimelineTrackHeader", () => {
|
|
|
583
585
|
}),
|
|
584
586
|
} as TimelineElement;
|
|
585
587
|
|
|
588
|
+
it("offers the effect rack from an effect lane only while audio effects are shown", () => {
|
|
589
|
+
const clip = { ...BED, domId: "bed" } as TimelineElement;
|
|
590
|
+
const gainLabel = (showAudioEffects?: boolean) => {
|
|
591
|
+
const { host, root } = renderHeader({
|
|
592
|
+
keyframeClip: clip,
|
|
593
|
+
animations: [],
|
|
594
|
+
showAudioEffects,
|
|
595
|
+
});
|
|
596
|
+
const button = host
|
|
597
|
+
.querySelector('[data-automation-lane-label="Peaking EQ 1.6 kHz · Gain"]')
|
|
598
|
+
?.querySelector("button");
|
|
599
|
+
const state = {
|
|
600
|
+
label: button?.getAttribute("aria-label") ?? null,
|
|
601
|
+
disabled: button?.disabled,
|
|
602
|
+
};
|
|
603
|
+
act(() => root.unmount());
|
|
604
|
+
return state;
|
|
605
|
+
};
|
|
606
|
+
expect(gainLabel()).toEqual({
|
|
607
|
+
label: "Show Peaking EQ 1.6 kHz · Gain in the effect rack",
|
|
608
|
+
disabled: false,
|
|
609
|
+
});
|
|
610
|
+
expect(gainLabel(false)).toEqual({ label: null, disabled: true });
|
|
611
|
+
});
|
|
612
|
+
|
|
586
613
|
it("names every envelope in the label column", () => {
|
|
587
614
|
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
|
|
588
615
|
const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
|
|
@@ -72,6 +72,7 @@ interface TimelineTrackHeaderProps {
|
|
|
72
72
|
isGroupMember?: boolean;
|
|
73
73
|
rovingTargetId?: string | null;
|
|
74
74
|
theme: TimelineTheme;
|
|
75
|
+
showAudioEffects?: boolean;
|
|
75
76
|
onToggleClipExpanded: () => void;
|
|
76
77
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
77
78
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
@@ -98,6 +99,7 @@ export function TimelineTrackHeader({
|
|
|
98
99
|
isAudioTrack,
|
|
99
100
|
isGroupMember = false,
|
|
100
101
|
theme,
|
|
102
|
+
showAudioEffects = true,
|
|
101
103
|
onToggleClipExpanded,
|
|
102
104
|
onToggleTrackHidden,
|
|
103
105
|
onTogglePropertyGroupKeyframe,
|
|
@@ -316,7 +318,7 @@ export function TimelineTrackHeader({
|
|
|
316
318
|
// On the control line rather than a third row of its own.
|
|
317
319
|
trailing={
|
|
318
320
|
<>
|
|
319
|
-
{singleAudioClip && (
|
|
321
|
+
{showAudioEffects && singleAudioClip && (
|
|
320
322
|
<TimelineFxButton
|
|
321
323
|
variant="chain"
|
|
322
324
|
fxChainRaw={singleAudioClip.fxChain}
|
|
@@ -340,7 +342,8 @@ export function TimelineTrackHeader({
|
|
|
340
342
|
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
341
343
|
/>
|
|
342
344
|
)}
|
|
343
|
-
{
|
|
345
|
+
{showAudioEffects &&
|
|
346
|
+
clipCount > 1 &&
|
|
344
347
|
!isTrackGrouped &&
|
|
345
348
|
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
|
|
346
349
|
<TimelineFxButton
|
|
@@ -455,7 +458,7 @@ export function TimelineTrackHeader({
|
|
|
455
458
|
// element, so a shared row's other envelopes belong to clips it is
|
|
456
459
|
// not showing and there would be nothing to reveal.
|
|
457
460
|
onReveal={
|
|
458
|
-
revealTarget && revealElementId && keyframeClip
|
|
461
|
+
showAudioEffects && revealTarget && revealElementId && keyframeClip
|
|
459
462
|
? () => {
|
|
460
463
|
// Select FIRST: the rack is the property panel's view of
|
|
461
464
|
// the selected element, so a reveal aimed at an unselected
|
|
@@ -281,57 +281,51 @@ export function getTimelineInsertBoundaryBand(rowHeight: number): number {
|
|
|
281
281
|
* rendered width (see Timeline.tsx displayContentWidth).
|
|
282
282
|
*/
|
|
283
283
|
export const DRAG_EXTEND_MARGIN_PX = 160;
|
|
284
|
-
/**
|
|
285
|
-
* The rendered timeline always spans at least this many seconds of ruler +
|
|
286
|
-
* track lanes, even when the composition is shorter — the empty space on the
|
|
287
|
-
* right is a real, drag/drop-enabled surface (clips can be moved into it; the
|
|
288
|
-
* composition grows on commit, content-driven). In fit mode the fit pps is
|
|
289
|
-
* derived against this floor, so a 10s comp renders as ~1/6 of the viewport
|
|
290
|
-
* with 60s of ruler after it.
|
|
291
|
-
*/
|
|
284
|
+
/** The span the timeline maps while the composition's duration is unknown or 0. */
|
|
292
285
|
export const MIN_TIMELINE_EXTENT_S = 60;
|
|
293
286
|
/**
|
|
294
287
|
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
|
|
295
288
|
* duration — onto the viewport, so the composition ends at ~83% of the width
|
|
296
289
|
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
|
|
297
290
|
* drag clips past the current end without first zooming out). Applied ONLY
|
|
298
|
-
* inside {@link
|
|
299
|
-
* lanes, playhead, marquee, and drag math
|
|
291
|
+
* inside {@link getTimelineFitSpan}, which fit pps and the drawn width share, so
|
|
292
|
+
* the ruler, lanes, playhead, marquee, and drag math inherit it. Manual
|
|
300
293
|
* zoom percentages stay defined relative to this fit basis (100% == fit).
|
|
301
294
|
*/
|
|
302
295
|
export const FIT_ZOOM_HEADROOM = 1.2;
|
|
303
296
|
|
|
304
297
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
305
298
|
/* ── Width / duration derivation ──────────────────────────────────── */
|
|
299
|
+
/** Seconds the timeline maps: the composition plus FIT_ZOOM_HEADROOM, or the floor while its duration is unknown. */
|
|
300
|
+
function getTimelineFitSpan(effectiveDuration: number): number {
|
|
301
|
+
return Number.isFinite(effectiveDuration) && effectiveDuration > 0
|
|
302
|
+
? effectiveDuration * FIT_ZOOM_HEADROOM
|
|
303
|
+
: MIN_TIMELINE_EXTENT_S;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
306
|
/**
|
|
307
307
|
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
|
308
308
|
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
|
|
309
|
-
* into the right edge)
|
|
310
|
-
* seconds onto it — a short comp takes a fraction of the width and the
|
|
311
|
-
* remaining ruler runs to 1:00.
|
|
312
|
-
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
309
|
+
* into the right edge). Manual zoom multiplies this base.
|
|
313
310
|
*/
|
|
314
311
|
export function getTimelineFitPps(
|
|
315
312
|
viewportWidth: number,
|
|
316
313
|
effectiveDuration: number,
|
|
317
314
|
contentOrigin: number,
|
|
318
315
|
): number {
|
|
319
|
-
const safeDuration =
|
|
320
|
-
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
321
|
-
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
322
316
|
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
|
|
323
|
-
return (viewportWidth - contentOrigin - 2) /
|
|
317
|
+
return (viewportWidth - contentOrigin - 2) / getTimelineFitSpan(effectiveDuration);
|
|
324
318
|
}
|
|
325
319
|
|
|
326
320
|
/**
|
|
327
321
|
* The rendered timeline extent in px. Always covers, whichever is largest:
|
|
328
|
-
* the
|
|
322
|
+
* the visible viewport (no dead black after short
|
|
329
323
|
* content — CapCut-style), a live drag or resize ghost plus the auto-scroll
|
|
330
|
-
* margin (drag/trim-to-extend), and the
|
|
324
|
+
* margin (drag/trim-to-extend), and the fit span at this zoom. Only the
|
|
331
325
|
* RENDERED extent grows; clip positions/durations are untouched.
|
|
332
326
|
*/
|
|
333
327
|
export function getTimelineDisplayContentWidth(input: {
|
|
334
|
-
|
|
328
|
+
effectiveDuration: number;
|
|
335
329
|
viewportWidth: number;
|
|
336
330
|
contentOrigin: number;
|
|
337
331
|
pps: number;
|
|
@@ -340,11 +334,10 @@ export function getTimelineDisplayContentWidth(input: {
|
|
|
340
334
|
}): number {
|
|
341
335
|
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
|
342
336
|
return Math.max(
|
|
343
|
-
input.trackContentWidth,
|
|
344
337
|
input.viewportWidth - input.contentOrigin - 2,
|
|
345
338
|
input.dragGhostEndPx ?? 0,
|
|
346
339
|
input.resizeGhostEndPx ?? 0,
|
|
347
|
-
|
|
340
|
+
getTimelineFitSpan(input.effectiveDuration) * safePps,
|
|
348
341
|
);
|
|
349
342
|
}
|
|
350
343
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterAll, beforeAll } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mounting a whole <Timeline> in happy-dom needs a width to lay out against and a size report.
|
|
4
|
+
class MockResizeObserver {
|
|
5
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
6
|
+
observe(target: Element) {
|
|
7
|
+
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
|
8
|
+
}
|
|
9
|
+
unobserve() {}
|
|
10
|
+
disconnect() {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function installTimelineMountEnv(): void {
|
|
14
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
15
|
+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
18
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get: () => 900,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
25
|
+
if (originalClientWidth)
|
|
26
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
});
|
|
29
|
+
}
|