@hyperframes/studio 0.8.186 → 0.8.188

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/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-Dn5FAk2e.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-M8lEpS0Q.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-CESvLzz0.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -66847,7 +66847,7 @@ function useVolumeAutomation(element, currentTime, onSetAttributeQuiet, linkedSp
66847
66847
  if (!linkedSpeed) return writeOwn(edit);
66848
66848
  void linkedSpeed.commitAttribute(
66849
66849
  HF_AUDIO_AUTOMATION_ATTR2,
66850
- (raw2) => automationAttrValue(edit(readPanelAutomation(raw2, void 0))) || null,
66850
+ (raw) => automationAttrValue(edit(readPanelAutomation(raw, void 0))) || null,
66851
66851
  "Edit speed"
66852
66852
  );
66853
66853
  };
@@ -66862,10 +66862,7 @@ function useVolumeAutomation(element, currentTime, onSetAttributeQuiet, linkedSp
66862
66862
  onCommitAt: (v) => writeLane((a) => withPointAt(a, target, clipTimeSec, v))
66863
66863
  };
66864
66864
  };
66865
- const raw = element.dataAttributes?.["volume"];
66866
- const parsed = raw ? Number(raw) : 1;
66867
- const current = Number.isFinite(parsed) ? parsed : 1;
66868
- const volume = laneBinding(VOLUME_TARGET3, current, writeOwn);
66865
+ const volume = laneBinding(VOLUME_TARGET3, 1, writeOwn);
66869
66866
  const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
66870
66867
  const rate = laneBinding(
66871
66868
  RATE_TARGET2,
@@ -67048,8 +67045,8 @@ function FlatMediaSection({
67048
67045
  const isImage = element.tagName === "img";
67049
67046
  const isVisualMedia = isVideo || isImage;
67050
67047
  const el = element.element;
67051
- const volume = volumeAutomated && automatedVolumeValue !== void 0 ? automatedVolumeValue : parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
67052
- const volumeFaderPosition = audioGainToFaderPosition3(volume);
67048
+ const gain = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
67049
+ const volume = volumeAutomated && automatedVolumeValue !== void 0 ? automatedVolumeValue : 1;
67053
67050
  const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
67054
67051
  const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
67055
67052
  const playbackRate = rate?.automated && rate.automatedValue !== void 0 ? rate.automatedValue : constantRate;
@@ -67183,6 +67180,25 @@ function FlatMediaSection({
67183
67180
  ] })
67184
67181
  ] }),
