@hyperframes/studio 0.8.47 → 0.8.48
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-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
- package/dist/assets/{index-ryL7q1QG.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +66 -23
- package/dist/index.html +2 -2
- package/dist/index.js +1015 -621
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/index-DnRfAiK2.css +0 -1
- package/dist/assets/index-j-4hCrZU.js +0 -441
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
automationAttrValue,
|
|
5
|
+
withLane,
|
|
6
|
+
withoutLane,
|
|
7
|
+
withPointAt,
|
|
8
|
+
} from "./propertyPanelAutomation.js";
|
|
4
9
|
|
|
5
10
|
const carved = (): HfAutomation => ({
|
|
6
11
|
version: 1,
|
|
@@ -45,6 +50,82 @@ describe("withLane", () => {
|
|
|
45
50
|
});
|
|
46
51
|
});
|
|
47
52
|
|
|
53
|
+
// A panel control has no drag gesture of its own, so a commit has to land on
|
|
54
|
+
// the same point the timeline envelope's double-click would have produced.
|
|
55
|
+
describe("withPointAt", () => {
|
|
56
|
+
it("adds a point to an existing lane, keeping the others", () => {
|
|
57
|
+
const next = withPointAt(carved(), "volume", 5, 0.3);
|
|
58
|
+
const volume = next.lanes.find((l) => l.target === "volume");
|
|
59
|
+
expect(volume?.points).toEqual([
|
|
60
|
+
{ t: 0, v: 0.8 },
|
|
61
|
+
{ t: 5, v: 0.3 },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("replaces the nearby point instead of stacking a second one on top of it", () => {
|
|
66
|
+
const automation: HfAutomation = {
|
|
67
|
+
version: 1,
|
|
68
|
+
lanes: [
|
|
69
|
+
{
|
|
70
|
+
target: "volume",
|
|
71
|
+
points: [
|
|
72
|
+
{ t: 0, v: 0.8 },
|
|
73
|
+
{ t: 5, v: 0.3 },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const next = withPointAt(automation, "volume", 5.01, 0.6);
|
|
79
|
+
const volume = next.lanes.find((l) => l.target === "volume");
|
|
80
|
+
expect(volume?.points).toEqual([
|
|
81
|
+
{ t: 0, v: 0.8 },
|
|
82
|
+
{ t: 5.01, v: 0.6 },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("keeps points sorted after an insert earlier than the last one", () => {
|
|
87
|
+
const automation: HfAutomation = {
|
|
88
|
+
version: 1,
|
|
89
|
+
lanes: [
|
|
90
|
+
{
|
|
91
|
+
target: "volume",
|
|
92
|
+
points: [
|
|
93
|
+
{ t: 0, v: 0.2 },
|
|
94
|
+
{ t: 10, v: 0.9 },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
const next = withPointAt(automation, "volume", 4, 0.5);
|
|
100
|
+
expect(next.lanes.find((l) => l.target === "volume")?.points.map((p) => p.t)).toEqual([
|
|
101
|
+
0, 4, 10,
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("seeds the start when writing to an empty lane away from t=0", () => {
|
|
106
|
+
// A single point would be a constant jump the instant playback crosses it,
|
|
107
|
+
// not the envelope a fresh keyframe should produce.
|
|
108
|
+
const automation: HfAutomation = { version: 1, lanes: [{ target: "volume", points: [] }] };
|
|
109
|
+
const next = withPointAt(automation, "volume", 5, 0.3);
|
|
110
|
+
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([
|
|
111
|
+
{ t: 0, v: 0.3 },
|
|
112
|
+
{ t: 5, v: 0.3 },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does not seed a second point when writing at the start of an empty lane", () => {
|
|
117
|
+
const automation: HfAutomation = { version: 1, lanes: [{ target: "volume", points: [] }] };
|
|
118
|
+
const next = withPointAt(automation, "volume", 0, 0.4);
|
|
119
|
+
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.4 }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("leaves sibling lanes untouched", () => {
|
|
123
|
+
const next = withPointAt(carved(), "fx.n1.gain", 3, -2);
|
|
124
|
+
expect(next.lanes.find((l) => l.target === "fx.n2.gain")?.points).toEqual([{ t: 0, v: -9 }]);
|
|
125
|
+
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
48
129
|
describe("withoutLane", () => {
|
|
49
130
|
it("takes one lane and leaves the rest", () => {
|
|
50
131
|
// A node removed without its lane leaves an orphan driving a parameter that
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type HfAutomation,
|
|
18
18
|
} from "@hyperframes/core/audio-automation";
|
|
19
19
|
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
20
|
+
import { mergeInsertPoint } from "../../player/components/automationLaneGeometry";
|
|
20
21
|
|
|
21
22
|
const EMPTY: HfAutomation = { version: 1, lanes: [] };
|
|
22
23
|
|
|
@@ -89,6 +90,20 @@ export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAu
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/** Insert or replace the point nearest `t` on `target`'s lane — reuses the
|
|
94
|
+
* timeline envelope's own double-click merge rule, so a panel control with no
|
|
95
|
+
* drag gesture of its own can still write into an automated parameter the
|
|
96
|
+
* same way. */
|
|
97
|
+
export function withPointAt(
|
|
98
|
+
automation: HfAutomation,
|
|
99
|
+
target: string,
|
|
100
|
+
t: number,
|
|
101
|
+
v: number,
|
|
102
|
+
): HfAutomation {
|
|
103
|
+
const points = automation.lanes.find((lane) => lane.target === target)?.points ?? [];
|
|
104
|
+
return withLane(automation, { target, points: mergeInsertPoint(points, t, v) });
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
/** The attribute value for an automation set; empty when nothing is automated. */
|
|
93
108
|
export function automationAttrValue(automation: HfAutomation): string {
|
|
94
109
|
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
|
|
@@ -189,6 +189,41 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
189
189
|
act(() => root.unmount());
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
it("writes through the envelope instead of the attribute once volume is automated", () => {
|
|
193
|
+
const onSetAttribute = vi.fn();
|
|
194
|
+
const onCommitVolumeAt = vi.fn();
|
|
195
|
+
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
|
|
196
|
+
const host = document.createElement("div");
|
|
197
|
+
document.body.append(host);
|
|
198
|
+
const root = createRoot(host);
|
|
199
|
+
act(() => {
|
|
200
|
+
root.render(
|
|
201
|
+
<FlatMediaSection
|
|
202
|
+
projectDir={null}
|
|
203
|
+
element={element}
|
|
204
|
+
styles={{}}
|
|
205
|
+
onSetStyle={vi.fn()}
|
|
206
|
+
onSetAttribute={onSetAttribute}
|
|
207
|
+
onSetHtmlAttribute={vi.fn()}
|
|
208
|
+
volumeAutomated
|
|
209
|
+
onCommitVolumeAt={onCommitVolumeAt}
|
|
210
|
+
/>,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
|
|
214
|
+
Object.defineProperty(volumeTrack, "getBoundingClientRect", {
|
|
215
|
+
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
|
216
|
+
});
|
|
217
|
+
act(() => {
|
|
218
|
+
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
|
|
219
|
+
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
|
|
220
|
+
});
|
|
221
|
+
expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
|
|
223
|
+
expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
|
|
224
|
+
act(() => root.unmount());
|
|
225
|
+
});
|
|
226
|
+
|
|
192
227
|
it("commits a new rate value on slider track pointerdown", () => {
|
|
193
228
|
const onSetAttribute = vi.fn();
|
|
194
229
|
const element = makeVideoElement();
|
|
@@ -34,6 +34,8 @@ export function FlatMediaSection({
|
|
|
34
34
|
volumeAutomated,
|
|
35
35
|
onAutomateVolume,
|
|
36
36
|
onRemoveVolumeAutomation,
|
|
37
|
+
onCommitVolumeAt,
|
|
38
|
+
automatedVolumeValue,
|
|
37
39
|
}: {
|
|
38
40
|
projectDir: string | null;
|
|
39
41
|
element: DomEditSelection;
|
|
@@ -41,10 +43,12 @@ export function FlatMediaSection({
|
|
|
41
43
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
42
44
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
43
45
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
44
|
-
/** A volume lane in the timeline drives the level; the slider
|
|
46
|
+
/** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
|
|
45
47
|
volumeAutomated?: boolean;
|
|
46
48
|
onAutomateVolume?: () => void;
|
|
47
49
|
onRemoveVolumeAutomation?: () => void;
|
|
50
|
+
onCommitVolumeAt?: (v: number) => void;
|
|
51
|
+
automatedVolumeValue?: number;
|
|
48
52
|
onRemoveBackground?: (
|
|
49
53
|
inputPath: string,
|
|
50
54
|
options: {
|
|
@@ -61,7 +65,13 @@ export function FlatMediaSection({
|
|
|
61
65
|
const isVisualMedia = isVideo || isImage;
|
|
62
66
|
const el = element.element;
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
// While a lane owns the level, the envelope's live value at the playhead is
|
|
69
|
+
// what the slider must show — the static attribute is only what the engine
|
|
70
|
+
// falls back to outside automation.
|
|
71
|
+
const volume =
|
|
72
|
+
volumeAutomated && automatedVolumeValue !== undefined
|
|
73
|
+
? automatedVolumeValue
|
|
74
|
+
: (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
|
|
65
75
|
const volumeFaderPosition = audioGainToFaderPosition(volume);
|
|
66
76
|
const mediaStart =
|
|
67
77
|
Number.parseFloat(
|
|
@@ -213,9 +223,9 @@ export function FlatMediaSection({
|
|
|
213
223
|
)}
|
|
214
224
|
{(isVideo || isAudio) && (
|
|
215
225
|
<>
|
|
216
|
-
{/*
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
{/* While a lane owns the level, a commit writes a keyframe at the
|
|
227
|
+
playhead instead of the plain attribute — same slider, same
|
|
228
|
+
gesture, the write just goes through the envelope. */}
|
|
219
229
|
<div
|
|
220
230
|
className="hf-volume-row flex items-center gap-1"
|
|
221
231
|
data-volume-automated={volumeAutomated ? "" : undefined}
|
|
@@ -228,11 +238,15 @@ export function FlatMediaSection({
|
|
|
228
238
|
max={AUDIO_GAIN_FADER_MAX}
|
|
229
239
|
tier={volume === 1 ? "default" : "explicitCustom"}
|
|
230
240
|
displayValue={audioGainToText(volume)}
|
|
231
|
-
disabled={volumeAutomated}
|
|
232
241
|
centerTick
|
|
233
|
-
onCommit={(next) =>
|
|
234
|
-
|
|
235
|
-
|
|
242
|
+
onCommit={(next) => {
|
|
243
|
+
const gain = audioFaderPositionToGain(next);
|
|
244
|
+
if (volumeAutomated) {
|
|
245
|
+
onCommitVolumeAt?.(gain);
|
|
246
|
+
} else {
|
|
247
|
+
void onSetAttribute("volume", formatAudioGain(gain));
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
236
250
|
/>
|
|
237
251
|
</div>
|
|
238
252
|
<AutomationToggle
|
|
@@ -7,12 +7,15 @@ import type { DomEditSelection } from "./domEditingTypes";
|
|
|
7
7
|
|
|
8
8
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// The clip-local playhead time the hook derives is `currentTime - data-start`,
|
|
11
|
+
// clamped to `data-duration`: tests that care about it set both attributes.
|
|
12
|
+
function bind(dataAttributes: Record<string, string>, currentTime = 0) {
|
|
11
13
|
const onSetAttributeQuiet = vi.fn();
|
|
12
14
|
const captured: { current: VolumeAutomationBinding | null } = { current: null };
|
|
13
15
|
function Probe() {
|
|
14
16
|
captured.current = useVolumeAutomation(
|
|
15
17
|
{ dataAttributes } as unknown as DomEditSelection,
|
|
18
|
+
currentTime,
|
|
16
19
|
onSetAttributeQuiet,
|
|
17
20
|
);
|
|
18
21
|
return null;
|
|
@@ -29,6 +32,14 @@ function bind(dataAttributes: Record<string, string>) {
|
|
|
29
32
|
const volumeLane = (v: number) =>
|
|
30
33
|
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
|
|
31
34
|
|
|
35
|
+
const writtenAutomation = (onSetAttributeQuiet: ReturnType<typeof vi.fn>) =>
|
|
36
|
+
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1]));
|
|
37
|
+
|
|
38
|
+
const writtenVolumePoints = (onSetAttributeQuiet: ReturnType<typeof vi.fn>) =>
|
|
39
|
+
writtenAutomation(onSetAttributeQuiet).lanes.find(
|
|
40
|
+
(l: { target: string }) => l.target === "volume",
|
|
41
|
+
).points;
|
|
42
|
+
|
|
32
43
|
describe("useVolumeAutomation", () => {
|
|
33
44
|
it("reports an unautomated track", () => {
|
|
34
45
|
expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
|
|
@@ -61,7 +72,7 @@ describe("useVolumeAutomation", () => {
|
|
|
61
72
|
it("treats a missing data-volume as unity", () => {
|
|
62
73
|
const { binding, onSetAttributeQuiet } = bind({});
|
|
63
74
|
act(() => binding.onAutomateVolume());
|
|
64
|
-
expect(
|
|
75
|
+
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
|
|
65
76
|
});
|
|
66
77
|
|
|
67
78
|
it("keeps FX lanes when adding the volume one", () => {
|
|
@@ -72,9 +83,7 @@ describe("useVolumeAutomation", () => {
|
|
|
72
83
|
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
73
84
|
act(() => binding.onAutomateVolume());
|
|
74
85
|
expect(
|
|
75
|
-
|
|
76
|
-
(l: { target: string }) => l.target,
|
|
77
|
-
),
|
|
86
|
+
writtenAutomation(onSetAttributeQuiet).lanes.map((l: { target: string }) => l.target),
|
|
78
87
|
).toEqual(["fx.n1.frequency", "volume"]);
|
|
79
88
|
});
|
|
80
89
|
|
|
@@ -89,9 +98,7 @@ describe("useVolumeAutomation", () => {
|
|
|
89
98
|
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
90
99
|
act(() => binding.onRemoveVolumeAutomation());
|
|
91
100
|
expect(
|
|
92
|
-
|
|
93
|
-
(l: { target: string }) => l.target,
|
|
94
|
-
),
|
|
101
|
+
writtenAutomation(onSetAttributeQuiet).lanes.map((l: { target: string }) => l.target),
|
|
95
102
|
).toEqual(["fx.n1.frequency"]);
|
|
96
103
|
});
|
|
97
104
|
|
|
@@ -102,6 +109,70 @@ describe("useVolumeAutomation", () => {
|
|
|
102
109
|
expect(onSetAttributeQuiet).toHaveBeenCalledWith("data-automation", null);
|
|
103
110
|
});
|
|
104
111
|
|
|
112
|
+
it("writes a keyframe at the playhead instead of the flat attribute", () => {
|
|
113
|
+
// The slider used to be disabled while automated; this is what replaces
|
|
114
|
+
// that: a commit lands in the envelope, not on `data-volume`.
|
|
115
|
+
const { binding, onSetAttributeQuiet } = bind(
|
|
116
|
+
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
|
|
117
|
+
5,
|
|
118
|
+
);
|
|
119
|
+
act(() => binding.onCommitVolumeAt(0.7));
|
|
120
|
+
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
|
|
121
|
+
{ t: 0, v: 0.2 },
|
|
122
|
+
{ t: 5, v: 0.7 },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("clamps the playhead to the clip's own span, not the composition's", () => {
|
|
127
|
+
// A playhead past the clip's end must still land the keyframe at the
|
|
128
|
+
// clip's own last instant, not off the end of its automation lane.
|
|
129
|
+
const { binding, onSetAttributeQuiet } = bind(
|
|
130
|
+
{ volume: "0.4", automation: volumeLane(0.2), start: "10", duration: "4" },
|
|
131
|
+
50,
|
|
132
|
+
);
|
|
133
|
+
act(() => binding.onCommitVolumeAt(0.9));
|
|
134
|
+
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
|
|
135
|
+
{ t: 0, v: 0.2 },
|
|
136
|
+
{ t: 4, v: 0.9 },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("moves the existing point instead of stacking a new one when committing near it", () => {
|
|
141
|
+
const { binding, onSetAttributeQuiet } = bind(
|
|
142
|
+
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
|
|
143
|
+
0,
|
|
144
|
+
);
|
|
145
|
+
act(() => binding.onCommitVolumeAt(0.9));
|
|
146
|
+
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([{ t: 0, v: 0.9 }]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reports the envelope's own value at the playhead, not the static attribute", () => {
|
|
150
|
+
const { binding } = bind(
|
|
151
|
+
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
|
|
152
|
+
0,
|
|
153
|
+
);
|
|
154
|
+
expect(binding.automatedVolumeValue).toBe(0.2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("translates to clip-local time, not composition time, for a clip that starts mid-timeline", () => {
|
|
158
|
+
// start=10 keeps this inside the clip's own duration either way, so the
|
|
159
|
+
// duration clamp can't mask a missing `currentTime - elStart` subtraction
|
|
160
|
+
// the way start=0 cases elsewhere in this file do.
|
|
161
|
+
const { binding, onSetAttributeQuiet } = bind(
|
|
162
|
+
{ volume: "0.4", automation: volumeLane(0.2), start: "10", duration: "20" },
|
|
163
|
+
15,
|
|
164
|
+
);
|
|
165
|
+
act(() => binding.onCommitVolumeAt(0.9));
|
|
166
|
+
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
|
|
167
|
+
{ t: 0, v: 0.2 },
|
|
168
|
+
{ t: 5, v: 0.9 },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("reports no automated value when the track is not automated", () => {
|
|
173
|
+
expect(bind({ volume: "0.4" }).binding.automatedVolumeValue).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
105
176
|
it("reads an unreadable attribute as no automation", () => {
|
|
106
177
|
expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
|
|
107
178
|
});
|
|
@@ -111,12 +182,12 @@ describe("useVolumeAutomation", () => {
|
|
|
111
182
|
// engine read the same empty attribute as unity.
|
|
112
183
|
const { binding, onSetAttributeQuiet } = bind({ volume: "" });
|
|
113
184
|
act(() => binding.onAutomateVolume());
|
|
114
|
-
expect(
|
|
185
|
+
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
|
|
115
186
|
});
|
|
116
187
|
|
|
117
188
|
it("seeds at unity when data-volume is not a number", () => {
|
|
118
189
|
const { binding, onSetAttributeQuiet } = bind({ volume: "loud" });
|
|
119
190
|
act(() => binding.onAutomateVolume());
|
|
120
|
-
expect(
|
|
191
|
+
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
|
|
121
192
|
});
|
|
122
193
|
});
|
|
@@ -6,24 +6,36 @@
|
|
|
6
6
|
* rather than a second interpretation of the attribute.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
HF_AUDIO_AUTOMATION_DATA_KEY,
|
|
11
|
+
sampleAutomationLane,
|
|
12
|
+
VOLUME_TARGET,
|
|
13
|
+
} from "@hyperframes/core/audio-automation";
|
|
10
14
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
11
15
|
import {
|
|
12
16
|
automationAttrValue,
|
|
13
17
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
14
18
|
readPanelAutomation,
|
|
15
19
|
withoutLane,
|
|
20
|
+
withPointAt,
|
|
16
21
|
withSeededLane,
|
|
17
22
|
} from "./propertyPanelAutomation";
|
|
23
|
+
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
24
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
18
25
|
|
|
19
26
|
export interface VolumeAutomationBinding {
|
|
20
27
|
volumeAutomated: boolean;
|
|
21
28
|
onAutomateVolume: () => void;
|
|
22
29
|
onRemoveVolumeAutomation: () => void;
|
|
30
|
+
/** Write `v` as a keyframe at the playhead instead of the disabled fallback. */
|
|
31
|
+
onCommitVolumeAt: (v: number) => void;
|
|
32
|
+
/** The envelope's own value at the playhead, so the slider tracks it live. */
|
|
33
|
+
automatedVolumeValue: number | undefined;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export function useVolumeAutomation(
|
|
26
37
|
element: DomEditSelection,
|
|
38
|
+
currentTime: number,
|
|
27
39
|
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
28
40
|
): VolumeAutomationBinding {
|
|
29
41
|
// The chain is not needed to resolve a volume lane — volume is always a valid
|
|
@@ -32,6 +44,12 @@ export function useVolumeAutomation(
|
|
|
32
44
|
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
|
|
33
45
|
undefined,
|
|
34
46
|
);
|
|
47
|
+
// ponytail: no GSAP animations passed — a media clip always carries an
|
|
48
|
+
// explicit data-duration, so `deriveElementTiming` never infers from animations here.
|
|
49
|
+
// `currentTime` comes from the caller's own `useLivePlayheadTime()` — a second
|
|
50
|
+
// subscription here would race the same re-render the caller already triggers.
|
|
51
|
+
const { start: elStart, duration: elDuration } = deriveElementTiming(element);
|
|
52
|
+
const clipTimeSec = clampNumber(currentTime - elStart, 0, elDuration > 0 ? elDuration : Infinity);
|
|
35
53
|
const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
|
|
36
54
|
// Quiet: clicking the toggle used to reload the preview and restart every
|
|
37
55
|
// playing track, while the same click on an effect parameter did not.
|
|
@@ -43,12 +61,15 @@ export function useVolumeAutomation(
|
|
|
43
61
|
const raw = element.dataAttributes?.["volume"];
|
|
44
62
|
const parsed = raw ? Number(raw) : 1;
|
|
45
63
|
const current = Number.isFinite(parsed) ? parsed : 1;
|
|
64
|
+
const volumeLane = automation.lanes.find((lane) => lane.target === VOLUME_TARGET);
|
|
46
65
|
return {
|
|
47
|
-
volumeAutomated:
|
|
66
|
+
volumeAutomated: volumeLane !== undefined,
|
|
67
|
+
automatedVolumeValue: volumeLane ? sampleAutomationLane(volumeLane, clipTimeSec) : undefined,
|
|
48
68
|
// Seeded at the level the slider already shows, so automating the track does
|
|
49
69
|
// not change how loud it is.
|
|
50
70
|
onAutomateVolume: () =>
|
|
51
71
|
write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
|
|
52
72
|
onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
|
|
73
|
+
onCommitVolumeAt: (v: number) => write(withPointAt(automation, VOLUME_TARGET, clipTimeSec, v)),
|
|
53
74
|
};
|
|
54
75
|
}
|
|
@@ -256,15 +256,12 @@ export const VariablesPanel = memo(function VariablesPanel({
|
|
|
256
256
|
}: VariablesPanelProps) {
|
|
257
257
|
const { activeCompPath, showToast } = useStudioShellContext();
|
|
258
258
|
const { refreshKey } = useStudioPlaybackContext();
|
|
259
|
-
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
|
|
259
|
+
const { readProjectFile, writeProjectFile, fileTree, compositions } = useFileManagerContext();
|
|
260
260
|
const { domEditSelection } = useDomEditContext();
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
// Null only when the project has no composition yet, in which case sdkSession
|
|
266
|
-
// is also null and the panel is inert.
|
|
267
|
-
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree);
|
|
261
|
+
// Master view (no activeCompPath) targets the real main composition, not a
|
|
262
|
+
// hardcoded index.html — used for both the persist write target and the
|
|
263
|
+
// handoff render command. Null only when the project has no composition.
|
|
264
|
+
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(compositions);
|
|
268
265
|
const previewValues = usePreviewVariablesStore((s) => s.values);
|
|
269
266
|
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
|
|
270
267
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
|
8
|
+
import { CompositionsTab } from "./CompositionsTab";
|
|
9
|
+
|
|
10
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
11
|
+
(
|
|
12
|
+
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
|
13
|
+
).happyDOM.settings.disableIframePageLoading = true;
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (root) act(() => root?.unmount());
|
|
19
|
+
root = null;
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
usePlayerStore.setState({ thumbnailContentRevision: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mount(compositions: string[], masterCompositionPath: string | null) {
|
|
25
|
+
const host = document.createElement("div");
|
|
26
|
+
document.body.append(host);
|
|
27
|
+
root = createRoot(host);
|
|
28
|
+
act(() => {
|
|
29
|
+
root?.render(
|
|
30
|
+
<CompositionsTab
|
|
31
|
+
projectId="demo"
|
|
32
|
+
compositions={compositions}
|
|
33
|
+
activeComposition={null}
|
|
34
|
+
masterCompositionPath={masterCompositionPath}
|
|
35
|
+
onSelect={vi.fn()}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
return host;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("CompositionsTab root badge", () => {
|
|
43
|
+
it("marks the composition matching masterCompositionPath as root", () => {
|
|
44
|
+
const compositions = ["index.html", "compositions/headline.html"];
|
|
45
|
+
const host = mount(compositions, resolveMasterCompositionPath(compositions));
|
|
46
|
+
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
|
|
47
|
+
expect(cards).toHaveLength(2);
|
|
48
|
+
expect(cards[0]?.textContent).toContain("Root");
|
|
49
|
+
expect(cards[1]?.textContent).not.toContain("Root");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("marks nothing as root when masterCompositionPath is null", () => {
|
|
53
|
+
const compositions = ["compositions/hero.html", "compositions/outro.html"];
|
|
54
|
+
const host = mount(compositions, null);
|
|
55
|
+
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
|
|
56
|
+
expect(cards[0]?.textContent).not.toContain("Root");
|
|
57
|
+
expect(cards[1]?.textContent).not.toContain("Root");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -10,6 +10,8 @@ interface CompositionsTabProps {
|
|
|
10
10
|
projectId: string;
|
|
11
11
|
compositions: string[];
|
|
12
12
|
activeComposition: string | null;
|
|
13
|
+
/** The project's root composition (same value App.tsx auto-opens on load), or null if none. */
|
|
14
|
+
masterCompositionPath?: string | null;
|
|
13
15
|
onSelect: (comp: string) => void;
|
|
14
16
|
onRenderComposition?: (comp: string) => void;
|
|
15
17
|
onAddToTimeline?: (comp: string) => void;
|
|
@@ -117,6 +119,7 @@ function CompCard({
|
|
|
117
119
|
projectId,
|
|
118
120
|
comp,
|
|
119
121
|
isActive,
|
|
122
|
+
isRoot,
|
|
120
123
|
onSelect,
|
|
121
124
|
onRender,
|
|
122
125
|
isRendering,
|
|
@@ -127,6 +130,7 @@ function CompCard({
|
|
|
127
130
|
projectId: string;
|
|
128
131
|
comp: string;
|
|
129
132
|
isActive: boolean;
|
|
133
|
+
isRoot: boolean;
|
|
130
134
|
onSelect: () => void;
|
|
131
135
|
onRender?: () => void;
|
|
132
136
|
isRendering?: boolean;
|
|
@@ -298,6 +302,15 @@ function CompCard({
|
|
|
298
302
|
>
|
|
299
303
|
<div className="flex items-center gap-1">
|
|
300
304
|
<span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
|
|
305
|
+
{isRoot && (
|
|
306
|
+
<span
|
|
307
|
+
aria-label="Root composition — opens automatically on load"
|
|
308
|
+
title="Root composition — opens automatically on load"
|
|
309
|
+
className="flex-shrink-0 rounded-full bg-neutral-700/60 px-1.5 py-px text-[8px] font-bold uppercase tracking-wide text-neutral-300"
|
|
310
|
+
>
|
|
311
|
+
Root
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
301
314
|
{lintInfo && lintInfo.count > 0 && (
|
|
302
315
|
<span
|
|
303
316
|
aria-label={`${lintInfo.count} lint finding${lintInfo.count === 1 ? "" : "s"}`}
|
|
@@ -368,6 +381,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
368
381
|
projectId,
|
|
369
382
|
compositions,
|
|
370
383
|
activeComposition,
|
|
384
|
+
masterCompositionPath = null,
|
|
371
385
|
onSelect,
|
|
372
386
|
onRenderComposition,
|
|
373
387
|
onAddToTimeline,
|
|
@@ -391,6 +405,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
391
405
|
projectId={projectId}
|
|
392
406
|
comp={comp}
|
|
393
407
|
isActive={activeComposition === comp}
|
|
408
|
+
isRoot={comp === masterCompositionPath}
|
|
394
409
|
onSelect={() => onSelect(comp)}
|
|
395
410
|
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
|
396
411
|
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { LeftSidebar } from "./LeftSidebar";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
(
|
|
11
|
+
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
|
12
|
+
).happyDOM.settings.disableIframePageLoading = true;
|
|
13
|
+
|
|
14
|
+
let root: Root | null = null;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (root) act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.setState({ thumbnailContentRevision: 0 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("LeftSidebar root badge source", () => {
|
|
24
|
+
it("derives the root badge from the filtered compositions list, not the raw file tree", () => {
|
|
25
|
+
// `fileTree` includes a non-composition index.html (a vendored preset) that the
|
|
26
|
+
// server's filtered `compositions` list excludes — the badge must source from
|
|
27
|
+
// `compositions`, not the raw file tree.
|
|
28
|
+
const host = document.createElement("div");
|
|
29
|
+
document.body.append(host);
|
|
30
|
+
root = createRoot(host);
|
|
31
|
+
act(() => {
|
|
32
|
+
root?.render(
|
|
33
|
+
<LeftSidebar
|
|
34
|
+
projectId="demo"
|
|
35
|
+
compositions={["hero.html"]}
|
|
36
|
+
assets={[]}
|
|
37
|
+
fileTree={["index.html", "hero.html"]}
|
|
38
|
+
activeComposition={null}
|
|
39
|
+
onSelectComposition={vi.fn()}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
|
|
44
|
+
expect(cards).toHaveLength(1);
|
|
45
|
+
expect(cards[0]?.textContent).toContain("Root");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
useState,
|
|
4
4
|
useCallback,
|
|
5
5
|
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
6
7
|
useRef,
|
|
7
8
|
forwardRef,
|
|
8
9
|
type ReactNode,
|
|
@@ -11,6 +12,7 @@ import { CompositionsTab } from "./CompositionsTab";
|
|
|
11
12
|
import { AssetsTab } from "./AssetsTab";
|
|
12
13
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
13
14
|
import { safeLocalStorage } from "../../utils/safeStorage";
|
|
15
|
+
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
|
14
16
|
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
15
17
|
import { FileTree } from "../editor/FileTree";
|
|
16
18
|
import { Tooltip } from "../ui";
|
|
@@ -120,6 +122,10 @@ export const LeftSidebar = memo(
|
|
|
120
122
|
const tabRef = useRef(tab);
|
|
121
123
|
tabRef.current = tab;
|
|
122
124
|
const tablistRef = useRef<HTMLDivElement>(null);
|
|
125
|
+
const masterCompositionPath = useMemo(
|
|
126
|
+
() => resolveMasterCompositionPath(compositions),
|
|
127
|
+
[compositions],
|
|
128
|
+
);
|
|
123
129
|
|
|
124
130
|
const selectTab = useCallback((t: SidebarTab) => {
|
|
125
131
|
setTab(t);
|
|
@@ -228,6 +234,7 @@ export const LeftSidebar = memo(
|
|
|
228
234
|
projectId={projectId}
|
|
229
235
|
compositions={compositions}
|
|
230
236
|
activeComposition={activeComposition}
|
|
237
|
+
masterCompositionPath={masterCompositionPath}
|
|
231
238
|
onSelect={onSelectComposition}
|
|
232
239
|
onAddToTimeline={onAddCompositionToTimeline}
|
|
233
240
|
onRenderComposition={onRenderComposition}
|