@hyperframes/studio 0.8.46 → 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.
Files changed (112) hide show
  1. package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
  2. package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +83 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1118 -661
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/manualEditingAvailability.test.ts +19 -0
  19. package/src/components/editor/manualEditingAvailability.ts +9 -5
  20. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  21. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  23. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  24. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  25. package/src/components/editor/useVolumeAutomation.ts +23 -2
  26. package/src/components/panels/VariablesPanel.tsx +5 -8
  27. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  29. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  30. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  31. package/src/contexts/FileManagerContext.tsx +0 -3
  32. package/src/hooks/appHotkeysDispatch.ts +256 -0
  33. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  34. package/src/hooks/domEditDeleteMembers.ts +19 -0
  35. package/src/hooks/useActiveComposition.ts +69 -0
  36. package/src/hooks/useAppHotkeys.test.ts +23 -1
  37. package/src/hooks/useAppHotkeys.ts +6 -239
  38. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  39. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  40. package/src/hooks/useDomEditCommits.ts +13 -15
  41. package/src/hooks/useDomEditSession.test.tsx +90 -1
  42. package/src/hooks/useDomEditSession.ts +31 -34
  43. package/src/hooks/useDomSelectionTypes.ts +7 -2
  44. package/src/hooks/useElementLifecycleOps.ts +0 -8
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -2
  47. package/src/hooks/useFileTree.test.tsx +214 -0
  48. package/src/hooks/useFileTree.ts +66 -24
  49. package/src/hooks/useGroupCommits.test.ts +98 -0
  50. package/src/hooks/useGroupCommits.ts +66 -9
  51. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  52. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  53. package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
  54. package/src/hooks/useSdkSession.ts +21 -3
  55. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  56. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  57. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  58. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  59. package/src/hooks/useTimelineEditing.ts +1 -0
  60. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  61. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  62. package/src/index.ts +10 -0
  63. package/src/player/components/Timeline.test.ts +0 -4
  64. package/src/player/components/Timeline.tsx +0 -1
  65. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  66. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  67. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  68. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  69. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  70. package/src/player/components/automationLaneData.test.ts +11 -3
  71. package/src/player/components/automationLaneData.ts +5 -4
  72. package/src/player/components/automationLaneGeometry.ts +15 -0
  73. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  74. package/src/player/components/timelineDragDrop.ts +9 -25
  75. package/src/player/components/timelineGapCommit.test.ts +96 -0
  76. package/src/player/components/timelineGapCommit.ts +56 -11
  77. package/src/player/components/timelineGaps.ts +2 -2
  78. package/src/player/components/timelineLayout.test.ts +0 -1
  79. package/src/player/components/timelineLayout.ts +1 -6
  80. package/src/player/components/timelineZones.test.ts +20 -1
  81. package/src/player/components/timelineZones.ts +10 -3
  82. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  83. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  84. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  85. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  86. package/src/player/hooks/useTimelinePlayer.ts +9 -4
  87. package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
  88. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  89. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  90. package/src/player/store/playerStore.test.ts +42 -0
  91. package/src/player/store/playerStore.ts +25 -28
  92. package/src/player/store/readinessSlice.ts +42 -0
  93. package/src/utils/razorSplitTransaction.test.ts +10 -0
  94. package/src/utils/razorSplitTransaction.ts +5 -0
  95. package/src/utils/sdkCutover.gate.test.ts +5 -4
  96. package/src/utils/sdkCutover.test.ts +93 -0
  97. package/src/utils/sdkCutover.ts +46 -25
  98. package/src/utils/sdkCutoverEligibility.ts +21 -0
  99. package/src/utils/sdkCutoverPolicy.test.ts +11 -2
  100. package/src/utils/sdkCutoverPolicy.ts +5 -3
  101. package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
  102. package/src/utils/sdkEditTransaction.ts +22 -1
  103. package/src/utils/sdkResolverShadow.ts +10 -1
  104. package/src/utils/studioHelpers.test.ts +18 -0
  105. package/src/utils/studioHelpers.ts +41 -5
  106. package/src/utils/studioUiPreferences.test.ts +14 -0
  107. package/src/utils/studioUiPreferences.ts +6 -0
  108. package/src/utils/studioUrlState.ts +15 -7
  109. package/src/utils/timelineInspector.ts +10 -0
  110. package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
  111. package/dist/assets/index-DCblmTX3.js +0 -441
  112. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability";
3
+ import { STUDIO_SDK_OPERATION_FAMILIES } from "../../utils/sdkCutoverPolicy";
3
4
 
4
5
  async function loadAvailabilityWithEnv(env: Record<string, string | undefined>) {
5
6
  vi.resetModules();
@@ -88,4 +89,22 @@ describe("manual editing availability", () => {
88
89
  });
89
90
  expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
90
91
  });
92
+
93
+ it("defaults SDK cutover on for every family", async () => {
94
+ const all = await loadAvailabilityWithEnv({});
95
+ expect(all.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
96
+ expect(all.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(STUDIO_SDK_OPERATION_FAMILIES));
97
+ });
98
+
99
+ it("honors the SDK cutover kill switches", async () => {
100
+ const off = await loadAvailabilityWithEnv({ VITE_STUDIO_SDK_CUTOVER_ENABLED: "false" });
101
+ expect(off.STUDIO_SDK_CUTOVER_ENABLED).toBe(false);
102
+ expect(off.STUDIO_SDK_CUTOVER_FAMILIES.size).toBe(0);
103
+
104
+ const subset = await loadAvailabilityWithEnv({
105
+ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,timing",
106
+ });
107
+ expect(subset.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
108
+ expect(subset.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(["dom", "timing"]));
109
+ });
91
110
  });
@@ -38,16 +38,20 @@ const runtimeEnv =
38
38
  : {};
39
39
  const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv;
40
40
 
41
- // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
42
- // instead of the server patch-element API. Default false; enable via
43
- // VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
41
+ // Stage 7 SDK cutover — routes Studio edits through SDK dispatch instead of the
42
+ // server patch/mutation routes. Default TRUE as of the flip release (spec:
43
+ // 2026-09-17-studio-sdk-cutover-flip-design.md). Kill switch:
44
+ // VITE_STUDIO_SDK_CUTOVER_ENABLED=false (works at runtime via __HF_STUDIO_ENV__).
44
45
  export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
45
46
  env,
46
47
  ["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
47
- false,
48
+ true,
48
49
  );
49
50
 
50
- /** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
51
+ /**
52
+ * Per-family selection. Unset = every family (the master alone enables all).
53
+ * Set `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` to RESTRICT to a subset.
54
+ */
51
55
  export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
52
56
  env,
53
57
  STUDIO_SDK_CUTOVER_ENABLED,
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { HfAutomation } from "@hyperframes/core/audio-automation";
3
- import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js";
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 cannot. */
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
- const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
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
- {/* The slider is disabled while a lane owns the level: a value set
217
- here would be overwritten by the envelope on the next tick. The
218
- toggle beside it carries the tooltip. */}
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
- void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
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
- function bind(dataAttributes: Record<string, string>) {
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(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
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
- JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
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
- JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
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(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
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(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
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 { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
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: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
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
- // On the master view (no activeCompPath) the panel targets the project's real
262
- // main composition — the first .html in the tree — not a hardcoded index.html
263
- // that may not exist. This same path is used for the persist write target (so
264
- // an edit never lands in a phantom index.html) AND the handoff render command.
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}