@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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maths behind an automation lane: which parameters it can offer, how a
|
|
3
|
+
* value maps to a position in the lane, and how a lane is edited.
|
|
4
|
+
*
|
|
5
|
+
* Pure — no React, no DOM. Split from the lane component so the geometry can be
|
|
6
|
+
* tested on its own, and so the component is left with the parts that genuinely
|
|
7
|
+
* need a pointer and a render.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
fxAutomationTarget,
|
|
12
|
+
resolveAutomationRange,
|
|
13
|
+
sampleAutomationLane,
|
|
14
|
+
steadyViaPoint,
|
|
15
|
+
VOLUME_RANGE,
|
|
16
|
+
VOLUME_TARGET,
|
|
17
|
+
type AutomationRange,
|
|
18
|
+
type HfAutomation,
|
|
19
|
+
type HfAutomationLane,
|
|
20
|
+
} from "@hyperframes/core/audio-automation";
|
|
21
|
+
import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
22
|
+
|
|
23
|
+
/** Points nearer than this in clip seconds are the same point, not two. */
|
|
24
|
+
export const POINT_MERGE_SEC = 0.02;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Closest two breakpoints may sit in clip seconds while still being two points.
|
|
28
|
+
*
|
|
29
|
+
* A drag clamps to this short of its neighbour rather than onto it. Landing on the
|
|
30
|
+
* exact same time is not a step, it is a deletion: the lane's own normalisation
|
|
31
|
+
* collapses points that share a `t`, keeping the later one — so dragging a point
|
|
32
|
+
* fully into its neighbour used to consume that neighbour. A millisecond is under a
|
|
33
|
+
* pixel at any zoom the lane offers, so the two still read as touching.
|
|
34
|
+
*/
|
|
35
|
+
export const MIN_POINT_GAP_SEC = 0.001;
|
|
36
|
+
/** Hit radius for grabbing a point, in px. */
|
|
37
|
+
export const GRAB_PX = 7;
|
|
38
|
+
/** Samples used to draw a segment the eye should see as curved. */
|
|
39
|
+
export const DRAW_SAMPLES = 64;
|
|
40
|
+
/**
|
|
41
|
+
* Slack on each side of the envelope, so a point sitting exactly at the clip's
|
|
42
|
+
* start or end is drawn whole instead of half outside the lane. Wide enough for
|
|
43
|
+
* the grab circle plus its stroke.
|
|
44
|
+
*/
|
|
45
|
+
export const PAD_X = GRAB_PX + 2;
|
|
46
|
+
|
|
47
|
+
export interface AutomationTargetOption {
|
|
48
|
+
target: string;
|
|
49
|
+
label: string;
|
|
50
|
+
range: AutomationRange;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Everything this clip could automate: its fader, then each automatable knob of
|
|
55
|
+
* each effect in its chain. Effects with no chain node id are skipped — a lane
|
|
56
|
+
* has nothing stable to address them by (the panel mints ids as it adds nodes).
|
|
57
|
+
*/
|
|
58
|
+
export function automationTargets(chain: HfAudioFxChain | null): AutomationTargetOption[] {
|
|
59
|
+
const out: AutomationTargetOption[] = [
|
|
60
|
+
{ target: VOLUME_TARGET, label: "Volume", range: VOLUME_RANGE },
|
|
61
|
+
];
|
|
62
|
+
for (const node of chain?.nodes ?? []) {
|
|
63
|
+
out.push(...nodeTargets(node, chain));
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One effect's automatable knobs. Empty for a node no lane could address. */
|
|
69
|
+
function nodeTargets(
|
|
70
|
+
node: HfAudioFxChain["nodes"][number],
|
|
71
|
+
chain: HfAudioFxChain | null,
|
|
72
|
+
): AutomationTargetOption[] {
|
|
73
|
+
const nodeId = node.id;
|
|
74
|
+
const def = nodeId ? getAudioFxDef(node.type) : undefined;
|
|
75
|
+
if (!nodeId || !def) return [];
|
|
76
|
+
const out: AutomationTargetOption[] = [];
|
|
77
|
+
for (const param of def.params) {
|
|
78
|
+
if (param.kind !== "number" || !param.automatable) continue;
|
|
79
|
+
const target = fxAutomationTarget(nodeId, param.key);
|
|
80
|
+
const range = resolveAutomationRange(target, chain ?? undefined);
|
|
81
|
+
if (range) out.push({ target, label: range.label, range });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Value → 0..1 up the lane, honouring a log-read knob's own scale. */
|
|
87
|
+
export function toUnit(range: AutomationRange, value: number): number {
|
|
88
|
+
const { min, max } = range;
|
|
89
|
+
if (max <= min) return 0;
|
|
90
|
+
if (range.scale === "log" && min > 0 && value > 0) {
|
|
91
|
+
return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min));
|
|
92
|
+
}
|
|
93
|
+
return (value - min) / (max - min);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fromUnit(range: AutomationRange, unit: number): number {
|
|
97
|
+
const t = Math.min(1, Math.max(0, unit));
|
|
98
|
+
const { min, max } = range;
|
|
99
|
+
if (range.scale === "log" && min > 0) {
|
|
100
|
+
return Math.exp(Math.log(min) + t * (Math.log(max) - Math.log(min)));
|
|
101
|
+
}
|
|
102
|
+
return min + t * (max - min);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatValue(range: AutomationRange, value: number): string {
|
|
106
|
+
const decimals = range.step >= 1 ? 0 : range.step >= 0.1 ? 1 : 2;
|
|
107
|
+
const shown =
|
|
108
|
+
range.unit === "" && range.max === 1 ? `${Math.round(value * 100)}%` : value.toFixed(decimals);
|
|
109
|
+
return range.unit ? `${shown} ${range.unit}` : shown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The via point that bends a segment through a dragged pointer: the pointer's own
|
|
114
|
+
* position in the segment's normalised space, which is all the model needs.
|
|
115
|
+
*
|
|
116
|
+
* There is nothing to solve any more, and that is the point. This used to fit an
|
|
117
|
+
* exponent — `x^e = f`, so `curve = log2(ln f / ln x) / 2` — and an exponent is
|
|
118
|
+
* one knob for two questions. It spent it on the wrong one: every upward bend it
|
|
119
|
+
* could draw deviated most inside the first fifth of the segment, so grabbing the
|
|
120
|
+
* line near its right-hand breakpoint still bulged it on the left. And reaching a
|
|
121
|
+
* pointer near either end needed an exponent the model refuses — `e` runs past 15
|
|
122
|
+
* at `x = 0.9`, clamped to 4 — so the line stopped following the pointer
|
|
123
|
+
* altogether, missing it by up to a third of the segment's height. Naming the
|
|
124
|
+
* point the curve passes through says both things at once, exactly, anywhere.
|
|
125
|
+
*
|
|
126
|
+
* Null when the segment cannot take a bend: a flat segment draws the same line
|
|
127
|
+
* whatever the shape, and a pointer at the very ends is the ends.
|
|
128
|
+
*/
|
|
129
|
+
export function curveForDrag(input: {
|
|
130
|
+
range: AutomationRange;
|
|
131
|
+
a: { t: number; v: number };
|
|
132
|
+
b: { t: number; v: number };
|
|
133
|
+
t: number;
|
|
134
|
+
v: number;
|
|
135
|
+
}): { viaX: number; viaY: number } | null {
|
|
136
|
+
const { range, a, b, t, v } = input;
|
|
137
|
+
const span = b.t - a.t;
|
|
138
|
+
if (span <= 0) return null;
|
|
139
|
+
const x = (t - a.t) / span;
|
|
140
|
+
if (x <= 0.001 || x >= 0.999) return null;
|
|
141
|
+
const ua = toUnit(range, a.v);
|
|
142
|
+
const ub = toUnit(range, b.v);
|
|
143
|
+
if (Math.abs(ub - ua) < 0.001) return null;
|
|
144
|
+
const f = (toUnit(range, v) - ua) / (ub - ua);
|
|
145
|
+
if (f <= 0.001 || f >= 0.999) return null;
|
|
146
|
+
// Reported as the model will honour it, not as the pointer asked. A bend is held
|
|
147
|
+
// to a steady curve, so a pointer dragged past that stops being followed — and
|
|
148
|
+
// the write, the preview and the readout all have to say the same thing about
|
|
149
|
+
// where the line actually went.
|
|
150
|
+
return steadyViaPoint(x, f);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A point drag with Shift held: one axis at a time, whichever the gesture
|
|
155
|
+
* committed to, and a quarter of the vertical travel for a value that has to
|
|
156
|
+
* land on a number.
|
|
157
|
+
*
|
|
158
|
+
* Which axis "won" is decided in pixels, not in seconds and dB — those are
|
|
159
|
+
* different units and comparing them would make the lock depend on the zoom.
|
|
160
|
+
*/
|
|
161
|
+
/** Which way a gesture is going, in pixels — the only comparable unit. */
|
|
162
|
+
export function dominantDragAxis(input: {
|
|
163
|
+
origin: { t: number; v: number };
|
|
164
|
+
raw: { t: number; v: number };
|
|
165
|
+
xOf(t: number): number;
|
|
166
|
+
yOf(v: number): number;
|
|
167
|
+
}): "time" | "value" {
|
|
168
|
+
const { origin, raw, xOf, yOf } = input;
|
|
169
|
+
return Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))
|
|
170
|
+
? "time"
|
|
171
|
+
: "value";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The pointer, constrained to one axis.
|
|
176
|
+
*
|
|
177
|
+
* The axis is handed in rather than worked out here, because it has to be decided
|
|
178
|
+
* once for the gesture and held. Recomputed per event it followed whichever way
|
|
179
|
+
* the last move happened to lean, so a hand drifting sideways during a vertical
|
|
180
|
+
* drag flipped the lock and the point moved in both — which is indistinguishable
|
|
181
|
+
* from no lock at all.
|
|
182
|
+
*
|
|
183
|
+
* Locking to time holds the value exactly. Locking to value holds the time and
|
|
184
|
+
* moves the value at a quarter speed: the same gesture is the fine adjustment,
|
|
185
|
+
* because a fader spanning 60px of lane has no other way to be set precisely.
|
|
186
|
+
*/
|
|
187
|
+
export function applyShiftConstraint(input: {
|
|
188
|
+
range: AutomationRange;
|
|
189
|
+
origin: { t: number; v: number };
|
|
190
|
+
raw: { t: number; v: number };
|
|
191
|
+
/** Same projections the lane draws with, so the comparison is on screen. */
|
|
192
|
+
xOf(t: number): number;
|
|
193
|
+
yOf(v: number): number;
|
|
194
|
+
/** Decided on the gesture's first travel; worked out here when absent. */
|
|
195
|
+
axis?: "time" | "value";
|
|
196
|
+
}): { t: number; v: number } {
|
|
197
|
+
const { range, origin, raw } = input;
|
|
198
|
+
const axis = input.axis ?? dominantDragAxis(input);
|
|
199
|
+
if (axis === "time") return { t: raw.t, v: origin.v };
|
|
200
|
+
const from = toUnit(range, origin.v);
|
|
201
|
+
return { t: origin.t, v: fromUnit(range, from + (toUnit(range, raw.v) - from) * 0.25) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Nearest snap target within the threshold, else the time unchanged.
|
|
206
|
+
*
|
|
207
|
+
* A breakpoint is placed by eye, and by eye "on the beat" and "three
|
|
208
|
+
* milliseconds off the beat" look identical — so the lane snaps to the beat grid
|
|
209
|
+
* and to its own neighbouring points, the two things an envelope is usually
|
|
210
|
+
* aligned against.
|
|
211
|
+
*/
|
|
212
|
+
export function snapLaneTime(t: number, targets: readonly number[], thresholdSec: number): number {
|
|
213
|
+
let best = t;
|
|
214
|
+
let bestDist = thresholdSec;
|
|
215
|
+
for (const target of targets) {
|
|
216
|
+
const d = Math.abs(target - t);
|
|
217
|
+
if (d < bestDist) {
|
|
218
|
+
bestDist = d;
|
|
219
|
+
best = target;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return best;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The svg path for one lane's envelope.
|
|
227
|
+
*
|
|
228
|
+
* A flat line at the parameter's own default stands in for a lane with no
|
|
229
|
+
* points, so the first double-click has something to land on. Straight segments
|
|
230
|
+
* are drawn as one line each; a curved or log-read segment is sampled, because
|
|
231
|
+
* drawing it straight would lie about the envelope the audio thread is going to
|
|
232
|
+
* play.
|
|
233
|
+
*/
|
|
234
|
+
export function envelopePath(input: {
|
|
235
|
+
lane: HfAutomationLane;
|
|
236
|
+
range: AutomationRange;
|
|
237
|
+
widthPx: number;
|
|
238
|
+
xOf(t: number): number;
|
|
239
|
+
yOf(v: number): number;
|
|
240
|
+
}): string {
|
|
241
|
+
const { lane, range, widthPx, xOf, yOf } = input;
|
|
242
|
+
const first = lane.points[0];
|
|
243
|
+
const last = lane.points[lane.points.length - 1];
|
|
244
|
+
if (!first || !last) {
|
|
245
|
+
const y = yOf(range.default ?? (range.min + range.max) / 2);
|
|
246
|
+
return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`;
|
|
247
|
+
}
|
|
248
|
+
const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
|
|
249
|
+
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
|
250
|
+
const a = lane.points[i];
|
|
251
|
+
const b = lane.points[i + 1];
|
|
252
|
+
if (!a || !b) continue;
|
|
253
|
+
// A via point bends the segment with no `curve` of its own, so the
|
|
254
|
+
// straight-line shortcut has to rule out both.
|
|
255
|
+
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
|
|
256
|
+
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
|
|
260
|
+
const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
|
|
261
|
+
pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
|
|
265
|
+
return pts.join(" ");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
|
|
269
|
+
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Replace one lane in place, dropping it when it has no points left.
|
|
274
|
+
*
|
|
275
|
+
* Order is preserved deliberately. A lane with no explicitly chosen parameter
|
|
276
|
+
* shows whichever comes first, so moving the edited one to the end would switch
|
|
277
|
+
* the lane out from under the pointer on the first edit.
|
|
278
|
+
*/
|
|
279
|
+
export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation {
|
|
280
|
+
const empty = lane.points.length === 0;
|
|
281
|
+
const exists = automation.lanes.some((l) => l.target === lane.target);
|
|
282
|
+
const lanes = automation.lanes
|
|
283
|
+
.map((l) => (l.target === lane.target ? lane : l))
|
|
284
|
+
.filter((l) => l.points.length > 0);
|
|
285
|
+
if (!exists && !empty) lanes.push(lane);
|
|
286
|
+
return { version: 1, lanes };
|
|
287
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Height of one audio automation lane.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because both the row layout and the lane itself need it, and
|
|
5
|
+
* putting it in either would have the layout importing a component or the
|
|
6
|
+
* component's constant living somewhere it is not used.
|
|
7
|
+
*
|
|
8
|
+
* Taller than a keyframe lane because it carries a value axis rather than a row
|
|
9
|
+
* of diamonds: a fader envelope drawn 28px high cannot be aimed. 48 was still
|
|
10
|
+
* too tight in use — the drawing area is the height minus 6px of padding either
|
|
11
|
+
* side, so 48 left 36px for the whole 0..1 fader and a breakpoint's 11px grab
|
|
12
|
+
* disc covered a third of the axis. 72 leaves 60px, which is what makes a value
|
|
13
|
+
* aimable and two points at similar values separately grabbable.
|
|
14
|
+
*/
|
|
15
|
+
export const AUTOMATION_LANE_H = 72;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer capture for a lane gesture. Its own module only because both gesture
|
|
3
|
+
* hooks need it and `automationLaneGeometry` is deliberately DOM-free.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keep the rest of a gesture even if the pointer leaves the lane. Without it a
|
|
8
|
+
* drag that strays outside the svg stops sending moves and the point sticks.
|
|
9
|
+
*
|
|
10
|
+
* Captured on the svg the handler is bound to, NOT on `e.target`: the target is
|
|
11
|
+
* whatever child the press happened to land on — a breakpoint circle, a
|
|
12
|
+
* selection line — and a child that unmounts mid-drag takes the capture with it,
|
|
13
|
+
* silently and with no `pointercancel` to notice it by. The svg outlives every
|
|
14
|
+
* gesture on it.
|
|
15
|
+
*
|
|
16
|
+
* Structurally typed so this stays testable without React's event types.
|
|
17
|
+
*/
|
|
18
|
+
export function capturePointer(e: { currentTarget: Element; pointerId: number }): void {
|
|
19
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
20
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pointsIn, replaceRange, retimeRange } from "./automationLaneSelection";
|
|
3
|
+
import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
5
|
+
|
|
6
|
+
const ramp: HfAutomationLane = {
|
|
7
|
+
target: "volume",
|
|
8
|
+
points: [
|
|
9
|
+
{ t: 0, v: 1 },
|
|
10
|
+
{ t: 2, v: 0.6 },
|
|
11
|
+
{ t: 3, v: 0.4 },
|
|
12
|
+
{ t: 6, v: 0 },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("pointsIn", () => {
|
|
17
|
+
it("returns only the points inside the range, endpoints inclusive", () => {
|
|
18
|
+
expect(pointsIn(ramp, 2, 3).map((p) => p.t)).toEqual([2, 3]);
|
|
19
|
+
expect(pointsIn(ramp, 2.1, 2.9)).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("replaceRange", () => {
|
|
24
|
+
it("never moves the envelope outside the selection", () => {
|
|
25
|
+
// THE invariant. Deleting the middle of a ramp must not reshape the rest.
|
|
26
|
+
const next: HfAutomationLane = {
|
|
27
|
+
target: "volume",
|
|
28
|
+
points: replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }),
|
|
29
|
+
};
|
|
30
|
+
for (const t of [0, 0.5, 1.0, 1.5, 3.5, 4, 5, 6]) {
|
|
31
|
+
expect(sampleAutomationLane(next, t, "linear")).toBeCloseTo(
|
|
32
|
+
sampleAutomationLane(ramp, t, "linear"),
|
|
33
|
+
5,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("pins anchors at both edges when the interior empties", () => {
|
|
39
|
+
const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] });
|
|
40
|
+
const times = pts.map((p) => p.t);
|
|
41
|
+
expect(times).toContain(1.5);
|
|
42
|
+
expect(times).toContain(3.5);
|
|
43
|
+
expect(times).not.toContain(2);
|
|
44
|
+
expect(times).not.toContain(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lets inner points at the edges stand in for the anchors", () => {
|
|
48
|
+
// A ramp generator emits its own boundary points; pinning a second anchor
|
|
49
|
+
// at the same time would fight it.
|
|
50
|
+
const pts = replaceRange({
|
|
51
|
+
lane: ramp,
|
|
52
|
+
range: VOLUME_RANGE,
|
|
53
|
+
t0: 2,
|
|
54
|
+
t1: 3,
|
|
55
|
+
inner: [
|
|
56
|
+
{ t: 2, v: 0 },
|
|
57
|
+
{ t: 3, v: 1 },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
expect(pts.filter((p) => p.t === 2)).toHaveLength(1);
|
|
61
|
+
expect(pts.find((p) => p.t === 2)?.v).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sorts and respects the point cap", () => {
|
|
65
|
+
const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
|
|
66
|
+
const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
|
|
67
|
+
expect(pts.length).toBeLessThanOrEqual(512);
|
|
68
|
+
expect([...pts].sort((a, b) => a.t - b.t)).toEqual(pts);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps a constant flat when the lane has no points", () => {
|
|
72
|
+
const empty: HfAutomationLane = { target: "volume", points: [] };
|
|
73
|
+
const pts = replaceRange({ lane: empty, range: VOLUME_RANGE, t0: 1, t1: 2, inner: [] });
|
|
74
|
+
// Nothing to preserve, nothing to pin: an empty lane stays empty.
|
|
75
|
+
expect(pts).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps the far anchor and every outside point when inner would overflow the cap", () => {
|
|
79
|
+
const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
|
|
80
|
+
const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
|
|
81
|
+
const times = pts.map((p) => p.t);
|
|
82
|
+
expect(times).toContain(1.5); // near anchor
|
|
83
|
+
expect(times).toContain(3.5); // far anchor — this is what the bug dropped
|
|
84
|
+
expect(times).toContain(0); // outside point before the range
|
|
85
|
+
expect(times).toContain(6); // outside point after the range
|
|
86
|
+
expect(pts.length).toBeLessThanOrEqual(512);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("thins the interior evenly rather than dropping its tail", () => {
|
|
90
|
+
const dense = Array.from({ length: 2001 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
|
|
91
|
+
const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
|
|
92
|
+
const innerTimes = pts.map((p) => p.t).filter((t) => t > 1.5 && t < 3.5);
|
|
93
|
+
// Evenly spread across the range, not clustered at the start.
|
|
94
|
+
expect(Math.max(...innerTimes)).toBeGreaterThan(3.0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("retimeRange", () => {
|
|
99
|
+
it("scales interior points proportionally into the new span", () => {
|
|
100
|
+
const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
|
|
101
|
+
const moved = pts.find((p) => p.v === 0.4); // the t=3 point
|
|
102
|
+
expect(moved?.t).toBe(5);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("preserves the envelope outside the union of old and new spans", () => {
|
|
106
|
+
const before: HfAutomationLane = { target: "volume", points: ramp.points };
|
|
107
|
+
const after: HfAutomationLane = {
|
|
108
|
+
target: "volume",
|
|
109
|
+
points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }),
|
|
110
|
+
};
|
|
111
|
+
// Nothing to the left of t0=2 moved (newT0 === t0 here), so sampled
|
|
112
|
+
// continuity holds all the way up to the edited region.
|
|
113
|
+
for (const t of [0, 1, 1.9]) {
|
|
114
|
+
expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
|
|
115
|
+
sampleAutomationLane(before, t, "linear"),
|
|
116
|
+
5,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// The next real breakpoint past the edited region keeps its own exact value.
|
|
120
|
+
const farPoint = after.points.find((p) => p.t === 6);
|
|
121
|
+
expect(farPoint).toEqual({ t: 6, v: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("preserves the envelope on BOTH sides when no breakpoint sits on the moved edge", () => {
|
|
125
|
+
// The right side is where the invariant is worth asserting — `newT0 === t0`
|
|
126
|
+
// makes the left side of the test above trivially true, and an earlier
|
|
127
|
+
// right-side probe at t=5.1 was DELETED as inherent when it was reporting
|
|
128
|
+
// the real behaviour below. With the selection's edges off any breakpoint,
|
|
129
|
+
// the guarantee holds exactly, in both directions.
|
|
130
|
+
const before: HfAutomationLane = { target: "volume", points: ramp.points };
|
|
131
|
+
const after: HfAutomationLane = {
|
|
132
|
+
target: "volume",
|
|
133
|
+
points: retimeRange({
|
|
134
|
+
lane: ramp,
|
|
135
|
+
range: VOLUME_RANGE,
|
|
136
|
+
t0: 2.2,
|
|
137
|
+
t1: 2.9,
|
|
138
|
+
newT0: 2.2,
|
|
139
|
+
newT1: 4,
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
for (const t of [0, 1, 2, 4.5, 5, 5.5, 6]) {
|
|
143
|
+
expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
|
|
144
|
+
sampleAutomationLane(before, t, "linear"),
|
|
145
|
+
5,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("moves a breakpoint sitting exactly on the dragged edge, reshaping the segment past it", () => {
|
|
151
|
+
// The design decision this pins, because it is not free either way.
|
|
152
|
+
// `pointsIn` is endpoint-inclusive, so a breakpoint ON the edge is interior
|
|
153
|
+
// and travels with the stretch. It has to: every range operation leaves a
|
|
154
|
+
// breakpoint exactly on the edge it created, so treating that point as an
|
|
155
|
+
// anchor instead would make the commonest stretch — grabbing the edge to
|
|
156
|
+
// drag that very point outward — delete it and flatten the span.
|
|
157
|
+
//
|
|
158
|
+
// The cost is that the retimed point lands ON the union's boundary, where a
|
|
159
|
+
// preservation anchor would also go, and `anchor()` stands down within a
|
|
160
|
+
// merge radius. Two different values cannot occupy one time; the segment
|
|
161
|
+
// leaving the union reshapes, which is the "envelope outside the selection
|
|
162
|
+
// never moves" invariant bending exactly here and nowhere else.
|
|
163
|
+
const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
|
|
164
|
+
expect(pts).toEqual([
|
|
165
|
+
{ t: 0, v: 1 },
|
|
166
|
+
{ t: 2, v: 0.6 },
|
|
167
|
+
{ t: 5, v: 0.4 }, // the t=3 point, retimed onto the new edge
|
|
168
|
+
{ t: 6, v: 0 },
|
|
169
|
+
]);
|
|
170
|
+
// The old 3→6 segment sloped -0.133/s; the new 5→6 slopes -0.4/s, so the
|
|
171
|
+
// envelope past the union genuinely moves. Asserted, not tolerated: if this
|
|
172
|
+
// number changes, the decision above changed with it.
|
|
173
|
+
const after: HfAutomationLane = { target: "volume", points: pts };
|
|
174
|
+
expect(sampleAutomationLane(after, 5.5, "linear")).toBeCloseTo(0.2, 5);
|
|
175
|
+
expect(sampleAutomationLane(ramp, 5.5, "linear")).toBeCloseTo(0.0667, 4);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rejects a degenerate span", () => {
|
|
179
|
+
expect(
|
|
180
|
+
retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 4, newT1: 4 }),
|
|
181
|
+
).toEqual(ramp.points);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Range operations over one automation lane.
|
|
3
|
+
*
|
|
4
|
+
* `replaceRange` is the only mutator every range feature (delete, shapes,
|
|
5
|
+
* paste) composes, and it carries the invariant that makes them safe:
|
|
6
|
+
* the envelope OUTSIDE the selection never moves. It samples the lane at both
|
|
7
|
+
* edges first and pins anchor points there, so cutting the middle out of a
|
|
8
|
+
* ramp cannot reshape the rest of the clip.
|
|
9
|
+
*
|
|
10
|
+
* Exact for linear segments. A curved segment straddling an edge keeps its
|
|
11
|
+
* edge VALUE but reshapes slightly between its own start and the anchor — the
|
|
12
|
+
* curve exponent now runs over a shorter span. Accepted: the alternative is
|
|
13
|
+
* splitting curves analytically for a difference the ear cannot place.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
MAX_AUTOMATION_POINTS,
|
|
18
|
+
sampleAutomationLane,
|
|
19
|
+
type AutomationRange,
|
|
20
|
+
type HfAutomationLane,
|
|
21
|
+
type HfAutomationPoint,
|
|
22
|
+
} from "@hyperframes/core/audio-automation";
|
|
23
|
+
import { POINT_MERGE_SEC } from "./automationLaneGeometry";
|
|
24
|
+
|
|
25
|
+
/** Points inside [t0, t1], endpoints inclusive. */
|
|
26
|
+
export function pointsIn(lane: HfAutomationLane, t0: number, t1: number): HfAutomationPoint[] {
|
|
27
|
+
return lane.points.filter((p) => p.t >= t0 && p.t <= t1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An anchor, unless `inner` already provides the edge within the merge radius. */
|
|
31
|
+
function anchor(
|
|
32
|
+
lane: HfAutomationLane,
|
|
33
|
+
range: AutomationRange,
|
|
34
|
+
t: number,
|
|
35
|
+
inner: readonly HfAutomationPoint[],
|
|
36
|
+
): HfAutomationPoint[] {
|
|
37
|
+
if (inner.some((p) => Math.abs(p.t - t) <= POINT_MERGE_SEC)) return [];
|
|
38
|
+
return [{ t, v: sampleAutomationLane(lane, t, range.scale) }];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Evenly subsample items to a budget, preserving first and last. */
|
|
42
|
+
function decimateEvenly<T>(items: readonly T[], budget: number): T[] {
|
|
43
|
+
if (budget <= 0) return [];
|
|
44
|
+
if (items.length <= budget) return [...items];
|
|
45
|
+
if (budget === 1) {
|
|
46
|
+
const item = items[0];
|
|
47
|
+
return item ? [item] : [];
|
|
48
|
+
}
|
|
49
|
+
const out: T[] = [];
|
|
50
|
+
const step = (items.length - 1) / (budget - 1);
|
|
51
|
+
for (let i = 0; i < budget; i += 1) {
|
|
52
|
+
const item = items[Math.round(i * step)];
|
|
53
|
+
if (item) out.push(item);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function replaceRange(input: {
|
|
59
|
+
lane: HfAutomationLane;
|
|
60
|
+
range: AutomationRange;
|
|
61
|
+
t0: number;
|
|
62
|
+
t1: number;
|
|
63
|
+
inner: HfAutomationPoint[];
|
|
64
|
+
}): HfAutomationPoint[] {
|
|
65
|
+
const { lane, range, t0, t1, inner } = input;
|
|
66
|
+
// An empty lane draws a flat default; there is nothing to preserve, and
|
|
67
|
+
// pinning anchors would turn "no automation" into a constant lane.
|
|
68
|
+
if (lane.points.length === 0 && inner.length === 0) return [];
|
|
69
|
+
const outside = lane.points.filter((p) => p.t < t0 || p.t > t1);
|
|
70
|
+
const edges =
|
|
71
|
+
lane.points.length === 0
|
|
72
|
+
? []
|
|
73
|
+
: [...anchor(lane, range, t0, inner), ...anchor(lane, range, t1, inner)];
|
|
74
|
+
const budget = Math.max(0, MAX_AUTOMATION_POINTS - outside.length - edges.length);
|
|
75
|
+
const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget);
|
|
76
|
+
return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a breakpoint falls inside the selection box, edges included.
|
|
81
|
+
*
|
|
82
|
+
* The one rule three places need: what Delete removes, what the lane rings, and
|
|
83
|
+
* what a group drag moves. They have to agree — a point drawn as caught but left
|
|
84
|
+
* behind by the drag is worse than either answer.
|
|
85
|
+
*
|
|
86
|
+
* Both axes, which is what makes a selection a box: a point at the right time but
|
|
87
|
+
* the wrong value is not in it. Values compare in the parameter's own units, and
|
|
88
|
+
* that is correct on a logarithmic axis too — the mapping to screen is monotonic,
|
|
89
|
+
* so a box drawn around some pixels holds exactly the values it looks like it does.
|
|
90
|
+
*/
|
|
91
|
+
export function pointInSelection(
|
|
92
|
+
point: { t: number; v: number },
|
|
93
|
+
box: { t0: number; t1: number; v0: number; v1: number },
|
|
94
|
+
): boolean {
|
|
95
|
+
return point.t >= box.t0 && point.t <= box.t1 && point.v >= box.v0 && point.v <= box.v1;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Retime a selection: interior points scale proportionally into the new span,
|
|
99
|
+
* then replaceRange runs over the UNION of old and new spans — growing eats
|
|
100
|
+
* whatever it covers, shrinking pins anchors where the envelope re-enters.
|
|
101
|
+
*
|
|
102
|
+
* Interior is `pointsIn`, so a breakpoint sitting exactly ON an edge travels
|
|
103
|
+
* with the stretch. Deliberate: every range operation leaves a breakpoint on the
|
|
104
|
+
* edge it created, so treating that point as a fixed anchor would make the
|
|
105
|
+
* commonest stretch of all — grabbing the edge to drag that point outward —
|
|
106
|
+
* delete it instead. The price is that such a point lands on the union's own
|
|
107
|
+
* boundary, where `anchor` then stands down (one time cannot hold two values),
|
|
108
|
+
* so the segment leaving the union reshapes. That is the ONE place
|
|
109
|
+
* `replaceRange`'s outside-never-moves invariant bends, and it is pinned by name
|
|
110
|
+
* in automationLaneSelection.test.ts.
|
|
111
|
+
*/
|
|
112
|
+
export function retimeRange(input: {
|
|
113
|
+
lane: HfAutomationLane;
|
|
114
|
+
range: AutomationRange;
|
|
115
|
+
t0: number;
|
|
116
|
+
t1: number;
|
|
117
|
+
newT0: number;
|
|
118
|
+
newT1: number;
|
|
119
|
+
}): HfAutomationPoint[] {
|
|
120
|
+
const { lane, range, t0, t1, newT0, newT1 } = input;
|
|
121
|
+
const oldSpan = t1 - t0;
|
|
122
|
+
const newSpan = newT1 - newT0;
|
|
123
|
+
if (oldSpan <= 0 || newSpan <= 0) return lane.points;
|
|
124
|
+
const inner = pointsIn(lane, t0, t1).map((p) => ({
|
|
125
|
+
...p,
|
|
126
|
+
t: newT0 + ((p.t - t0) * newSpan) / oldSpan,
|
|
127
|
+
}));
|
|
128
|
+
return replaceRange({
|
|
129
|
+
lane,
|
|
130
|
+
range,
|
|
131
|
+
t0: Math.min(t0, newT0),
|
|
132
|
+
t1: Math.max(t1, newT1),
|
|
133
|
+
inner,
|
|
134
|
+
});
|
|
135
|
+
}
|