fix(studio): make the volume fader tell the truth about the gain it writes (#3305)

* fix(studio): make the volume fader tell the truth about the gain it writes

The fader travels in dB, so its stops are irrational values; serializing them
through the generic two-decimal numeric formatter collapsed the bottom quarter
of its travel onto "0" — a hard mute — and made the knob jump on release
everywhere below unity. Both panels now use the exact serializer, which
round-trips every integer stop back to itself.

Raise the volume automation lane to the same ceiling the fader reaches.
Clamping the lane at unity meant automating a boosted clip silently discarded
the boost, and the panel disables the fader while a lane owns the level, so
there was no way back. This rescales the lane's vertical axis: unity now sits
a quarter of the way up rather than at the top.

Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute —
they replace the authored gain rather than scaling it — so a clip carrying both
plays at whatever the tween names, and the fader gives no sign of it. The rule
reuses the tween detector the sibling lane/tween rule already has.

* fix(lint): treat a missing data-volume as unity, not as silence

readAttr returns null when the attribute is absent, and Number(null) is 0 —
finite, and not 1 — so a clip carrying NO data-volume cleared both filters and
was reported as authored at silence. Both halves of that were false: absent
means unity everywhere else in the runtime.

It fired on exactly the case the rule exists to bless. The docs this PR edits
say data-volume is the baseline for elements no tween touches, so a tweened
clip is expected not to carry one — the common audio fade. A warning does not
fail check, but an agent reading the fixHint would have written a gain to
correct a level that was never wrong.
This commit is contained in:
Miguel Ángel
2026-08-19 18:08:23 -04:00
committed by GitHub
parent b3c43e2480
commit 228eabd43f
15 changed files with 243 additions and 108 deletions
+5 -2
View File
@@ -18,6 +18,7 @@ import {
type HfAutomationLane, type HfAutomationLane,
} from "./audioAutomation.js"; } from "./audioAutomation.js";
import { mintAudioFxNodeId, parseAudioFxChain, type HfAudioFxChain } from "./audioFx.js"; import { mintAudioFxNodeId, parseAudioFxChain, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";
const chain: HfAudioFxChain = { const chain: HfAudioFxChain = {
version: 1, version: 1,
@@ -116,7 +117,7 @@ describe("normalisation", () => {
]); ]);
}); });
it("clamps volume into 0..1 at parse time", () => { it("clamps volume into the authoring gain range at parse time", () => {
const parsed = parseAutomation( const parsed = parseAutomation(
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
@@ -131,7 +132,9 @@ describe("normalisation", () => {
], ],
}), }),
); );
expect(parsed.lanes[0]!.points.map((p) => p.v)).toEqual([1, 0]); // The lane shares the fader's ceiling. Clamping it at unity discarded the
// boost of any clip authored above 0 dB the moment it was automated.
expect(parsed.lanes[0]!.points.map((p) => p.v)).toEqual([MAX_AUDIO_GAIN, 0]);
}); });
it("refuses malformed input instead of silently losing an envelope", () => { it("refuses malformed input instead of silently losing an envelope", () => {
+11 -3
View File
@@ -12,6 +12,7 @@
*/ */
import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js"; import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";
export const HF_AUDIO_AUTOMATION_ATTR = "data-automation"; export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";
@@ -135,8 +136,9 @@ export const PRESET_RANGE: AutomationRange = {
/** /**
* The value range a lane is drawn and clamped against. * The value range a lane is drawn and clamped against.
* *
* Volume is linear 0..1, matching `data-volume` and the existing volume * Volume is linear over the full authoring gain range, matching `data-volume`
* envelope machinery — no dB conversion enters the volume path. Everything * and the existing volume envelope machinery — no dB conversion enters the
* volume path. Everything
* else is read from the effect registry, so a lane can never offer a value the * else is read from the effect registry, so a lane can never offer a value the
* renderer would reject, and the log-scaled knobs sweep the way a DAW's do. * renderer would reject, and the log-scaled knobs sweep the way a DAW's do.
*/ */
@@ -151,9 +153,15 @@ export interface AutomationRange {
default: number; default: number;
} }
/**
* One ceiling for the fader, the lane, the preview transport and the render
* mixer. Capping the lane at unity while the fader reached +12 dB made
* automating a boosted clip silently discard the boost — and the panel
* disables the fader while a lane owns it, so there was no way back.
*/
export const VOLUME_RANGE: AutomationRange = { export const VOLUME_RANGE: AutomationRange = {
min: 0, min: 0,
max: 1, max: MAX_AUDIO_GAIN,
step: 0.01, step: 0.01,
unit: "", unit: "",
label: "Volume", label: "Volume",
+55
View File
@@ -429,6 +429,61 @@ describe("media_variable_src_no_fallback", () => {
}); });
}); });
describe("audio_volume_tween_overrides_gain", () => {
const withScript = (audioAttrs: string, script: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<audio id="bgm" src="a.wav" data-start="0" data-duration="10" ${audioAttrs}></audio>
</div>
<script>${script}</script>
</body></html>`;
it("warns that the tween's values win over an authored gain", async () => {
const res = await lintHyperframeHtml(
withScript(`data-volume="1.949845"`, `tl.fromTo("#bgm", { volume: 0 }, { volume: 1 });`),
);
const finding = res.findings.find((f) => f.code === "audio_volume_tween_overrides_gain");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("bgm");
expect(finding?.message).toMatch(/5\.8 dB/);
});
it("warns about an attenuation the tween overrides, not just a boost", async () => {
const res = await lintHyperframeHtml(
withScript(`data-volume="0.3"`, `tl.to("#bgm", { volume: 1 });`),
);
expect(res.findings.some((f) => f.code === "audio_volume_tween_overrides_gain")).toBe(true);
});
it("stays quiet on the fade the docs recommend, which carries no data-volume", async () => {
// `Number(null)` is 0 — finite and not 1 — so a clip with NO `data-volume`
// was reported as authored at silence. Both halves were false, and this is
// the shape the docs recommend for a tweened clip: the baseline attribute is
// for elements no tween touches. The rule fired on exactly the common fade.
const res = await lintHyperframeHtml(
withScript("", `tl.fromTo("#bgm", { volume: 0 }, { volume: 1 });`),
);
expect(res.findings.some((f) => f.code === "audio_volume_tween_overrides_gain")).toBe(false);
});
it("stays quiet at unity, without a tween, or when a lane already owns the level", async () => {
const unity = await lintHyperframeHtml(
withScript(`data-volume="1"`, `tl.to("#bgm", { volume: 0 });`),
);
const noTween = await lintHyperframeHtml(
withScript(`data-volume="2"`, `tl.to("#bgm", { x: 1 });`),
);
const lane = await lintHyperframeHtml(
withScript(
`data-volume="2" data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}'`,
`tl.to("#bgm", { volume: 0 });`,
),
);
for (const res of [unity, noTween, lane]) {
expect(res.findings.some((f) => f.code === "audio_volume_tween_overrides_gain")).toBe(false);
}
});
});
describe("audio_volume_double_automation", () => { describe("audio_volume_double_automation", () => {
const withScript = (audioAttrs: string, script: string) => `<!DOCTYPE html><html><body> const withScript = (audioAttrs: string, script: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10"> <div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
+47
View File
@@ -629,8 +629,55 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
// audio_volume_double_automation // audio_volume_double_automation
findVolumeDoubleAutomationFindings, findVolumeDoubleAutomationFindings,
// audio_volume_tween_overrides_gain
findVolumeTweenOverridesGainFindings,
]; ];
/**
* Tween values on `volume` are ABSOLUTE gains, not multipliers of the authored
* `data-volume`: the probed keyframes replace that baseline outright, in
* preview and in the render alike. So a clip carrying both plays at whatever
* the tween names — `{ volume: 1 }` is 0 dB even on a clip the fader says is
* at +5.8 dB, and Studio's fader gives no sign of it.
*
* Silent before this rule, and easier to hit since the fader gained +12 dB of
* boost and `normalize-audio` writes into the very same attribute.
*/
function findVolumeTweenOverridesGainFindings(ctx: LintContext): HyperframeLintFinding[] {
const boosted = ctx.tags
.filter((tag) => isMediaTag(tag.name))
// Absent means unity, as it does everywhere else. Reading it raw gave
// `Number(null)` — 0, finite and not 1, so a clip with NO `data-volume`
// cleared both filters and was reported as authored at silence. That is the
// shape the docs recommend for a tweened clip, so the rule fired on exactly
// the case it exists to bless.
.map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") }))
.filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1)
// A lane already has its own rule, and it wins over both of these.
.filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation"))
.map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") }))
.filter((entry): entry is typeof entry & { id: string } => Boolean(entry.id));
if (boosted.length === 0) return [];
const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
const findings: HyperframeLintFinding[] = [];
for (const { tag, id, volume } of boosted) {
if (!tweensVolumeInSameCall(script, id)) continue;
const db = volume > 0 ? `${(20 * Math.log10(volume)).toFixed(1)} dB` : "silence";
findings.push({
code: "audio_volume_tween_overrides_gain",
severity: "warning",
message: `#${id} has data-volume="${volume}" (${db}) and a GSAP tween on \`volume\`. Tween values are absolute — they REPLACE this gain rather than scale it — so wherever the tween names a value the clip plays at that value, not at ${db}.`,
elementId: id,
fixHint:
"Write the tween's targets in the same absolute gain (e.g. `volume: 1.95`, not `volume: 1`), or reset data-volume to 1 and let the tween carry the level on its own.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}
/** /**
* A track can have its volume shaped by an automation lane or by a GSAP tween, * A track can have its volume shaped by an automation lane or by a GSAP tween,
* and only the lane is heard: the runtime reads `data-automation` first and * and only the lane is heard: the runtime reads `data-automation` first and
@@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => {
}); });
describe("FlatMediaSection — volume/rate/media-start", () => { describe("FlatMediaSection — volume/rate/media-start", () => {
it("renders volume at its stored percentage and commits a new value on drag", () => { it("renders unity volume as neutral 0 dB at the slider midpoint", () => {
const onSetAttribute = vi.fn(); const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div"); const host = document.createElement("div");
document.body.append(host); document.body.append(host);
const root = createRoot(host); const root = createRoot(host);
@@ -149,48 +149,16 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
/>, />,
); );
}); });
expect(host.textContent).toContain("50%"); expect(host.textContent).toContain("0.0 dB");
expect(
host.querySelector('[data-flat-slider-track="true"]')?.getAttribute("aria-valuenow"),
).toBe("0");
act(() => root.unmount()); act(() => root.unmount());
}); });
it("refuses to commit from the percent slider on a clip authored above unity", () => { it("commits +12 dB of boost from the upper half of the volume fader", () => {
// The control tops out at 100%, so any commit from it would cap a boosted
// clip and silently drop up to 12 dB that now genuinely renders. Held until
// the dB fader that can represent these levels replaces it.
const onSetAttribute = vi.fn(); const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "1.949845" } }); const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
Object.defineProperty(volumeTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
expect(onSetAttribute).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("commits a new volume value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
const host = document.createElement("div"); const host = document.createElement("div");
document.body.append(host); document.body.append(host);
const root = createRoot(host); const root = createRoot(host);
@@ -211,11 +179,13 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
}); });
act(() => { act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
}); });
// starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" // Six decimals, not two: at two the bottom of the dB fader collapses onto
expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); // "0" (a hard mute) and every stop below unity writes a value the knob then
// jumps away from.
expect(onSetAttribute).toHaveBeenCalledWith("volume", "3.981072");
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -13,6 +13,14 @@ import {
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle"; import { FlatToggle } from "./propertyPanelFlatToggle";
import { AutomationToggle } from "./propertyPanelFxControls"; import { AutomationToggle } from "./propertyPanelFxControls";
import {
AUDIO_GAIN_FADER_MAX,
AUDIO_GAIN_FADER_MIN,
audioFaderPositionToGain,
formatAudioGain,
audioGainToFaderPosition,
audioGainToText,
} from "@hyperframes/core/audio-gain";
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export function FlatMediaSection({ export function FlatMediaSection({
@@ -54,7 +62,7 @@ export function FlatMediaSection({
const el = element.element; const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100); const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart = const mediaStart =
Number.parseFloat( Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
@@ -207,13 +215,7 @@ export function FlatMediaSection({
<> <>
{/* The slider is disabled while a lane owns the level: a value set {/* The slider is disabled while a lane owns the level: a value set
here would be overwritten by the envelope on the next tick. The here would be overwritten by the envelope on the next tick. The
toggle beside it carries the tooltip. toggle beside it carries the tooltip. */}
It is also disabled above unity, for the same reason in a
different guise — this control tops out at 100%, so committing
from it would silently cap a boosted clip and drop up to 12 dB
that now genuinely renders. A hold, not a fix: the dB fader that
can represent these levels replaces this control outright. */}
<div <div
className="hf-volume-row flex items-center gap-1" className="hf-volume-row flex items-center gap-1"
data-volume-automated={volumeAutomated ? "" : undefined} data-volume-automated={volumeAutomated ? "" : undefined}
@@ -221,13 +223,16 @@ export function FlatMediaSection({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<FlatSlider <FlatSlider
label="Volume" label="Volume"
value={volumePercent} value={volumeFaderPosition}
min={0} min={AUDIO_GAIN_FADER_MIN}
max={100} max={AUDIO_GAIN_FADER_MAX}
tier={volumePercent === 100 ? "default" : "explicitCustom"} tier={volume === 1 ? "default" : "explicitCustom"}
displayValue={`${volumePercent}%`} displayValue={audioGainToText(volume)}
disabled={volumeAutomated || volume > 1} disabled={volumeAutomated}
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))} centerTick
onCommit={(next) =>
void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
}
/> />
</div> </div>
<AutomationToggle <AutomationToggle
@@ -13,6 +13,14 @@ import {
} from "./propertyPanelHelpers"; } from "./propertyPanelHelpers";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives"; import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import {
AUDIO_GAIN_FADER_MAX,
AUDIO_GAIN_FADER_MIN,
audioFaderPositionToGain,
formatAudioGain,
audioGainToFaderPosition,
audioGainToText,
} from "@hyperframes/core/audio-gain";
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export function MediaSection({ export function MediaSection({
@@ -47,7 +55,7 @@ export function MediaSection({
const el = element.element; const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100); const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart = const mediaStart =
Number.parseFloat( Number.parseFloat(
@@ -246,23 +254,18 @@ export function MediaSection({
{(isVideo || isAudio) && ( {(isVideo || isAudio) && (
<> <>
{/* Held above unity: this control tops out at 100%, so committing
from it would silently cap a boosted clip and drop up to 12 dB
that now genuinely renders. The dB fader that can represent
these levels replaces this control outright. */}
<div className="grid min-w-0 gap-1.5"> <div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Volume</span> <span className={LABEL}>Volume</span>
<SliderControl <SliderControl
trackName="Volume" trackName="Volume"
value={volumePercent} value={volumeFaderPosition}
min={0} min={AUDIO_GAIN_FADER_MIN}
max={100} max={AUDIO_GAIN_FADER_MAX}
step={1} step={1}
disabled={volume > 1} displayValue={audioGainToText(volume)}
displayValue={`${volumePercent}%`} formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))}
formatDisplayValue={(next) => `${Math.round(next)}%`}
onCommit={(next) => { onCommit={(next) => {
void onSetAttribute("volume", formatNumericValue(next / 100)); void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)));
}} }}
/> />
</div> </div>
@@ -261,9 +261,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 5, t0: 5,
t1: 7, t1: 7,
// Full height: everything the paste landed is selected, so Delete straight // Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press. // after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0, v0: 0,
v1: 1, v1: VOLUME_RANGE.max,
}); });
}); });
@@ -300,9 +301,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4, t0: 4,
t1: 6, t1: 6,
// Full height: everything the paste landed is selected, so Delete straight // Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press. // after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0, v0: 0,
v1: 1, v1: VOLUME_RANGE.max,
}); });
}); });
@@ -338,9 +340,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4, t0: 4,
t1: 6, t1: 6,
// Full height: everything the paste landed is selected, so Delete straight // Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press. // after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0, v0: 0,
v1: 1, v1: VOLUME_RANGE.max,
}); });
}); });
@@ -6,6 +6,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
import { PAD_X } from "./automationLaneGeometry"; import { PAD_X } from "./automationLaneGeometry";
import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import { import {
normalizeAutomation, normalizeAutomation,
resolveAutomationRange, resolveAutomationRange,
@@ -126,6 +127,14 @@ const ramp: HfAutomation = {
], ],
}; };
/**
* These are geometry and gesture tests, not ceiling tests: a plain 0..1 axis
* keeps every pointer coordinate below readable. `VOLUME_RANGE` itself reaches
* the +12 dB authoring ceiling covered by its own case at the end of this
* file, and by audioAutomation.test.ts.
*/
const UNIT_RANGE = { ...VOLUME_RANGE, max: 1 };
function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> = {}) { function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> = {}) {
const target = over.target ?? "volume"; const target = over.target ?? "volume";
return { return {
@@ -140,7 +149,9 @@ function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> =
onCommit: vi.fn(), onCommit: vi.fn(),
...over, ...over,
target, target,
range: over.range ?? resolveAutomationRange(target, chain) ?? VOLUME_RANGE, range:
over.range ??
(target === "volume" ? UNIT_RANGE : (resolveAutomationRange(target, chain) ?? VOLUME_RANGE)),
}; };
} }
@@ -934,7 +945,25 @@ describe("TimelineAutomationLane modifiers", () => {
input?.dispatchEvent(new Event("focusout", { bubbles: true })); input?.dispatchEvent(new Event("focusout", { bubbles: true }));
}); });
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined; const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max); expect(committed?.lanes[0]?.points[0]?.v).toBe(UNIT_RANGE.max);
});
it("reaches the authoring ceiling on the real volume range", () => {
const { container, svg, props } = mount(ramp, { range: VOLUME_RANGE });
// On the real range unity sits a quarter of the way up, not at the top.
fire(svg, "dblclick", at(0, 1 / MAX_AUDIO_GAIN));
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
input?.dispatchEvent(new Event("focusout", { bubbles: true }));
});
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
// A boosted clip seeds its lane above unity; clamping the lane at 1 while
// the fader reached +12 dB silently threw the boost away.
expect(committed?.lanes[0]?.points[0]?.v).toBeCloseTo(MAX_AUDIO_GAIN, 6);
}); });
}); });
@@ -8,6 +8,9 @@ import {
import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
/** The fixture's values double as unit positions, so pin it to a 0..1 axis. */
const UNIT_RANGE = { ...VOLUME_RANGE, max: 1 };
const duck: HfAutomationLane = { const duck: HfAutomationLane = {
target: "volume", target: "volume",
points: [ points: [
@@ -21,7 +24,7 @@ beforeEach(clearAutomationClipboard);
describe("automation clipboard", () => { describe("automation clipboard", () => {
it("copies the range rebased to zero", () => { it("copies the range rebased to zero", () => {
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
const entry = readClipboard("project-a"); const entry = readClipboard("project-a");
expect(entry?.span).toBe(2); expect(entry?.span).toBe(2);
expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]); expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]);
@@ -29,11 +32,11 @@ describe("automation clipboard", () => {
}); });
it("pastes at a new time on the same axis unchanged", () => { it("pastes at a new time on the same axis unchanged", () => {
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
const entry = readClipboard("project-a"); const entry = readClipboard("project-a");
expect(entry).not.toBeNull(); expect(entry).not.toBeNull();
if (!entry) return; if (!entry) return;
const pts = pastePoints(entry, VOLUME_RANGE, 10); const pts = pastePoints(entry, UNIT_RANGE, 10);
expect(pts.map((p) => p.t)).toEqual([10, 11, 12]); expect(pts.map((p) => p.t)).toEqual([10, 11, 12]);
expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]); expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]);
}); });
@@ -51,7 +54,7 @@ describe("automation clipboard", () => {
expect(frequency).toBeTruthy(); expect(frequency).toBeTruthy();
if (!frequency) return; if (!frequency) return;
expect(frequency.scale).toBe("log"); expect(frequency.scale).toBe("log");
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
const entry = readClipboard("project-a"); const entry = readClipboard("project-a");
if (!entry) return; if (!entry) return;
const pts = pastePoints(entry, frequency, 0); const pts = pastePoints(entry, frequency, 0);
@@ -74,12 +77,12 @@ describe("automation clipboard", () => {
}); });
it("does not hand a range copied in one project to another", () => { it("does not hand a range copied in one project to another", () => {
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
expect(readClipboard("project-b")).toBeNull(); expect(readClipboard("project-b")).toBeNull();
}); });
it("drops the entry for good once another project has read past it", () => { it("drops the entry for good once another project has read past it", () => {
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
readClipboard("project-b"); readClipboard("project-b");
// Not merely hidden from B: switching back must not resurrect a shape whose // Not merely hidden from B: switching back must not resurrect a shape whose
// source clip may have been edited or deleted while the project was closed. // source clip may have been edited or deleted while the project was closed.
@@ -87,7 +90,7 @@ describe("automation clipboard", () => {
}); });
it("keeps serving the entry inside its own project", () => { it("keeps serving the entry inside its own project", () => {
copyRange("project-a", duck, VOLUME_RANGE, 2, 4); copyRange("project-a", duck, UNIT_RANGE, 2, 4);
expect(readClipboard("project-a")?.span).toBe(2); expect(readClipboard("project-a")?.span).toBe(2);
expect(readClipboard("project-a")?.span).toBe(2); expect(readClipboard("project-a")?.span).toBe(2);
}); });
@@ -52,9 +52,15 @@ describe("automationTargets", () => {
describe("value ↔ lane position", () => { describe("value ↔ lane position", () => {
it("maps a linear range straight onto the lane", () => { it("maps a linear range straight onto the lane", () => {
expect(toUnit(VOLUME_RANGE, 0)).toBe(0); const unit = { ...VOLUME_RANGE, max: 1 };
expect(toUnit(VOLUME_RANGE, 1)).toBe(1); expect(toUnit(unit, 0)).toBe(0);
expect(toUnit(VOLUME_RANGE, 0.25)).toBeCloseTo(0.25, 10); expect(toUnit(unit, 1)).toBe(1);
expect(toUnit(unit, 0.25)).toBeCloseTo(0.25, 10);
});
it("puts unity a quarter up the volume lane, which reaches +12 dB", () => {
expect(toUnit(VOLUME_RANGE, VOLUME_RANGE.max)).toBe(1);
expect(toUnit(VOLUME_RANGE, 1)).toBeCloseTo(1 / VOLUME_RANGE.max, 10);
}); });
it("maps a log-read knob on its own scale, so its middle is geometric", () => { it("maps a log-read knob on its own scale, so its middle is geometric", () => {
@@ -67,7 +73,7 @@ describe("value ↔ lane position", () => {
it("clamps a pointer that has left the lane", () => { it("clamps a pointer that has left the lane", () => {
expect(fromUnit(VOLUME_RANGE, -3)).toBe(0); expect(fromUnit(VOLUME_RANGE, -3)).toBe(0);
expect(fromUnit(VOLUME_RANGE, 4)).toBe(1); expect(fromUnit(VOLUME_RANGE, 4)).toBe(VOLUME_RANGE.max);
}); });
it("reads a zero-width range as the bottom rather than dividing by zero", () => { it("reads a zero-width range as the bottom rather than dividing by zero", () => {
+2 -2
View File
@@ -26,7 +26,7 @@
"files": 121 "files": 121
}, },
"hyperframes-audio": { "hyperframes-audio": {
"hash": "6bdb36e1571586fe", "hash": "94aba963d262d71d",
"files": 6 "files": 6
}, },
"hyperframes-cli": { "hyperframes-cli": {
@@ -34,7 +34,7 @@
"files": 11 "files": 11
}, },
"hyperframes-core": { "hyperframes-core": {
"hash": "2471f4b5049fb489", "hash": "f6516b74cb6e5d58",
"files": 20 "files": 20
}, },
"hyperframes-creative": { "hyperframes-creative": {
+4 -1
View File
@@ -332,7 +332,10 @@ instead. `references/fx-registry.md` marks every parameter.
Almost no static gate covers the mix. The linter reads `data-automation` for Almost no static gate covers the mix. The linter reads `data-automation` for
exactly one conflict — `audio_volume_double_automation`, a volume lane on a track exactly one conflict — `audio_volume_double_automation`, a volume lane on a track
that also has a GSAP tween on `volume`, where the lane wins and the tween is that also has a GSAP tween on `volume`, where the lane wins and the tween is
ignored — and nothing validates the chain or the effect lanes at all. What ignored — plus `audio_volume_tween_overrides_gain`, an authored `data-volume`
on a track whose `volume` is tweened, where the tween's values are absolute and
replace that gain instead of scaling it. Nothing validates the
chain or the effect lanes at all. What
enforces those is the render: a chain it cannot parse fails the whole mix rather enforces those is the render: a chain it cannot parse fails the whole mix rather
than quietly writing the dry signal, because a mix that sounds plausible and is than quietly writing the dry signal, because a mix that sounds plausible and is
wrong is worse than a refusal. Preview is the opposite by design: an unreadable wrong is worse than a refusal. Preview is the opposite by design: an unreadable
@@ -24,15 +24,15 @@ Timed child elements are clips. **`class="clip"` is required on visible timed el
**Visual clips (`class="clip"`) must be DIRECT children of the composition root.** A clip nested inside a wrapper `<div>` is not registered as a clip, so its `data-start`/`data-duration` are ignored and it stays visible the whole composition. To wrap/transform a clip, put the wrapper _inside_ the clip, or animate the clip element itself; do not wrap the clip. (This is a clip-_visibility_ rule. `<video>`/`<audio>` are exempt: the framework drives their playback via a flat DOM query, so they seek/decode at any depth, including inside a sub-comp `<template>` — see `variables-and-media.md`.) **Visual clips (`class="clip"`) must be DIRECT children of the composition root.** A clip nested inside a wrapper `<div>` is not registered as a clip, so its `data-start`/`data-duration` are ignored and it stays visible the whole composition. To wrap/transform a clip, put the wrapper _inside_ the clip, or animate the clip element itself; do not wrap the clip. (This is a clip-_visibility_ rule. `<video>`/`<audio>` are exempt: the framework drives their playback via a flat DOM query, so they seek/decode at any depth, including inside a sub-comp `<template>` — see `variables-and-media.md`.)
| Attribute | Required | Meaning | | Attribute | Required | Meaning |
| ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | Yes | Stable DOM ID for linting, timeline targets, and debugging. | | `id` | Yes | Stable DOM ID for linting, timeline targets, and debugging. |
| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. | | `data-start` | Yes | Start time in seconds, or a supported clip-time reference. |
| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. | | `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. |
| `data-track-index` | Yes | Timeline track. Clips on the same track must not overlap. | | `data-track-index` | Yes | Timeline track. Clips on the same track must not overlap. |
| `data-media-start` | No | Offset into the media source, in seconds. | | `data-media-start` | No | Offset into the media source, in seconds. |
| `data-volume` | No | Static audio volume, `0` to `1`, default `1`. For fades, animate `volume` on the timeline instead (see `variables-and-media.md`). | | `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades, animate `volume` on the timeline instead (see `variables-and-media.md`); a tween's own values replace this baseline entirely. |
| `data-has-audio` | No (`<video>` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. | | `data-has-audio` | No (`<video>` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
**Visibility window is inclusive of both ends.** A clip shows while `start ≤ t ≤ start + duration` — it still renders at exactly `t = start + duration`, so the final frame holds the animation's resolved end state (the runtime does not hide it one frame early). A reveal/entrance that lands on `data-duration` is therefore visible on the last frame; you do not need to finish it _before_ `data-duration` just to guarantee the end state renders. (Climax-dwell guidance in `/hyperframes-animation` is about pacing, not this boundary.) **Visibility window is inclusive of both ends.** A clip shows while `start ≤ t ≤ start + duration` — it still renders at exactly `t = start + duration`, so the final frame holds the animation's resolved end state (the runtime does not hide it one frame early). A reveal/entrance that lands on `data-duration` is therefore visible on the last frame; you do not need to finish it _before_ `data-duration` just to guarantee the end state renders. (Climax-dwell guidance in `/hyperframes-animation` is about pacing, not this boundary.)
@@ -98,7 +98,7 @@ Video elements must be muted and inline. Audio must be a separate `<audio>` elem
- **Do not** nest video inside a timed wrapper. Put timing on the media element or keep the wrapper untimed. - **Do not** nest video inside a timed wrapper. Put timing on the media element or keep the wrapper untimed.
- Add `crossorigin="anonymous"` for external media that needs canvas capture or pixel inspection. - Add `crossorigin="anonymous"` for external media that needs canvas capture or pixel inspection.
- Audio always lives on a separate `<audio>` element — even if its source file is the same as a `<video>`. The `<video>` is muted; the `<audio>` carries sound. - Audio always lives on a separate `<audio>` element — even if its source file is the same as a `<video>`. The `<video>` is muted; the `<audio>` carries sound.
- For volume fades/ducking, animate `volume` on the timeline (`tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`) rather than swapping `data-volume`. The runtime probes the timeline's volume keyframes and applies them identically in preview and render; `data-volume` is the static baseline for elements no tween touches. - For volume fades/ducking, animate `volume` on the timeline (`tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`) rather than swapping `data-volume`. The runtime probes the timeline's volume keyframes and applies them identically in preview and render; `data-volume` is the static baseline for elements no tween touches. A tween's values REPLACE that baseline rather than scaling it, so on a clip whose gain is not `1` you scale the tween's targets instead (`{ volume: 1.95 }`, not `{ volume: 1 }`) — `lint` warns with `audio_volume_tween_overrides_gain` when the two disagree.
For media duration: `<video>` and `<audio>` can omit `data-duration` if the media's intrinsic length is known and you want the full clip. Otherwise provide `data-duration` explicitly. For media duration: `<video>` and `<audio>` can omit `data-duration` if the media's intrinsic length is known and you want the full clip. Otherwise provide `data-duration` explicitly.