diff --git a/packages/core/src/audioAutomation.test.ts b/packages/core/src/audioAutomation.test.ts
index 04e3a3b885..6467260867 100644
--- a/packages/core/src/audioAutomation.test.ts
+++ b/packages/core/src/audioAutomation.test.ts
@@ -18,6 +18,7 @@ import {
type HfAutomationLane,
} from "./audioAutomation.js";
import { mintAudioFxNodeId, parseAudioFxChain, type HfAudioFxChain } from "./audioFx.js";
+import { MAX_AUDIO_GAIN } from "./audioGain.js";
const chain: HfAudioFxChain = {
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(
JSON.stringify({
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", () => {
diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts
index bcd7401e84..ec873d6cdd 100644
--- a/packages/core/src/audioAutomation.ts
+++ b/packages/core/src/audioAutomation.ts
@@ -12,6 +12,7 @@
*/
import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
+import { MAX_AUDIO_GAIN } from "./audioGain.js";
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.
*
- * Volume is linear 0..1, matching `data-volume` and the existing volume
- * envelope machinery — no dB conversion enters the volume path. Everything
+ * Volume is linear over the full authoring gain range, matching `data-volume`
+ * 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
* renderer would reject, and the log-scaled knobs sweep the way a DAW's do.
*/
@@ -151,9 +153,15 @@ export interface AutomationRange {
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 = {
min: 0,
- max: 1,
+ max: MAX_AUDIO_GAIN,
step: 0.01,
unit: "",
label: "Volume",
diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts
index baf03bae74..de3bf9687e 100644
--- a/packages/lint/src/rules/media.test.ts
+++ b/packages/lint/src/rules/media.test.ts
@@ -429,6 +429,50 @@ describe("media_variable_src_no_fallback", () => {
});
});
+describe("audio_volume_tween_overrides_gain", () => {
+ const withScript = (audioAttrs: string, script: string) => `
+
+
+
+
+ `;
+
+ 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 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", () => {
const withScript = (audioAttrs: string, script: string) => `
diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts
index 8a759f5407..977790abf2 100644
--- a/packages/lint/src/rules/media.ts
+++ b/packages/lint/src/rules/media.ts
@@ -629,8 +629,50 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
// audio_volume_double_automation
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))
+ .map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume")) }))
+ .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,
* and only the lane is heard: the runtime reads `data-automation` first and
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
index 9ae2f79df7..88a8f90140 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx
@@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => {
});
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 element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
+ const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(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());
});
- it("refuses to commit from the percent slider on a clip authored above unity", () => {
- // 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.
+ it("commits +12 dB of boost from the upper half of the volume fader", () => {
const onSetAttribute = vi.fn();
- const element = makeVideoElement({ dataAttributes: { volume: "1.949845" } });
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render(
- ,
- );
- });
-
- 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 element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(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 }),
});
act(() => {
- volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
- volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
+ volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
+ 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"
- expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
+ // Six decimals, not two: at two the bottom of the dB fader collapses onto
+ // "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());
});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
index 806d83dcf1..6ba4dd095a 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx
@@ -13,6 +13,14 @@ import {
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
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
export function FlatMediaSection({
@@ -54,7 +62,7 @@ export function FlatMediaSection({
const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
- const volumePercent = Math.round(volume * 100);
+ const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart =
Number.parseFloat(
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
here would be overwritten by the envelope on the next tick. The
- 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. */}
+ toggle beside it carries the tooltip. */}
- {/* 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. */}
diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
index 87fb8523df..7dc02f6f89 100644
--- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
+++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
@@ -261,9 +261,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 5,
t1: 7,
// 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,
- v1: 1,
+ v1: VOLUME_RANGE.max,
});
});
@@ -300,9 +301,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4,
t1: 6,
// 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,
- v1: 1,
+ v1: VOLUME_RANGE.max,
});
});
@@ -338,9 +340,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4,
t1: 6,
// 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,
- v1: 1,
+ v1: VOLUME_RANGE.max,
});
});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
index d162ca4b0a..94da3b6745 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
@@ -6,6 +6,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
import { PAD_X } from "./automationLaneGeometry";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import {
normalizeAutomation,
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[0]> = {}) {
const target = over.target ?? "volume";
return {
@@ -140,7 +149,9 @@ function laneProps(over: Partial[0]> =
onCommit: vi.fn(),
...over,
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 }));
});
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(".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);
});
});
diff --git a/packages/studio/src/player/components/automationClipboard.test.ts b/packages/studio/src/player/components/automationClipboard.test.ts
index ff7a510ec0..d424a2fbaa 100644
--- a/packages/studio/src/player/components/automationClipboard.test.ts
+++ b/packages/studio/src/player/components/automationClipboard.test.ts
@@ -8,6 +8,9 @@ import {
import { resolveAutomationRange, VOLUME_RANGE } 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 = {
target: "volume",
points: [
@@ -21,7 +24,7 @@ beforeEach(clearAutomationClipboard);
describe("automation clipboard", () => {
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");
expect(entry?.span).toBe(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", () => {
- copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
+ copyRange("project-a", duck, UNIT_RANGE, 2, 4);
const entry = readClipboard("project-a");
expect(entry).not.toBeNull();
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.v)).toEqual([1, 0.25, 1]);
});
@@ -51,7 +54,7 @@ describe("automation clipboard", () => {
expect(frequency).toBeTruthy();
if (!frequency) return;
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");
if (!entry) return;
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", () => {
- copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
+ copyRange("project-a", duck, UNIT_RANGE, 2, 4);
expect(readClipboard("project-b")).toBeNull();
});
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");
// 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.
@@ -87,7 +90,7 @@ describe("automation clipboard", () => {
});
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);
});
diff --git a/packages/studio/src/player/components/automationLaneGeometry.test.ts b/packages/studio/src/player/components/automationLaneGeometry.test.ts
index 105cb3b6f3..a41bacf452 100644
--- a/packages/studio/src/player/components/automationLaneGeometry.test.ts
+++ b/packages/studio/src/player/components/automationLaneGeometry.test.ts
@@ -52,9 +52,15 @@ describe("automationTargets", () => {
describe("value ↔ lane position", () => {
it("maps a linear range straight onto the lane", () => {
- expect(toUnit(VOLUME_RANGE, 0)).toBe(0);
- expect(toUnit(VOLUME_RANGE, 1)).toBe(1);
- expect(toUnit(VOLUME_RANGE, 0.25)).toBeCloseTo(0.25, 10);
+ const unit = { ...VOLUME_RANGE, max: 1 };
+ expect(toUnit(unit, 0)).toBe(0);
+ 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", () => {
@@ -67,7 +73,7 @@ describe("value ↔ lane position", () => {
it("clamps a pointer that has left the lane", () => {
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", () => {
diff --git a/skills-manifest.json b/skills-manifest.json
index 39217fc336..7a460a4957 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -26,7 +26,7 @@
"files": 121
},
"hyperframes-audio": {
- "hash": "c96ccac4b1127b8c",
+ "hash": "1c014197370ef80c",
"files": 6
},
"hyperframes-cli": {
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
- "hash": "ec542db377d8b213",
+ "hash": "e57de95856bc1e37",
"files": 20
},
"hyperframes-creative": {
diff --git a/skills/hyperframes-audio/SKILL.md b/skills/hyperframes-audio/SKILL.md
index 48d2b07669..f1ee5781b2 100644
--- a/skills/hyperframes-audio/SKILL.md
+++ b/skills/hyperframes-audio/SKILL.md
@@ -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
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
-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
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
diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md
index ea8f0cdc1f..b7d4abcfca 100644
--- a/skills/hyperframes-core/references/data-attributes.md
+++ b/skills/hyperframes-core/references/data-attributes.md
@@ -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 `
` 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. `