67185
67182
  (isVideo || isAudio) && /* @__PURE__ */ jsxs134(Fragment48, { children: [
67183
+ /* @__PURE__ */ jsx176(
67184
+ FlatSlider,
67185
+ {
67186
+ label: "Gain",
67187
+ value: audioGainToFaderPosition3(gain),
67188
+ min: AUDIO_GAIN_FADER_MIN3,
67189
+ max: AUDIO_GAIN_FADER_MAX3,
67190
+ tier: gain === 1 ? "default" : "explicitCustom",
67191
+ displayValue: audioGainToText3(gain),
67192
+ centerTick: true,
67193
+ onCommit: (next) => void onSetAttribute("volume", formatAudioGain4(audioFaderPositionToGain3(next))),
67194
+ onCommitText: (text) => {
67195
+ const next = parseGainInput(text);
67196
+ if (next === null) return false;
67197
+ void onSetAttribute("volume", formatAudioGain4(next));
67198
+ return true;
67199
+ }
67200
+ }
67201
+ ),
67186
67202
  /* @__PURE__ */ jsxs134(
67187
67203
  "div",
67188
67204
  {
@@ -67193,25 +67209,17 @@ function FlatMediaSection({
67193
67209
  FlatSlider,
67194
67210
  {
67195
67211
  label: "Volume",
67196
- value: volumeFaderPosition,
67212
+ value: audioGainToFaderPosition3(volume),
67197
67213
  min: AUDIO_GAIN_FADER_MIN3,
67198
67214
  max: AUDIO_GAIN_FADER_MAX3,
67199
67215
  tier: volume === 1 ? "default" : "explicitCustom",
67200
67216
  displayValue: audioGainToText3(volume),
67201
67217
  centerTick: true,
67202
- onCommit: (next) => {
67203
- const gain = audioFaderPositionToGain3(next);
67204
- if (volumeAutomated) {
67205
- onCommitVolumeAt?.(gain);
67206
- } else {
67207
- void onSetAttribute("volume", formatAudioGain4(gain));
67208
- }
67209
- },
67218
+ onCommit: (next) => onCommitVolumeAt?.(audioFaderPositionToGain3(next)),
67210
67219
  onCommitText: (text) => {
67211
- const gain = parseGainInput(text);
67212
- if (gain === null) return false;
67213
- if (volumeAutomated) onCommitVolumeAt?.(gain);
67214
- else void onSetAttribute("volume", formatAudioGain4(gain));
67220
+ const next = parseGainInput(text);
67221
+ if (next === null) return false;
67222
+ onCommitVolumeAt?.(next);
67215
67223
  return true;
67216
67224
  }
67217
67225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.186",
3
+ "version": "0.8.188",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,12 +59,12 @@
59
59
  "dockview-react": "8.3.1",
60
60
  "gsap": "^3.13.0",
61
61
  "mediabunny": "^1.45.3",
62
- "@hyperframes/core": "0.8.186",
63
- "@hyperframes/player": "0.8.186",
64
- "@hyperframes/shader-transitions": "0.8.186",
65
- "@hyperframes/studio-server": "0.8.186",
66
- "@hyperframes/sdk": "0.8.186",
67
- "@hyperframes/parsers": "0.8.186"
62
+ "@hyperframes/player": "0.8.188",
63
+ "@hyperframes/core": "0.8.188",
64
+ "@hyperframes/shader-transitions": "0.8.188",
65
+ "@hyperframes/studio-server": "0.8.188",
66
+ "@hyperframes/parsers": "0.8.188",
67
+ "@hyperframes/sdk": "0.8.188"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.186"
84
+ "@hyperframes/producer": "0.8.188"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -9,6 +9,7 @@ import type { DomEditSelection } from "./domEditing";
9
9
  import type { PatchOperation } from "../../utils/sourcePatcher";
10
10
  import { formatTimingValue } from "./propertyPanelHelpers";
11
11
  import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
12
+ import { audioGainToFaderPosition } from "@hyperframes/core/audio-gain";
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
@@ -163,7 +164,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
163
164
  act(() => root.unmount());
164
165
  });
165
166
 
166
- it("commits +12 dB of boost from the upper half of the volume fader", () => {
167
+ it("commits +12 dB of boost from the upper half of the gain fader", () => {
167
168
  const onSetAttribute = vi.fn();
168
169
  const element = makeVideoElement({ dataAttributes: { volume: "1" } });
169
170
  const host = document.createElement("div");
@@ -182,7 +183,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
182
183
  />,
183
184
  );
184
185
  });
185
- const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
186
+ const volumeTrack = host.querySelector('[data-flat-slider-track="true"][aria-label="Gain"]')!;
186
187
  Object.defineProperty(volumeTrack, "getBoundingClientRect", {
187
188
  value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
188
189
  });
@@ -197,10 +198,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
197
198
  act(() => root.unmount());
198
199
  });
199
200
 
200
- it("writes through the envelope instead of the attribute once volume is automated", () => {
201
- const onSetAttribute = vi.fn();
202
- const onCommitVolumeAt = vi.fn();
203
- const element = makeVideoElement({ dataAttributes: { volume: "1" } });
201
+ it("shows the clip's gain and its lane's level as two sliders", () => {
202
+ const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
204
203
  const host = document.createElement("div");
205
204
  document.body.append(host);
206
205
  const root = createRoot(host);
@@ -211,28 +210,62 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
211
210
  element={element}
212
211
  styles={{}}
213
212
  onSetStyle={vi.fn()}
214
- onSetAttribute={onSetAttribute}
213
+ onSetAttribute={vi.fn()}
215
214
  onSetHtmlAttribute={vi.fn()}
216
215
  onSetAttributeBatch={vi.fn()}
217
216
  volumeAutomated
218
- onCommitVolumeAt={onCommitVolumeAt}
217
+ automatedVolumeValue={0.25}
219
218
  />,
220
219
  );
221
220
  });
222
- const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
223
- Object.defineProperty(volumeTrack, "getBoundingClientRect", {
224
- value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
225
- });
226
- act(() => {
227
- volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
228
- volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
229
- });
230
- expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
231
- expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
232
- expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
221
+ const [gain, volume] = host.querySelectorAll('[data-flat-slider-track="true"]');
222
+ expect(labelsOf(host).slice(0, 2)).toEqual(["Gain", "Volume"]);
223
+ expect(gain?.getAttribute("aria-valuenow")).toBe(String(audioGainToFaderPosition(0.5)));
224
+ expect(volume?.getAttribute("aria-valuenow")).toBe(String(audioGainToFaderPosition(0.25)));
233
225
  act(() => root.unmount());
234
226
  });
235
227
 
228
+ it.each([true, false])(
229
+ "writes Volume through the lane, never data-volume (automated: %s)",
230
+ (automated) => {
231
+ const onSetAttribute = vi.fn();
232
+ const onCommitVolumeAt = vi.fn();
233
+ const element = makeVideoElement({ dataAttributes: { volume: "1" } });
234
+ const host = document.createElement("div");
235
+ document.body.append(host);
236
+ const root = createRoot(host);
237
+ act(() => {
238
+ root.render(
239
+ <FlatMediaSection
240
+ projectDir={null}
241
+ element={element}
242
+ styles={{}}
243
+ onSetStyle={vi.fn()}
244
+ onSetAttribute={onSetAttribute}
245
+ onSetHtmlAttribute={vi.fn()}
246
+ onSetAttributeBatch={vi.fn()}
247
+ volumeAutomated={automated}
248
+ onCommitVolumeAt={onCommitVolumeAt}
249
+ />,
250
+ );
251
+ });
252
+ const volumeTrack = host.querySelector(
253
+ '[data-flat-slider-track="true"][aria-label="Volume"]',
254
+ )!;
255
+ Object.defineProperty(volumeTrack, "getBoundingClientRect", {
256
+ value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
257
+ });
258
+ act(() => {
259
+ volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
260
+ volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
261
+ });
262
+ expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
263
+ expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
264
+ expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
265
+ act(() => root.unmount());
266
+ },
267
+ );
268
+
236
269
  it("commits a new rate value on slider track pointerdown", () => {
237
270
  const onSetAttribute = vi.fn();
238
271
  const element = makeVideoElement();
@@ -252,7 +285,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
252
285
  />,
253
286
  );
254
287
  });
255
- const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1];
288
+ const rateTrack = host.querySelector('[data-flat-slider-track="true"][aria-label="Speed"]')!;
256
289
  Object.defineProperty(rateTrack, "getBoundingClientRect", {
257
290
  value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
258
291
  });
@@ -284,7 +317,9 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
284
317
  />,
285
318
  );
286
319
  });
