mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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", () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user