@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,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateShape } from "./automationShapes";
|
|
3
|
+
import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
5
|
+
|
|
6
|
+
const flat: HfAutomationLane = {
|
|
7
|
+
target: "volume",
|
|
8
|
+
points: [
|
|
9
|
+
{ t: 0, v: 0.8 },
|
|
10
|
+
{ t: 6, v: 0.8 },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("generateShape", () => {
|
|
15
|
+
it("ramp-up fades in from the floor to the envelope's own value", () => {
|
|
16
|
+
const pts = generateShape({ shape: "ramp-up", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
|
|
17
|
+
expect(pts).toEqual([
|
|
18
|
+
{ t: 1, v: VOLUME_RANGE.min },
|
|
19
|
+
{ t: 3, v: 0.8 },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ramp-down fades out from the envelope's own value", () => {
|
|
24
|
+
const pts = generateShape({
|
|
25
|
+
shape: "ramp-down",
|
|
26
|
+
lane: flat,
|
|
27
|
+
range: VOLUME_RANGE,
|
|
28
|
+
t0: 1,
|
|
29
|
+
t1: 3,
|
|
30
|
+
});
|
|
31
|
+
expect(pts).toEqual([
|
|
32
|
+
{ t: 1, v: 0.8 },
|
|
33
|
+
{ t: 3, v: VOLUME_RANGE.min },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("swell peaks at range max mid-selection, smoothed", () => {
|
|
38
|
+
const pts = generateShape({ shape: "swell", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
|
|
39
|
+
expect(pts).toHaveLength(3);
|
|
40
|
+
expect(pts[1]).toMatchObject({ t: 2, v: VOLUME_RANGE.max });
|
|
41
|
+
expect(pts[0]?.curve).toBeDefined(); // eased, not a triangle
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("dip ducks to a quarter of the edge value in unit space", () => {
|
|
45
|
+
const pts = generateShape({ shape: "dip", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
|
|
46
|
+
// volume is linear 0..1: unit(0.8) = 0.8, floor = 0.2
|
|
47
|
+
expect(pts[1]?.v).toBeCloseTo(0.2, 5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("computes in unit space on a log lane", () => {
|
|
51
|
+
const range = resolveAutomationRange("fx.n1.frequency", {
|
|
52
|
+
version: 1,
|
|
53
|
+
nodes: [{ type: "lowpass", id: "n1", params: {} }],
|
|
54
|
+
});
|
|
55
|
+
expect(range?.scale).toBe("log");
|
|
56
|
+
if (!range) return;
|
|
57
|
+
const lane: HfAutomationLane = {
|
|
58
|
+
target: "fx.n1.frequency",
|
|
59
|
+
points: [
|
|
60
|
+
{ t: 0, v: 2000 },
|
|
61
|
+
{ t: 6, v: 2000 },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
const pts = generateShape({ shape: "dip", lane, range, t0: 1, t1: 3 });
|
|
65
|
+
const floor = pts[1]?.v ?? 0;
|
|
66
|
+
// A quarter of the way up the LOG axis, not 500 Hz.
|
|
67
|
+
expect(floor).toBeGreaterThan(range.min);
|
|
68
|
+
expect(floor).toBeLessThan(2000 * 0.25);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("uses the range default when the lane is empty", () => {
|
|
72
|
+
const empty: HfAutomationLane = { target: "volume", points: [] };
|
|
73
|
+
const pts = generateShape({
|
|
74
|
+
shape: "ramp-down",
|
|
75
|
+
lane: empty,
|
|
76
|
+
range: VOLUME_RANGE,
|
|
77
|
+
t0: 1,
|
|
78
|
+
t1: 3,
|
|
79
|
+
});
|
|
80
|
+
expect(pts[0]?.v).toBe(VOLUME_RANGE.default);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The utility shapes a video author reaches for: fade in, fade out, swell,
|
|
3
|
+
* duck. One shape scaled to the selection — this is not a DAW, nobody needs a
|
|
4
|
+
* tempo-synced LFO. Edge values come from the envelope itself so a shape
|
|
5
|
+
* splices into whatever is already there; vertical maths runs in unit space so
|
|
6
|
+
* a log knob (frequency) behaves like the lane that draws it.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
sampleAutomationLane,
|
|
10
|
+
type AutomationRange,
|
|
11
|
+
type HfAutomationLane,
|
|
12
|
+
type HfAutomationPoint,
|
|
13
|
+
} from "@hyperframes/core/audio-automation";
|
|
14
|
+
import { fromUnit, toUnit } from "./automationLaneGeometry";
|
|
15
|
+
|
|
16
|
+
export type AutomationShapeId = "ramp-up" | "ramp-down" | "swell" | "dip";
|
|
17
|
+
|
|
18
|
+
export const AUTOMATION_SHAPES: ReadonlyArray<{ id: AutomationShapeId; label: string }> = [
|
|
19
|
+
{ id: "ramp-up", label: "Ramp up" },
|
|
20
|
+
{ id: "ramp-down", label: "Ramp down" },
|
|
21
|
+
{ id: "swell", label: "Swell" },
|
|
22
|
+
{ id: "dip", label: "Dip" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Ease used on the segments entering/leaving a swell or dip midpoint. */
|
|
26
|
+
const SMOOTH = 0.4;
|
|
27
|
+
/** A dip ducks to this fraction of the edge value, in unit space. */
|
|
28
|
+
const DIP_FLOOR = 0.25;
|
|
29
|
+
|
|
30
|
+
function edgeValue(lane: HfAutomationLane, range: AutomationRange, t: number): number {
|
|
31
|
+
if (lane.points.length === 0) return range.default ?? (range.min + range.max) / 2;
|
|
32
|
+
return sampleAutomationLane(lane, t, range.scale);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function generateShape(input: {
|
|
36
|
+
shape: AutomationShapeId;
|
|
37
|
+
lane: HfAutomationLane;
|
|
38
|
+
range: AutomationRange;
|
|
39
|
+
t0: number;
|
|
40
|
+
t1: number;
|
|
41
|
+
}): HfAutomationPoint[] {
|
|
42
|
+
const { shape, lane, range, t0, t1 } = input;
|
|
43
|
+
const v0 = edgeValue(lane, range, t0);
|
|
44
|
+
const v1 = edgeValue(lane, range, t1);
|
|
45
|
+
const mid = (t0 + t1) / 2;
|
|
46
|
+
switch (shape) {
|
|
47
|
+
case "ramp-up":
|
|
48
|
+
return [
|
|
49
|
+
{ t: t0, v: range.min },
|
|
50
|
+
{ t: t1, v: v1 },
|
|
51
|
+
];
|
|
52
|
+
case "ramp-down":
|
|
53
|
+
return [
|
|
54
|
+
{ t: t0, v: v0 },
|
|
55
|
+
{ t: t1, v: range.min },
|
|
56
|
+
];
|
|
57
|
+
case "swell":
|
|
58
|
+
return [
|
|
59
|
+
{ t: t0, v: v0, curve: SMOOTH },
|
|
60
|
+
{ t: mid, v: range.max, curve: -SMOOTH },
|
|
61
|
+
{ t: t1, v: v1 },
|
|
62
|
+
];
|
|
63
|
+
case "dip":
|
|
64
|
+
return [
|
|
65
|
+
{ t: t0, v: v0, curve: -SMOOTH },
|
|
66
|
+
{ t: mid, v: fromUnit(range, toUnit(range, v0) * DIP_FLOOR), curve: SMOOTH },
|
|
67
|
+
{ t: t1, v: v1 },
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { simplifyPoints } from "./automationSimplify";
|
|
3
|
+
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import { toUnit } from "./automationLaneGeometry";
|
|
5
|
+
import type { HfAutomationPoint } from "@hyperframes/core/audio-automation";
|
|
6
|
+
|
|
7
|
+
describe("simplifyPoints", () => {
|
|
8
|
+
it("collapses collinear runs to their endpoints", () => {
|
|
9
|
+
const line: HfAutomationPoint[] = Array.from({ length: 50 }, (_, i) => ({
|
|
10
|
+
t: i * 0.1,
|
|
11
|
+
v: 1 - i * 0.01,
|
|
12
|
+
}));
|
|
13
|
+
const out = simplifyPoints(line, VOLUME_RANGE);
|
|
14
|
+
expect(out).toHaveLength(2);
|
|
15
|
+
expect(out[0]).toEqual(line[0]);
|
|
16
|
+
expect(out[out.length - 1]).toEqual(line[line.length - 1]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps every survivor within epsilon of the original", () => {
|
|
20
|
+
const wave: HfAutomationPoint[] = Array.from({ length: 100 }, (_, i) => ({
|
|
21
|
+
t: i * 0.05,
|
|
22
|
+
v: 0.5 + 0.4 * Math.sin(i * 0.2),
|
|
23
|
+
}));
|
|
24
|
+
const out = simplifyPoints(wave, VOLUME_RANGE, 0.02);
|
|
25
|
+
expect(out.length).toBeLessThan(wave.length / 2);
|
|
26
|
+
// Every dropped point must sit within epsilon (unit space) of the
|
|
27
|
+
// simplified polyline — check by linear interpolation between survivors.
|
|
28
|
+
for (const p of wave) {
|
|
29
|
+
const rIdx = out.findIndex((q) => q.t >= p.t);
|
|
30
|
+
const b = out[rIdx] ?? out[out.length - 1];
|
|
31
|
+
const a = out[rIdx - 1] ?? b;
|
|
32
|
+
if (!a || !b) continue;
|
|
33
|
+
const span = b.t - a.t;
|
|
34
|
+
const f = span > 0 ? (p.t - a.t) / span : 0;
|
|
35
|
+
const approx =
|
|
36
|
+
toUnit(VOLUME_RANGE, a.v) + f * (toUnit(VOLUME_RANGE, b.v) - toUnit(VOLUME_RANGE, a.v));
|
|
37
|
+
expect(Math.abs(approx - toUnit(VOLUME_RANGE, p.v))).toBeLessThanOrEqual(0.021);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns short inputs untouched", () => {
|
|
42
|
+
const two: HfAutomationPoint[] = [
|
|
43
|
+
{ t: 0, v: 1 },
|
|
44
|
+
{ t: 1, v: 0 },
|
|
45
|
+
];
|
|
46
|
+
expect(simplifyPoints(two, VOLUME_RANGE)).toEqual(two);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ramer–Douglas–Peucker over an envelope's points, deviation measured
|
|
3
|
+
* VERTICALLY in unit space. Vertical (not perpendicular) because an envelope
|
|
4
|
+
* is a function of time — what matters is how far the value strays, and it
|
|
5
|
+
* keeps the metric independent of the time axis' units. Exists for dense
|
|
6
|
+
* producers: carve output and heavy hand edits.
|
|
7
|
+
*/
|
|
8
|
+
import type { AutomationRange, HfAutomationPoint } from "@hyperframes/core/audio-automation";
|
|
9
|
+
import { toUnit } from "./automationLaneGeometry";
|
|
10
|
+
|
|
11
|
+
export function simplifyPoints(
|
|
12
|
+
points: HfAutomationPoint[],
|
|
13
|
+
range: AutomationRange,
|
|
14
|
+
epsilon = 0.02,
|
|
15
|
+
): HfAutomationPoint[] {
|
|
16
|
+
if (points.length <= 2) return points;
|
|
17
|
+
const keep = new Array<boolean>(points.length).fill(false);
|
|
18
|
+
const last = keep.length - 1;
|
|
19
|
+
keep[0] = true;
|
|
20
|
+
keep[last] = true;
|
|
21
|
+
|
|
22
|
+
const stack: Array<[number, number]> = [[0, last]];
|
|
23
|
+
while (stack.length > 0) {
|
|
24
|
+
const seg = stack.pop();
|
|
25
|
+
if (!seg) break;
|
|
26
|
+
const [a, b] = seg;
|
|
27
|
+
const pa = points[a];
|
|
28
|
+
const pb = points[b];
|
|
29
|
+
if (!pa || !pb || b - a < 2) continue;
|
|
30
|
+
const ua = toUnit(range, pa.v);
|
|
31
|
+
const ub = toUnit(range, pb.v);
|
|
32
|
+
const span = pb.t - pa.t;
|
|
33
|
+
let worst = -1;
|
|
34
|
+
let worstDev = epsilon;
|
|
35
|
+
for (let i = a + 1; i < b; i += 1) {
|
|
36
|
+
const p = points[i];
|
|
37
|
+
if (!p) continue;
|
|
38
|
+
const f = span > 0 ? (p.t - pa.t) / span : 0;
|
|
39
|
+
const dev = Math.abs(toUnit(range, p.v) - (ua + f * (ub - ua)));
|
|
40
|
+
if (dev > worstDev) {
|
|
41
|
+
worstDev = dev;
|
|
42
|
+
worst = i;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (worst >= 0) {
|
|
46
|
+
keep[worst] = true;
|
|
47
|
+
stack.push([a, worst], [worst, b]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return points.filter((_, i) => keep[i]);
|
|
51
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
4
|
+
import {
|
|
5
|
+
resolveBlockedTimelineEditIntent,
|
|
6
|
+
type BlockedTimelineEditIntent,
|
|
7
|
+
} from "./timelineEditing";
|
|
8
|
+
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
|
+
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
+
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
12
|
+
|
|
13
|
+
export interface ClipGestureDeps {
|
|
14
|
+
pps: number;
|
|
15
|
+
onResizeElement?: TimelineEditCallbacks["onResizeElement"];
|
|
16
|
+
onMoveElement?: TimelineEditCallbacks["onMoveElement"];
|
|
17
|
+
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
18
|
+
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
19
|
+
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
20
|
+
shiftClickClipRef: React.RefObject<{
|
|
21
|
+
element: TimelineElement;
|
|
22
|
+
anchorX: number;
|
|
23
|
+
anchorY: number;
|
|
24
|
+
} | null>;
|
|
25
|
+
suppressClickRef: React.RefObject<boolean>;
|
|
26
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
27
|
+
setShowPopover(v: boolean): void;
|
|
28
|
+
setRangeSelection(v: null): void;
|
|
29
|
+
setResizingClip(v: ResizingClipState | null): void;
|
|
30
|
+
setDraggedClip(v: DraggedClipState | null): void;
|
|
31
|
+
setSelectedElementId(id: string | null): void;
|
|
32
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether a resize-handle drag on `edge` is allowed to begin at all. */
|
|
36
|
+
function canStartResize(
|
|
37
|
+
edge: "start" | "end",
|
|
38
|
+
e: ReactPointerEvent,
|
|
39
|
+
capabilities: TimelineEditCapabilities,
|
|
40
|
+
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
41
|
+
): boolean {
|
|
42
|
+
if (e.button !== 0 || e.shiftKey || !onResizeElement) return false;
|
|
43
|
+
if (edge === "start") return capabilities.canTrimStart;
|
|
44
|
+
return capabilities.canTrimEnd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type PointerDownAction =
|
|
48
|
+
| { kind: "ignore" }
|
|
49
|
+
| { kind: "arm-shift-click" }
|
|
50
|
+
| { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
|
|
51
|
+
| { kind: "move"; rect: DOMRect };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A hit-tested intent is "blocked" when the pointer landed on a smaller
|
|
55
|
+
* target than the gesture it implies would need — e.g. a resize handle too
|
|
56
|
+
* narrow to grab reliably — AND that gesture is actually wired up. An intent
|
|
57
|
+
* with nowhere to go (no matching callback) is not blocking anything.
|
|
58
|
+
*/
|
|
59
|
+
function isIntentBlocked(
|
|
60
|
+
intent: BlockedTimelineEditIntent | null,
|
|
61
|
+
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
62
|
+
onMoveElement: ClipGestureDeps["onMoveElement"],
|
|
63
|
+
): intent is BlockedTimelineEditIntent {
|
|
64
|
+
if (!intent) return false;
|
|
65
|
+
return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a pointerdown on a clip bar means, before any state is touched. Kept
|
|
70
|
+
* pure (no refs, no store writes) so the branching that decides move vs.
|
|
71
|
+
* resize-handle vs. blocked-by-a-smaller-hit-target lives in one place,
|
|
72
|
+
* separate from the side effects `onPointerDown` below performs for it.
|
|
73
|
+
*/
|
|
74
|
+
function resolvePointerDownAction(
|
|
75
|
+
e: ReactPointerEvent,
|
|
76
|
+
capabilities: TimelineEditCapabilities,
|
|
77
|
+
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
78
|
+
onMoveElement: ClipGestureDeps["onMoveElement"],
|
|
79
|
+
): PointerDownAction {
|
|
80
|
+
if (e.button !== 0) return { kind: "ignore" };
|
|
81
|
+
if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
|
|
82
|
+
if (e.shiftKey) return { kind: "arm-shift-click" };
|
|
83
|
+
|
|
84
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
85
|
+
const intent = resolveBlockedTimelineEditIntent({
|
|
86
|
+
width: rect.width,
|
|
87
|
+
offsetX: e.clientX - rect.left,
|
|
88
|
+
handleWidth: CLIP_HANDLE_W,
|
|
89
|
+
capabilities,
|
|
90
|
+
});
|
|
91
|
+
if (isIntentBlocked(intent, onResizeElement, onMoveElement)) {
|
|
92
|
+
return { kind: "block", intent, rect };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
|
|
96
|
+
return { kind: "move", rect };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The three pointer/click gestures a clip bar answers to. Factored out of
|
|
101
|
+
* TimelineLanes' render loop — one call per rendered clip, closing over that
|
|
102
|
+
* clip's own `el`/`elementKey`/`previewElement`/`capabilities` — so the huge
|
|
103
|
+
* per-track map body reads as JSX instead of ~120 lines of gesture logic.
|
|
104
|
+
*/
|
|
105
|
+
export function createClipGestureHandlers(
|
|
106
|
+
el: TimelineElement,
|
|
107
|
+
elementKey: string,
|
|
108
|
+
previewElement: TimelineElement,
|
|
109
|
+
capabilities: TimelineEditCapabilities,
|
|
110
|
+
deps: ClipGestureDeps,
|
|
111
|
+
) {
|
|
112
|
+
const {
|
|
113
|
+
pps,
|
|
114
|
+
onResizeElement,
|
|
115
|
+
onMoveElement,
|
|
116
|
+
onRazorSplit,
|
|
117
|
+
onRazorSplitAll,
|
|
118
|
+
blockedClipRef,
|
|
119
|
+
shiftClickClipRef,
|
|
120
|
+
suppressClickRef,
|
|
121
|
+
scrollRef,
|
|
122
|
+
setShowPopover,
|
|
123
|
+
setRangeSelection,
|
|
124
|
+
setResizingClip,
|
|
125
|
+
setDraggedClip,
|
|
126
|
+
setSelectedElementId,
|
|
127
|
+
onSelectElement,
|
|
128
|
+
} = deps;
|
|
129
|
+
|
|
130
|
+
const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
|
|
131
|
+
if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
|
|
132
|
+
e.stopPropagation();
|
|
133
|
+
blockedClipRef.current = null;
|
|
134
|
+
setShowPopover(false);
|
|
135
|
+
setRangeSelection(null);
|
|
136
|
+
setResizingClip({
|
|
137
|
+
pointerId: e.pointerId,
|
|
138
|
+
element: el,
|
|
139
|
+
edge,
|
|
140
|
+
originClientX: e.clientX,
|
|
141
|
+
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
142
|
+
previewStart: el.start,
|
|
143
|
+
previewDuration: el.duration,
|
|
144
|
+
previewPlaybackStart: el.playbackStart,
|
|
145
|
+
started: false,
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const onPointerDown = (e: ReactPointerEvent): void => {
|
|
150
|
+
const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
|
|
151
|
+
if (action.kind === "ignore") return;
|
|
152
|
+
|
|
153
|
+
if (action.kind === "arm-shift-click") {
|
|
154
|
+
shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (action.kind === "block") {
|
|
159
|
+
blockedClipRef.current = {
|
|
160
|
+
pointerId: e.pointerId,
|
|
161
|
+
element: el,
|
|
162
|
+
intent: action.intent,
|
|
163
|
+
originClientX: e.clientX,
|
|
164
|
+
originClientY: e.clientY,
|
|
165
|
+
started: false,
|
|
166
|
+
};
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { rect } = action;
|
|
171
|
+
blockedClipRef.current = null;
|
|
172
|
+
setShowPopover(false);
|
|
173
|
+
setRangeSelection(null);
|
|
174
|
+
setDraggedClip({
|
|
175
|
+
pointerId: e.pointerId,
|
|
176
|
+
element: el,
|
|
177
|
+
originClientX: e.clientX,
|
|
178
|
+
originClientY: e.clientY,
|
|
179
|
+
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
180
|
+
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
181
|
+
pointerClientX: e.clientX,
|
|
182
|
+
pointerClientY: e.clientY,
|
|
183
|
+
pointerOffsetX: e.clientX - rect.left,
|
|
184
|
+
pointerOffsetY: e.clientY - rect.top,
|
|
185
|
+
previewStart: el.start,
|
|
186
|
+
previewTrack: el.track,
|
|
187
|
+
desiredTrack: el.track,
|
|
188
|
+
insertRow: null,
|
|
189
|
+
snapTime: null,
|
|
190
|
+
snapType: null,
|
|
191
|
+
started: false,
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onClick = (e: ReactMouseEvent): void => {
|
|
196
|
+
e.stopPropagation();
|
|
197
|
+
if (suppressClickRef.current) return;
|
|
198
|
+
const { activeTool } = usePlayerStore.getState();
|
|
199
|
+
if (activeTool === "razor" && onRazorSplit) {
|
|
200
|
+
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
201
|
+
const clickOffsetX = e.clientX - clipRect.left;
|
|
202
|
+
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
203
|
+
const clampedTime = Math.max(
|
|
204
|
+
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
205
|
+
Math.min(
|
|
206
|
+
previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
|
|
207
|
+
splitTime,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
if (e.shiftKey && onRazorSplitAll) {
|
|
211
|
+
onRazorSplitAll(clampedTime);
|
|
212
|
+
} else {
|
|
213
|
+
onRazorSplit(el, clampedTime);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
218
|
+
setSelectedElementId(elementKey);
|
|
219
|
+
onSelectElement?.(el);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return { onResizeStart, onPointerDown, onClick };
|
|
223
|
+
}
|
|
@@ -9,6 +9,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us
|
|
|
9
9
|
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
10
10
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
11
11
|
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
|
12
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
13
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
12
14
|
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
13
15
|
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
|
14
16
|
|
|
@@ -98,3 +100,20 @@ export interface TimelineLaneBaseProps {
|
|
|
98
100
|
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
99
101
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
100
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* {@link TimelineLaneBaseProps} plus the handful of props only the lane
|
|
106
|
+
* renderer ({@link import("./TimelineLanes")}) itself needs — the drag-preview
|
|
107
|
+
* state and the edit callbacks TimelineCanvas does not otherwise touch.
|
|
108
|
+
*/
|
|
109
|
+
export interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
110
|
+
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
111
|
+
draggedElement: TimelineElement | null;
|
|
112
|
+
multiDragPreview: MultiDragPreviewInput | null;
|
|
113
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
114
|
+
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
115
|
+
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
116
|
+
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
117
|
+
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
118
|
+
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
119
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
1
2
|
import type { ZoomMode } from "../store/playerStore";
|
|
2
3
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
3
4
|
|
|
@@ -86,6 +87,8 @@ export const TRACKS_LEFT_PAD = 48;
|
|
|
86
87
|
export interface TimelineTrackHeightClip {
|
|
87
88
|
clipId: string;
|
|
88
89
|
laneCount: number;
|
|
90
|
+
/** Audio automation lanes shown when expanded, reserved at their own height. */
|
|
91
|
+
automationLaneCount?: number;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
|
|
@@ -101,12 +104,15 @@ export function trackHeights(
|
|
|
101
104
|
): number[] {
|
|
102
105
|
return tracks.map((clips) => {
|
|
103
106
|
let laneCount = 0;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
let automationLanes = 0;
|
|
108
|
+
for (const clip of clips) {
|
|
109
|
+
if (!expandedClipIds?.has(clip.clipId)) continue;
|
|
110
|
+
laneCount = Math.max(laneCount, clip.laneCount);
|
|
111
|
+
automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
|
|
108
112
|
}
|
|
109
|
-
return
|
|
113
|
+
return (
|
|
114
|
+
TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
|
|
115
|
+
);
|
|
110
116
|
});
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clampEdge } from "./useAutomationEdgeStretch";
|
|
3
|
+
|
|
4
|
+
describe("clampEdge", () => {
|
|
5
|
+
it("keeps the dragged edge inside the clip", () => {
|
|
6
|
+
expect(clampEdge("t0", -5, { t0: 1, t1: 3 }, 4)).toBe(0);
|
|
7
|
+
expect(clampEdge("t1", 99, { t0: 1, t1: 3 }, 4)).toBe(4);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("keeps the dragged edge clear of its partner", () => {
|
|
11
|
+
expect(clampEdge("t0", 3.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(2.98, 5);
|
|
12
|
+
expect(clampEdge("t1", 0.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(1.02, 5);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("never yields a negative time, even when the partner bound is itself below zero", () => {
|
|
16
|
+
// A selection thinner than the minimum width has no legal t0 at all. Bounding
|
|
17
|
+
// against the partner AFTER the 0-floor returned that illegal value, and
|
|
18
|
+
// core's cleanPoint collapses a negative time onto a duplicate t=0 on the
|
|
19
|
+
// next serialize round-trip — silent envelope corruption, not a visible bug.
|
|
20
|
+
expect(clampEdge("t0", -1, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
|
|
21
|
+
expect(clampEdge("t0", 0.008, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("never yields a time past the clip when the partner bound is past it", () => {
|
|
25
|
+
expect(clampEdge("t1", 99, { t0: 3.995, t1: 4 }, 4)).toBe(4);
|
|
26
|
+
});
|
|
27
|
+
});
|