287
- const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2];
320
+ const mediaStartTrack = host.querySelector(
321
+ '[data-flat-slider-track="true"][aria-label="Media start"]',
322
+ )!;
288
323
  Object.defineProperty(mediaStartTrack, "getBoundingClientRect", {
289
324
  value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
290
325
  });
@@ -465,7 +500,7 @@ describe("FlatMediaSection — audio clips", () => {
465
500
  it("shows Fade in / Fade out rows for audio, and for video only when it carries audio", () => {
466
501
  const audio = renderWithRate(makeAudioElement());
467
502
  expect(labelsOf(audio.host)).toEqual(
468
- expect.arrayContaining(["Volume", "Speed", "Media start", "Fade in", "Fade out"]),
503
+ expect.arrayContaining(["Gain", "Volume", "Speed", "Media start", "Fade in", "Fade out"]),
469
504
  );
470
505
  act(() => audio.root.unmount());
471
506
 
@@ -64,7 +64,7 @@ export function FlatMediaSection({
64
64
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
65
65
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
66
66
  onSetAttributeBatch: CommitDomAttributeBatch;
67
- /** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
67
+ /** A volume lane in the timeline drives the level; the Volume slider writes a keyframe. */
68
68
  volumeAutomated?: boolean;
69
69
  onAutomateVolume?: () => void;
70
70
  onRemoveVolumeAutomation?: () => void;
@@ -88,14 +88,10 @@ export function FlatMediaSection({
88
88
  const isVisualMedia = isVideo || isImage;
89
89
  const el = element.element;
90
90
 
91
- // While a lane owns the level, the envelope's live value at the playhead is
92
- // what the slider must show — the static attribute is only what the engine
93
- // falls back to outside automation.
94
- const volume =
95
- volumeAutomated && automatedVolumeValue !== undefined
96
- ? automatedVolumeValue
97
- : (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
98
- const volumeFaderPosition = audioGainToFaderPosition(volume);
91
+ // Gain is `data-volume`, ahead of the effects; Volume is the lane's level at the
92
+ // playhead, unity without one. What plays is gain times level, as in an editor.
93
+ const gain = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
94
+ const volume = volumeAutomated && automatedVolumeValue !== undefined ? automatedVolumeValue : 1;
99
95
  const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
100
96
  const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
101
97
  const playbackRate =
@@ -246,9 +242,25 @@ export function FlatMediaSection({
246
242
  )}
247
243
  {(isVideo || isAudio) && (
248
244
  <>
249
- {/* While a lane owns the level, a commit writes a keyframe at the
250
- playhead instead of the plain attribute — same slider, same
251
- gesture, the write just goes through the envelope. */}
245
+ <FlatSlider
246
+ label="Gain"
247
+ value={audioGainToFaderPosition(gain)}
248
+ min={AUDIO_GAIN_FADER_MIN}
249
+ max={AUDIO_GAIN_FADER_MAX}
250
+ tier={gain === 1 ? "default" : "explicitCustom"}
251
+ displayValue={audioGainToText(gain)}
252
+ centerTick
253
+ onCommit={(next) =>
254
+ void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
255
+ }
256
+ onCommitText={(text) => {
257
+ const next = parseGainInput(text);
258
+ if (next === null) return false;
259
+ void onSetAttribute("volume", formatAudioGain(next));
260
+ return true;
261
+ }}
262
+ />
263
+ {/* A commit writes a keyframe at the playhead, starting a lane when there is none. */}
252
264
  <div
253
265
  className="hf-volume-row flex items-center gap-1"
254
266
  data-volume-automated={volumeAutomated ? "" : undefined}
@@ -256,25 +268,17 @@ export function FlatMediaSection({
256
268
  <div className="min-w-0 flex-1">
257
269
  <FlatSlider
258
270
  label="Volume"
259
- value={volumeFaderPosition}
271
+ value={audioGainToFaderPosition(volume)}
260
272
  min={AUDIO_GAIN_FADER_MIN}
261
273
  max={AUDIO_GAIN_FADER_MAX}
262
274
  tier={volume === 1 ? "default" : "explicitCustom"}
263
275
  displayValue={audioGainToText(volume)}
264
276
  centerTick
265
- onCommit={(next) => {
266
- const gain = audioFaderPositionToGain(next);
267
- if (volumeAutomated) {
268
- onCommitVolumeAt?.(gain);
269
- } else {
270
- void onSetAttribute("volume", formatAudioGain(gain));
271
- }
272
- }}
277
+ onCommit={(next) => onCommitVolumeAt?.(audioFaderPositionToGain(next))}
273
278
  onCommitText={(text) => {
274
- const gain = parseGainInput(text);
275
- if (gain === null) return false;
276
- if (volumeAutomated) onCommitVolumeAt?.(gain);
277
- else void onSetAttribute("volume", formatAudioGain(gain));
279
+ const next = parseGainInput(text);
280
+ if (next === null) return false;
281
+ onCommitVolumeAt?.(next);
278
282
  return true;
279
283
  }}
280
284
  />
@@ -59,22 +59,16 @@ describe("useVolumeAutomation", () => {
59
59
  expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
60
60
  });
61
61
 
62
- it("seeds a new lane at the level the slider already shows", () => {
62
+ it("seeds a new lane at unity, which data-volume then multiplies", () => {
63
63
  // Automating a track must not change how loud it is.
64
64
  const { binding, onSetAttributeQuiet } = bind({ volume: "0.55" });
65
65
  act(() => binding.onAutomateVolume());
66
66
  expect(onSetAttributeQuiet).toHaveBeenCalledWith(
67
67
  "data-automation",
68
- JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
68
+ JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }] }),
69
69
  );
70
70
  });
71
71
 
72
- it("treats a missing data-volume as unity", () => {
73
- const { binding, onSetAttributeQuiet } = bind({});
74
- act(() => binding.onAutomateVolume());
75
- expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
76
- });
77
-
78
72
  it("keeps FX lanes when adding the volume one", () => {
79
73
  const automation = JSON.stringify({
80
74
  version: 1,
@@ -108,13 +108,8 @@ export function useVolumeAutomation(
108
108
  onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
109
109
  };
110
110
  };
111
- // `??` alone would let an empty `data-volume` through as Number("") === 0, so
112
- // automating the track would seed its lane at silence. The engine reads the same
113
- // empty value as unity.
114
- const raw = element.dataAttributes?.["volume"];
115
- const parsed = raw ? Number(raw) : 1;
116
- const current = Number.isFinite(parsed) ? parsed : 1;
117
- const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
111
+ // Unity: `data-volume` multiplies the lane, so seeding it there would play it twice.
112
+ const volume = laneBinding(VOLUME_TARGET, 1, writeOwn);
118
113
  const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
119
114
  const rate = laneBinding(
120
115
  RATE_TARGET,