mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix: data-volume multiplies the volume lane in preview and render (#6068)
* fix: data-volume multiplies the volume lane in preview and render * fix: a clip's data-volume plays ahead of its audio effects, in preview and render * feat(studio): the media panel shows a clip's Gain beside its Volume level * fix(audio): cap gain times volume lanes through each ramp * chore(core): account for shared volume envelopes in package budget * fix(core): restore static gain when a playing lane is removed
This commit is contained in:
@@ -115,6 +115,14 @@ Preview and render agree on both clocks.
|
||||
which is about `3.981` — the same ceiling `data-volume` uses. A lane that boosts
|
||||
above unity is valid and will play; `1` is unity, not the maximum.
|
||||
|
||||
The lane multiplies `data-volume`, the way clip gain offsets volume keyframes in
|
||||
an editor: `data-volume="0.5"` under a lane at `1` plays at `0.5`. The product
|
||||
shares the same +12 dB ceiling. This holds on clips and on groups.
|
||||
|
||||
On a clip, `data-volume` is applied **ahead of** its effects, so a compressor or
|
||||
limiter in its `data-fx-chain` reacts to it; the lane comes after them. A group's
|
||||
`data-volume` stays after its effects, like a track fader.
|
||||
|
||||
A lane holds its first value backwards to the start of its clip and its last
|
||||
value forward to the end. **A bed that begins before the voice therefore needs an
|
||||
explicit "no cut" point at `t: 0`, or it starts out already ducked.** Maximum 512
|
||||
|
||||
@@ -133,7 +133,7 @@ Audio has no visual lifecycle.
|
||||
| --- | --- | --- |
|
||||
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. Two groups of readers disagree, so the right name depends on the element. **Read only `data-media-start`:** the timing compiler, the HTML parser, `hyperframes validate` (which only inspects `<audio>`), and the engine's audio mixer (which feeds ffmpeg `-ss`). **Read `data-playback-start` first, falling back to `data-media-start`:** the runtime player, Studio (which also writes it), and `hyperframes snapshot`. Because the audio mixer reads only `data-media-start`, a `<video>` authored with just `data-playback-start` renders a trimmed picture over untrimmed audio. Set the name by kind: **`<video>` / `<audio>` → `data-media-start`**; **nested composition → `data-playback-start`** — composition hosts are inspected only by the playback-start-first readers (the media-start-only ones are all `<video>`/`<audio>`-scoped). `data-media-start` still works there as a fallback, but `data-playback-start` is the canonical name Studio writes and normalises to for new composition hosts, so the other name works until an edit rewrites it (it is the [child-timeline offset](/concepts/compositions)). |
|
||||
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `10`. A `rate` lane in `data-automation` overrides it; see [Speed ramps](/reference/speed-ramps) |
|
||||
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB) |
|
||||
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB). Multiplies any volume lane, and on a clip it applies ahead of the clip's audio effects |
|
||||
| `data-fade-in` / `data-fade-out` | Video and audio | Clip-edge fades in seconds: gain rises 0 → 1 over the first `data-fade-in` seconds and falls 1 → 0 over the last `data-fade-out` seconds of the clip. Linear, anchored to the clip's own edges (a fade-out follows a later trim), and multiplied on top of `data-volume` and any volume lane. Fades that together exceed the clip are scaled to meet inside it. Studio drags them from the dots at an audio clip's top corners or types them in the inspector's Fade rows |
|
||||
| `data-has-audio="true"` | Video | Declares that the video contributes audio |
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import {
|
||||
isConstantLane,
|
||||
isBentSegment,
|
||||
resolveAutomationRange,
|
||||
sampleAutomationCurve,
|
||||
sampleAutomationLane,
|
||||
@@ -62,8 +63,7 @@ function isStraight(
|
||||
scale: "linear" | "log",
|
||||
map: FxParamTarget["map"],
|
||||
): boolean {
|
||||
const bent = Boolean(point.curve) || (point.viaX !== undefined && point.viaY !== undefined);
|
||||
return !bent && scale === "linear" && !map;
|
||||
return !isBentSegment(point) && scale === "linear" && !map;
|
||||
}
|
||||
|
||||
function curvePointCount(duration: number): number {
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
applyCurve,
|
||||
shapeProgress,
|
||||
fxAutomationTarget,
|
||||
gainedVolumePoints,
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
HF_AUDIO_AUTOMATION_DATA_KEY,
|
||||
isBentSegment,
|
||||
isConstantLane,
|
||||
parseAutomation,
|
||||
parseAutomationTarget,
|
||||
@@ -572,3 +574,59 @@ describe("chain node ids", () => {
|
||||
expect(parseAudioFxChain(json).nodes[0]!.id).toBe("n7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gainedVolumePoints", () => {
|
||||
const ramp: HfAutomationLane = {
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 0 },
|
||||
{ t: 10, v: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
it("caps gain times the lane at every instant, not only at the breakpoints", () => {
|
||||
const points = gainedVolumePoints(ramp, 2);
|
||||
expect(points).toHaveLength(3);
|
||||
expect(points[1]!.t).toBeCloseTo((MAX_AUDIO_GAIN / 2 / 3) * 10, 9);
|
||||
expect(points[1]!.v).toBeCloseTo(MAX_AUDIO_GAIN, 9);
|
||||
// Halfway, gain 2 times 1.5 is 3, under the ceiling: the ramp to the crossing keeps it.
|
||||
expect(sampleAutomationLane({ target: "volume", points }, 5)).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("leaves only the ceiling's share of the lane when the gain plays ahead", () => {
|
||||
const constant: HfAutomationLane = { target: "volume", points: [{ t: 0, v: 3 }] };
|
||||
expect(gainedVolumePoints(constant, 2, true)[0]!.v).toBeCloseTo(MAX_AUDIO_GAIN / 2, 9);
|
||||
expect(gainedVolumePoints(constant, 0.5, true)[0]!.v).toBe(3);
|
||||
});
|
||||
|
||||
it("leaves a ramp at the ceiling until its falling product comes back under it", () => {
|
||||
const falling: HfAutomationLane = {
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 3 },
|
||||
{ t: 10, v: 0 },
|
||||
],
|
||||
};
|
||||
const points = gainedVolumePoints(falling, 2);
|
||||
expect(points).toHaveLength(3);
|
||||
expect(sampleAutomationLane({ target: "volume", points }, 2)).toBeCloseTo(MAX_AUDIO_GAIN, 9);
|
||||
expect(sampleAutomationLane({ target: "volume", points }, 5)).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("keeps zero gain muted without dividing by zero", () => {
|
||||
expect(gainedVolumePoints(ramp, 0, true).map((p) => p.v)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("draws a bent segment that crosses the ceiling as capped samples", () => {
|
||||
const bent: HfAutomationLane = {
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 0, viaX: 0.5, viaY: 0.8 },
|
||||
{ t: 2, v: 3 },
|
||||
],
|
||||
};
|
||||
const points = gainedVolumePoints(bent, 2);
|
||||
expect(points.length).toBeGreaterThan(30);
|
||||
expect(points.every((p) => p.v <= MAX_AUDIO_GAIN + 1e-9 && !isBentSegment(p))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
|
||||
import { MAX_AUDIO_GAIN } from "./audioGain.js";
|
||||
import { clampAudioGain, gainTimesLane, MAX_AUDIO_GAIN } from "./audioGain.js";
|
||||
import { MAX_PLAYBACK_RATE, MIN_PLAYBACK_RATE } from "./playbackRateBounds.js";
|
||||
|
||||
export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";
|
||||
@@ -175,6 +175,55 @@ export const VOLUME_RANGE: AutomationRange = {
|
||||
default: 1,
|
||||
};
|
||||
|
||||
/** Bounded sampling for volume curves drawn as straight pieces by the PCM baker. */
|
||||
export function volumeSegmentSampleCount(duration: number): number {
|
||||
return Math.min(4096, Math.max(2, Math.ceil(duration * 30)));
|
||||
}
|
||||
|
||||
/** Whether the segment leaving `point` bends: by `curve`, or through a `viaX`/`viaY` point. */
|
||||
export function isBentSegment(point: Pick<HfAutomationPoint, "curve" | "viaX" | "viaY">): boolean {
|
||||
return Boolean(point.curve) || (point.viaX !== undefined && point.viaY !== undefined);
|
||||
}
|
||||
|
||||
// With gain played upstream, leave only the lane's share of the capped product here.
|
||||
export function gainedVolume(v: number, gain: number, gainAhead = false): number {
|
||||
const g = clampAudioGain(gain);
|
||||
const product = gainTimesLane(g, v);
|
||||
return gainAhead && g > 0 ? product / g : product;
|
||||
}
|
||||
|
||||
// Split straight segments where their product reaches the ceiling; sample bent crossings.
|
||||
export function gainedVolumePoints(
|
||||
lane: HfAutomationLane,
|
||||
gain: number,
|
||||
gainAhead = false,
|
||||
): HfAutomationPoint[] {
|
||||
const out: HfAutomationPoint[] = [];
|
||||
const points = lane.points;
|
||||
const g = clampAudioGain(gain);
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const a = points[i]!;
|
||||
const b = points[i + 1];
|
||||
const crosses = b !== undefined && (g * a.v - MAX_AUDIO_GAIN) * (g * b.v - MAX_AUDIO_GAIN) < 0;
|
||||
if (!crosses) {
|
||||
out.push({ ...a, v: gainedVolume(a.v, gain, gainAhead) });
|
||||
continue;
|
||||
}
|
||||
out.push({ t: a.t, v: gainedVolume(a.v, gain, gainAhead) });
|
||||
if (isBentSegment(a)) {
|
||||
const steps = volumeSegmentSampleCount(b.t - a.t);
|
||||
for (let k = 1; k < steps; k += 1) {
|
||||
const t = a.t + ((b.t - a.t) * k) / steps;
|
||||
out.push({ t, v: gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead) });
|
||||
}
|
||||
} else {
|
||||
const t = a.t + ((MAX_AUDIO_GAIN / g - a.v) / (b.v - a.v)) * (b.t - a.t);
|
||||
out.push({ t, v: gainedVolume(MAX_AUDIO_GAIN / g, g, gainAhead) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const RATE_RANGE: AutomationRange = {
|
||||
min: MIN_PLAYBACK_RATE,
|
||||
max: MAX_PLAYBACK_RATE,
|
||||
|
||||
@@ -27,6 +27,16 @@ export function clampAudioGain(value: number): number {
|
||||
return Math.max(0, Math.min(MAX_AUDIO_GAIN, value));
|
||||
}
|
||||
|
||||
/** `data-volume` is a gain on the volume lane, as clip gain offsets the keyframes in an NLE. */
|
||||
export function gainTimesLane(gain: number, lane: number): number {
|
||||
return clampAudioGain(gain * lane);
|
||||
}
|
||||
|
||||
/** `data-volume` as clip gain, ahead of the FX chain; unity when a GSAP tween's absolute values own the level. */
|
||||
export function clipGain(volume: number, tweened: boolean): number {
|
||||
return tweened ? 1 : clampAudioGain(volume);
|
||||
}
|
||||
|
||||
export function clampNativeMediaVolume(value: number): number {
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { HF_AUDIO_FX_ATTR, parseAudioFxChain, type HfAudioFxChain } from "../audioFx.js";
|
||||
import {
|
||||
gainedVolumePoints,
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
parseAutomation,
|
||||
resolveAutomation,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import type { FxParamTarget } from "../audio/audioFxGraph.js";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import {
|
||||
audioFxWorkletsReady,
|
||||
buildFxChain,
|
||||
@@ -126,16 +128,32 @@ export interface FxAttributes {
|
||||
automation: string | null;
|
||||
}
|
||||
|
||||
/** The element's volume lane on `param`, as a target to cancel later; none when it has no lane. */
|
||||
function scheduleVolume(
|
||||
param: AudioParam,
|
||||
/** Where a volume lane plays, and the `data-volume` it multiplies (`gainAhead`: played upstream). */
|
||||
export interface VolumeTarget {
|
||||
param: AudioParam;
|
||||
gain?: () => number;
|
||||
gainAhead?: boolean;
|
||||
}
|
||||
|
||||
/** The element's volume lane on `target`, as a target to cancel later; none when it has no lane. */
|
||||
export function scheduleVolume(
|
||||
target: VolumeTarget,
|
||||
automation: HfAutomation,
|
||||
at: AutomationTiming,
|
||||
): FxParamTarget[] {
|
||||
const lane = volumeLane(automation);
|
||||
if (!lane) return [];
|
||||
scheduleParamLane([{ param }], lane, VOLUME_RANGE.scale, at);
|
||||
return [{ param }];
|
||||
const points = gainedVolumePoints(lane, target.gain?.() ?? 1, target.gainAhead);
|
||||
scheduleParamLane([{ param: target.param }], { ...lane, points }, VOLUME_RANGE.scale, at);
|
||||
return [{ param: target.param }];
|
||||
}
|
||||
|
||||
function restoreStaticVolume(target: VolumeTarget, at: AutomationTiming): void {
|
||||
clearParamLane([target]);
|
||||
target.param.setValueAtTime(
|
||||
target.gainAhead ? 1 : clampAudioGain(target.gain?.() ?? 1),
|
||||
at.scheduledAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function attachElementFxChain(
|
||||
@@ -145,7 +163,7 @@ export function attachElementFxChain(
|
||||
destination: AudioNode,
|
||||
timing?: AutomationTiming,
|
||||
attrs: FxAttributes = { automation: HF_AUDIO_AUTOMATION_ATTR },
|
||||
volume?: AudioParam,
|
||||
volume?: VolumeTarget,
|
||||
): ElementFxHandle | null {
|
||||
const { chain } = readChain(el);
|
||||
|
||||
@@ -226,12 +244,18 @@ export function attachElementFxChain(
|
||||
return;
|
||||
}
|
||||
const automation = readAutomation(el, next, attrs.automation);
|
||||
automated = [
|
||||
...(handle
|
||||
? scheduleChainAutomation(automation, next, handle.nodes, at, handle.presets)
|
||||
: []),
|
||||
...(volume ? scheduleVolume(volume, automation, at) : []),
|
||||
];
|
||||
const fxTargets = handle
|
||||
? scheduleChainAutomation(automation, next, handle.nodes, at, handle.presets)
|
||||
: [];
|
||||
const volumeTargets = volume ? scheduleVolume(volume, automation, at) : [];
|
||||
if (
|
||||
volume &&
|
||||
volumeTargets.length === 0 &&
|
||||
automated.some((target) => target.param === volume.param)
|
||||
) {
|
||||
restoreStaticVolume(volume, at);
|
||||
}
|
||||
automated = [...fxTargets, ...volumeTargets];
|
||||
};
|
||||
|
||||
// The reference frame every later reschedule measures from. Mutable because a
|
||||
@@ -310,7 +334,12 @@ export function attachElementFxChain(
|
||||
});
|
||||
observer.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: attrs.automation ? [HF_AUDIO_FX_ATTR, attrs.automation] : [HF_AUDIO_FX_ATTR],
|
||||
attributeFilter: [
|
||||
HF_AUDIO_FX_ATTR,
|
||||
...(attrs.automation ? [attrs.automation] : []),
|
||||
// The lane's ceiling depends on the gain it multiplies.
|
||||
...(volume?.gain ? ["data-volume"] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3413,8 +3413,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
cueAheadSeconds,
|
||||
getCompositionDuration: () =>
|
||||
getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision),
|
||||
onElementVolume: (el, _effectiveVolume, authorVolume) =>
|
||||
webAudio.setElementVolume(el, authorVolume),
|
||||
onElementVolume: (el, _effectiveVolume, authorVolume, clipGain) =>
|
||||
webAudio.setElementVolume(el, authorVolume, clipGain),
|
||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
||||
onAutoplayBlocked: () => {
|
||||
|
||||
@@ -442,10 +442,10 @@ describe("syncRuntimeMedia", () => {
|
||||
return seen;
|
||||
}
|
||||
|
||||
it("drives the transport gain from the lane, not from data-volume", () => {
|
||||
it("drives the transport gain from the lane times data-volume", () => {
|
||||
const [held, ducked] = volumesAt([1, 5], DUCK);
|
||||
expect(held).toBeCloseTo(0.8, 5);
|
||||
expect(ducked).toBeCloseTo(0.1, 5);
|
||||
expect(held).toBeCloseTo(0.8 * 0.55, 5);
|
||||
expect(ducked).toBeCloseTo(0.1 * 0.55, 5);
|
||||
});
|
||||
|
||||
it("keeps automation author-only while user volume remains a separate layer", () => {
|
||||
@@ -461,10 +461,15 @@ describe("syncRuntimeMedia", () => {
|
||||
onElementVolume,
|
||||
});
|
||||
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.4, 0.8);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(
|
||||
clip.el,
|
||||
expect.closeTo(0.22, 5),
|
||||
expect.closeTo(0.44, 5),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a routed clip's lane to the transport, sending only what multiplies it", () => {
|
||||
it("leaves a routed clip's lane to the transport, sending data-volume as clip gain", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0.55 });
|
||||
clip.el.setAttribute("data-automation", DUCK);
|
||||
const onElementVolume = vi.fn();
|
||||
@@ -477,15 +482,15 @@ describe("syncRuntimeMedia", () => {
|
||||
isWebAudioRouted: () => true,
|
||||
});
|
||||
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 1, 1);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 1, 1, 0.55);
|
||||
});
|
||||
|
||||
it("ramps between points across ticks", () => {
|
||||
const [a, b, c] = volumesAt([2, 2.5, 3], DUCK);
|
||||
expect(a).toBeCloseTo(0.8, 5);
|
||||
expect(b).toBeGreaterThan(0.1);
|
||||
expect(b).toBeLessThan(0.8);
|
||||
expect(c).toBeCloseTo(0.1, 5);
|
||||
expect(a).toBeCloseTo(0.8 * 0.55, 5);
|
||||
expect(b).toBeGreaterThan(0.1 * 0.55);
|
||||
expect(b).toBeLessThan(0.8 * 0.55);
|
||||
expect(c).toBeCloseTo(0.1 * 0.55, 5);
|
||||
});
|
||||
|
||||
it("falls back to data-volume when there is no lane", () => {
|
||||
@@ -591,8 +596,8 @@ describe("syncRuntimeMedia", () => {
|
||||
// `data-media-start="30"` on a clip whose lane holds 0.8 until t=2 then
|
||||
// ducks to 0.1 by t=3. At media time the playhead is already 30 s past the
|
||||
// last point, so preview held 0.1 from the first frame and never ducked.
|
||||
expect(trimmed(1)).toBeCloseTo(0.8, 5);
|
||||
expect(trimmed(5)).toBeCloseTo(0.1, 5);
|
||||
expect(trimmed(1)).toBeCloseTo(0.8 * 0.55, 5);
|
||||
expect(trimmed(5)).toBeCloseTo(0.1 * 0.55, 5);
|
||||
});
|
||||
|
||||
it("supersedes keyframes probed from the timeline", () => {
|
||||
@@ -614,7 +619,7 @@ describe("syncRuntimeMedia", () => {
|
||||
seen = v;
|
||||
},
|
||||
});
|
||||
expect(seen).toBeCloseTo(0.1, 5);
|
||||
expect(seen).toBeCloseTo(0.1 * 0.55, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1464,7 +1469,7 @@ describe("syncRuntimeMedia", () => {
|
||||
});
|
||||
|
||||
expect(clip.el.volume).toBeCloseTo(0.375);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375, 0.75);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375, 0.75, 1);
|
||||
});
|
||||
|
||||
it("preserves author volume through user zero then restore", () => {
|
||||
@@ -1487,8 +1492,8 @@ describe("syncRuntimeMedia", () => {
|
||||
onElementVolume,
|
||||
});
|
||||
|
||||
expect(onElementVolume.mock.calls.at(-2)).toEqual([clip.el, 0, 0.8]);
|
||||
expect(onElementVolume.mock.calls.at(-1)).toEqual([clip.el, 0.4, 0.8]);
|
||||
expect(onElementVolume.mock.calls.at(-2)).toEqual([clip.el, 0, 0.8, 1]);
|
||||
expect(onElementVolume.mock.calls.at(-1)).toEqual([clip.el, 0.4, 0.8, 1]);
|
||||
});
|
||||
|
||||
it("does not mistake routed upstream unity for an authored volume change", () => {
|
||||
@@ -1505,7 +1510,7 @@ describe("syncRuntimeMedia", () => {
|
||||
onElementVolume,
|
||||
});
|
||||
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.4, 0.8);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.5, 1, 0.8);
|
||||
});
|
||||
|
||||
describe("per-element mute (Web Audio ownership)", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
|
||||
import { fadeGain, NO_FADES, readElementFades, type AudioFades } from "../audioFade.js";
|
||||
import { readElementPlaybackRate, readElementRateSpec, readMediaStart } from "./playbackRate.js";
|
||||
import { rateAt, sourceTimeAt, timeAtSourceTime, type RateSpec } from "../speedRamp.js";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { clampAudioGain, clipGain, gainTimesLane } from "../audioGain.js";
|
||||
import { isMemberGroupHidden } from "../audioGroups.js";
|
||||
import { findInjectedRenderFrame } from "./renderFrameSibling.js";
|
||||
import { registerSeekCompletion } from "./adapters/seek-dispatch.js";
|
||||
@@ -358,7 +358,13 @@ export function syncRuntimeMedia(params: {
|
||||
* outbound message; further invocations are suppressed by the caller.
|
||||
*/
|
||||
onAutoplayBlocked?: () => void;
|
||||
onElementVolume?: (el: HTMLMediaElement, effectiveVolume: number, authorVolume: number) => void;
|
||||
/** `clipGain` is `data-volume` for a routed clip, played ahead of its FX; 1 otherwise. */
|
||||
onElementVolume?: (
|
||||
el: HTMLMediaElement,
|
||||
effectiveVolume: number,
|
||||
authorVolume: number,
|
||||
clipGain: number,
|
||||
) => void;
|
||||
/** Is THIS element owned by the Web Audio transport? Owned → mute it (transport
|
||||
* plays it); not owned → leave audible (HTMLMedia fallback). Per-element, not a
|
||||
* global flag, so a not-yet-claimed track isn't muted by other tracks. */
|
||||
@@ -444,7 +450,7 @@ export function syncRuntimeMedia(params: {
|
||||
const currentElementVolume = clampVolume(el.volume);
|
||||
|
||||
let authorVolume: number;
|
||||
// An explicit volume lane owns the fader. It is checked before the probed
|
||||
// An explicit volume lane, times `data-volume`, sets the level. Checked before the probed
|
||||
// keyframes because the two would otherwise fight, and it is the one the
|
||||
// author drew — `lint` warns when a track carries both.
|
||||
// Clip-local, NOT `relTime`. A lane's `t` is "seconds from the start of
|
||||
@@ -456,9 +462,11 @@ export function syncRuntimeMedia(params: {
|
||||
// trimmed clip. The FX lanes on this same feature use clip-local elapsed;
|
||||
// there is one time base, and this is it.
|
||||
const laneGain = elementVolumeLaneGain(el, params.timeSeconds - origin);
|
||||
const routed = params.isWebAudioRouted?.(el) === true;
|
||||
const tweened = laneGain === null && (clip.volumeKeyframes?.length ?? 0) > 0;
|
||||
const preFxGain = routed ? clipGain(fallbackAuthorVolume, tweened) : 1;
|
||||
if (laneGain !== null) {
|
||||
const transportOwnsLane = params.isWebAudioRouted?.(el);
|
||||
authorVolume = transportOwnsLane ? 1 : clampAudioGain(laneGain);
|
||||
authorVolume = routed ? 1 : gainTimesLane(fallbackAuthorVolume, laneGain);
|
||||
} else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) {
|
||||
// Keyframes probed from the GSAP timeline — same source as the renderer.
|
||||
// Use the interpolated envelope value directly; no need to track GSAP changes.
|
||||
@@ -469,8 +477,8 @@ export function syncRuntimeMedia(params: {
|
||||
// for an untrimmed clip playing at 1x from t=0.
|
||||
const elapsedInClip = params.timeSeconds - origin;
|
||||
authorVolume = clampAudioGain(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip));
|
||||
} else if (params.isWebAudioRouted?.(el)) {
|
||||
authorVolume = fallbackAuthorVolume;
|
||||
} else if (routed) {
|
||||
authorVolume = 1;
|
||||
} else if (previousRuntimeVolume === undefined) {
|
||||
// First tick this clip is active. The transport has already seeked GSAP
|
||||
// to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia),
|
||||
@@ -521,7 +529,7 @@ export function syncRuntimeMedia(params: {
|
||||
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
|
||||
if (Math.abs(el.volume - effectiveVolume) > 1e-6) el.volume = effectiveVolume;
|
||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
||||
params.onElementVolume?.(el, effectiveVolume, authorVolume, preFxGain);
|
||||
// Mute only when force-muted or the transport owns this element; an unclaimed
|
||||
// track stays audible via the HTMLMedia fallback.
|
||||
if ((forceMuteAll || params.isWebAudioOwned?.(el)) && !el.muted) el.muted = true;
|
||||
|
||||
@@ -42,7 +42,12 @@ function createMockAudioContext(currentTime = 100) {
|
||||
suspend: vi.fn(() => Promise.resolve()),
|
||||
createBufferSource: vi.fn(() => sourceNode),
|
||||
createMediaElementSource: vi.fn(() => mediaElementSourceNode),
|
||||
createGain: vi.fn(() => gainNode),
|
||||
// The element gain is the first one a schedule creates; its lane and clip gains get their own.
|
||||
createGain: vi.fn(() =>
|
||||
ctx.createGain.mock.calls.length === 1
|
||||
? gainNode
|
||||
: { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() },
|
||||
),
|
||||
destination: {},
|
||||
close: vi.fn(),
|
||||
};
|
||||
@@ -76,7 +81,9 @@ describe("WebAudioTransport author gain vs user volume", () => {
|
||||
// they schedule ramps onto the param directly and never pass through here.
|
||||
const { transport, mock } = setupTransport();
|
||||
const el = { muted: false } as HTMLMediaElement;
|
||||
transport["_activeSources"] = [{ el, gainNode: mock.gainNode, sourceKind: "buffer" }] as never;
|
||||
transport["_activeSources"] = [
|
||||
{ el, gainNode: mock.gainNode, clipGainNode: { gain: { value: 1 } }, sourceKind: "buffer" },
|
||||
] as never;
|
||||
|
||||
transport.setElementVolume(el, 1.949845);
|
||||
|
||||
@@ -86,7 +93,9 @@ describe("WebAudioTransport author gain vs user volume", () => {
|
||||
it("still refuses a gain beyond the shared ceiling", () => {
|
||||
const { transport, mock } = setupTransport();
|
||||
const el = { muted: false } as HTMLMediaElement;
|
||||
transport["_activeSources"] = [{ el, gainNode: mock.gainNode, sourceKind: "buffer" }] as never;
|
||||
transport["_activeSources"] = [
|
||||
{ el, gainNode: mock.gainNode, clipGainNode: { gain: { value: 1 } }, sourceKind: "buffer" },
|
||||
] as never;
|
||||
|
||||
transport.setElementVolume(el, 99);
|
||||
|
||||
@@ -950,15 +959,94 @@ describe("WebAudioTransport", () => {
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// An unbounded media-element clip: the runtime does not stopAll and reschedule it on a rate change.
|
||||
async function playBed(automation?: string) {
|
||||
async function playBed(automation?: string, volume?: string) {
|
||||
const setup = setupGroupTransport();
|
||||
const el = groupedAudioEl("bed");
|
||||
if (automation) el.setAttribute("data-automation", automation);
|
||||
if (volume) el.setAttribute("data-volume", volume);
|
||||
await setup.transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, setup.gen);
|
||||
expect(setup.transport.hasBoundedActiveSources()).toBe(false);
|
||||
return { ...setup, el, laneGain: setup.mock.gainNodes[1]! };
|
||||
}
|
||||
|
||||
// Gain 2 times a 0 -> 3 ramp crosses the ceiling at 6.635 s; the product holds there after.
|
||||
const CROSSING = lane([
|
||||
{ t: 0, v: 0 },
|
||||
{ t: 10, v: 3 },
|
||||
]);
|
||||
const crossAt = 100 + (MAX_AUDIO_GAIN / 2 / 3) * 10;
|
||||
|
||||
it("caps the routed graph's product when gain 2 and a constant lane 3 would make 6", async () => {
|
||||
const { transport, el, mock, laneGain } = await playBed(lane([{ t: 0, v: 3 }]), "2");
|
||||
transport.setElementVolume(el, 1, 2);
|
||||
const booked = (laneGain.gain as unknown as { setValueAtTime: ReturnType<typeof vi.fn> })
|
||||
.setValueAtTime.mock.calls;
|
||||
expect(mock.gainNodes[2]!.gain.value * booked[0]![0]).toBeCloseTo(MAX_AUDIO_GAIN, 9);
|
||||
});
|
||||
|
||||
it("holds a clip's gain times its lane under the ceiling, with data-volume played ahead", async () => {
|
||||
const { laneGain } = await playBed(CROSSING, "2");
|
||||
expect(rampsAfter(laneGain, 0)).toContainEqual([
|
||||
expect.closeTo(MAX_AUDIO_GAIN / 2, 6),
|
||||
expect.closeTo(crossAt, 6),
|
||||
]);
|
||||
expect(rampsAfter(laneGain, 0)).toContainEqual([
|
||||
expect.closeTo(MAX_AUDIO_GAIN / 2, 6),
|
||||
110,
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-books a clip's lane when its data-volume changes", async () => {
|
||||
const { el, laneGain } = await playBed(CROSSING, "2");
|
||||
const before = param(laneGain).linearRampToValueAtTime.mock.calls.length;
|
||||
|
||||
el.setAttribute("data-volume", "1");
|
||||
await settle();
|
||||
|
||||
expect(rampsAfter(laneGain, before)).toContainEqual([3, 110]);
|
||||
});
|
||||
|
||||
it("restores unity after a playing clip's volume lane is removed", async () => {
|
||||
const { el, laneGain, transport, mock } = await playBed(lane([{ t: 0, v: 0.25 }]), "2");
|
||||
transport.setElementVolume(el, 1, 2);
|
||||
|
||||
el.removeAttribute("data-automation");
|
||||
await Promise.resolve();
|
||||
|
||||
const booked = (laneGain.gain as unknown as { setValueAtTime: ReturnType<typeof vi.fn> })
|
||||
.setValueAtTime.mock.calls;
|
||||
expect(booked.at(-1)).toEqual([1, 100]);
|
||||
expect(mock.gainNodes[2]!.gain.value * booked.at(-1)![0]).toBe(2);
|
||||
});
|
||||
|
||||
it("restores the static group gain after its playing volume lane is removed", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="2"></hf-audio-group>`;
|
||||
const group = document.getElementById("vo")!;
|
||||
group.setAttribute("data-automation", lane([{ t: 0, v: 0.25 }]));
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[4]!;
|
||||
|
||||
group.removeAttribute("data-automation");
|
||||
await Promise.resolve();
|
||||
|
||||
const booked = (fader.gain as unknown as { setValueAtTime: ReturnType<typeof vi.fn> })
|
||||
.setValueAtTime.mock.calls;
|
||||
expect(booked.at(-1)).toEqual([2, 100]);
|
||||
});
|
||||
|
||||
it("holds a group's gain times its lane under the ceiling, through a ramp", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="2"></hf-audio-group>`;
|
||||
document.getElementById("vo")!.setAttribute("data-automation", CROSSING);
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[4]!;
|
||||
expect(rampsAfter(fader, 0)).toContainEqual([
|
||||
expect.closeTo(MAX_AUDIO_GAIN, 6),
|
||||
expect.closeTo(crossAt, 6),
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-aims a clip's lane at a new rate", async () => {
|
||||
const { transport, laneGain } = await playBed(FADE);
|
||||
expect(rampsAfter(laneGain, 0)).toContainEqual([1, 103]);
|
||||
@@ -1007,17 +1095,17 @@ describe("WebAudioTransport", () => {
|
||||
expect(rampsAfter(laneGain, 0)).toContainEqual([1, 103]);
|
||||
});
|
||||
|
||||
it("re-aims a group fader's lane at a new rate and on an edit", async () => {
|
||||
it("books a group's lane times its data-volume, at a new rate and on an edit", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
|
||||
document.getElementById("vo")!.setAttribute("data-automation", FADE);
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[4]!;
|
||||
expect(rampsAfter(fader, 0)).toContainEqual([1, 103]);
|
||||
expect(rampsAfter(fader, 0)).toContainEqual([0.5, 103]);
|
||||
|
||||
let before = param(fader).linearRampToValueAtTime.mock.calls.length;
|
||||
transport.setRate(2);
|
||||
expect(rampsAfter(fader, before)).toContainEqual([1, 101.5]);
|
||||
expect(rampsAfter(fader, before)).toContainEqual([0.5, 101.5]);
|
||||
|
||||
before = param(fader).linearRampToValueAtTime.mock.calls.length;
|
||||
document.getElementById("vo")!.setAttribute(
|
||||
@@ -1028,7 +1116,7 @@ describe("WebAudioTransport", () => {
|
||||
]),
|
||||
);
|
||||
await settle();
|
||||
expect(rampsAfter(fader, before)).toContainEqual([0.5, 102]);
|
||||
expect(rampsAfter(fader, before)).toContainEqual([0.25, 102]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1037,11 +1125,12 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await scheduleGrouped(transport, gen, "lone");
|
||||
|
||||
// The clip's gain goes straight to master; its volume-lane gain feeds it.
|
||||
expect(mock.gainNodes).toHaveLength(2);
|
||||
const [clipGain, laneGain] = mock.gainNodes;
|
||||
expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(laneGain!.connect).toHaveBeenCalledWith(clipGain);
|
||||
// Clip gain (data-volume, ahead of the FX), then the lane, then the per-tick gain to master.
|
||||
expect(mock.gainNodes).toHaveLength(3);
|
||||
const [gain, laneGain, clipGain] = mock.gainNodes;
|
||||
expect(gain!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(laneGain!.connect).toHaveBeenCalledWith(gain);
|
||||
expect(clipGain!.connect).toHaveBeenCalledWith(laneGain);
|
||||
});
|
||||
|
||||
// The media-element transport is the PRIMARY path for audio — the runtime
|
||||
@@ -1090,15 +1179,28 @@ describe("WebAudioTransport", () => {
|
||||
expect(clipGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
it("plays a routed clip's data-volume on its clip gain, ahead of its effects", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
const el = groupedAudioEl("lone");
|
||||
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
|
||||
|
||||
transport.setElementVolume(el, 0.8, 0.5);
|
||||
|
||||
const [gain, , clipGain] = mock.gainNodes;
|
||||
expect(clipGain!.gain.value).toBe(0.5);
|
||||
expect(gain!.gain.value).toBe(0.8);
|
||||
});
|
||||
|
||||
it("routes an UNGROUPED clip's media-element playback straight to master", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
const el = groupedAudioEl("lone");
|
||||
|
||||
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
|
||||
|
||||
expect(mock.gainNodes).toHaveLength(2);
|
||||
expect(mock.gainNodes).toHaveLength(3);
|
||||
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(mock.gainNodes[1]!.connect).toHaveBeenCalledWith(mock.gainNodes[0]);
|
||||
expect(mock.gainNodes[2]!.connect).toHaveBeenCalledWith(mock.gainNodes[1]);
|
||||
});
|
||||
|
||||
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
|
||||
@@ -1109,14 +1211,14 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
// Creation order for a: a-gain(0), groupInput(1), groupOutput(2),
|
||||
// muteGain(3), fader(4) — the group bus is built lazily inside a's
|
||||
// schedule call — then a's lane gain(5). Then b: b-gain(6), b's lane gain(7).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8);
|
||||
// schedule call — then a's lane(5) and clip gain(6). Then b: b-gain(7), lane(8), clip gain(9).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(10);
|
||||
const aGain = mock.gainNodes[0]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
const bGain = mock.gainNodes[6]!;
|
||||
const bGain = mock.gainNodes[7]!;
|
||||
|
||||
// Both members feed the shared bus — neither connects straight to master.
|
||||
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
||||
@@ -1143,8 +1245,8 @@ describe("WebAudioTransport", () => {
|
||||
const gainCountAfterFirst = mock.gainNodes.length; // a's gains + group input/output/mute/fader
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Only b's own gain and lane gain are new — no second group bus minted.
|
||||
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
|
||||
// Only b's own gain, lane and clip gain are new — no second group bus minted.
|
||||
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 3);
|
||||
});
|
||||
|
||||
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
|
||||
@@ -1337,8 +1439,8 @@ describe("WebAudioTransport", () => {
|
||||
const nodesAfterFirst = setup.mock.gainNodes.length;
|
||||
await scheduleGrouped(setup.transport, setup.gen, "b");
|
||||
|
||||
// The second member adds its own element gain and lane gain, and nothing else.
|
||||
expect(setup.mock.gainNodes.length).toBe(nodesAfterFirst + 2);
|
||||
// The second member adds its own element gain, lane and clip gain, and nothing else.
|
||||
expect(setup.mock.gainNodes.length).toBe(nodesAfterFirst + 3);
|
||||
expect(setup.mock.masterGain.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
attachElementFxChain,
|
||||
readElementAutomation,
|
||||
scheduleVolume,
|
||||
type ElementFxHandle,
|
||||
type FxAttributes,
|
||||
type VolumeTarget,
|
||||
} from "./audioFx.js";
|
||||
import {
|
||||
clearParamLane,
|
||||
scheduleParamLane,
|
||||
volumeLane,
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||
import { clearParamLane, type AutomationTiming } from "../audio/audioFxAutomation.js";
|
||||
import {
|
||||
audioGroupOf,
|
||||
readAudioGroupVolume,
|
||||
@@ -95,36 +91,25 @@ function startBoundedSource(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The volume lane rides the fader, after the effects — where a DAW puts it,
|
||||
* and the order the render bakes it in.
|
||||
*
|
||||
* Typed against the attribute reader rather than `HTMLMediaElement` so a group
|
||||
* bus (an `<hf-audio-group>`, not a media element) can ride the same path.
|
||||
*/
|
||||
function scheduleVolumeLane(
|
||||
el: { getAttribute?(name: string): string | null },
|
||||
gainNode: GainNode,
|
||||
timing: AutomationTiming,
|
||||
): void {
|
||||
const lane = volumeLane(readElementAutomation(el));
|
||||
if (!lane) return;
|
||||
scheduleParamLane([{ param: gainNode.gain }], lane, VOLUME_RANGE.scale, timing);
|
||||
}
|
||||
|
||||
// FX, then the volume lane on its own gain ahead of `gainNode` (rewritten every tick); the FX
|
||||
// handle re-aims the lane on rate changes and edits.
|
||||
// Clip gain, FX, then the volume lane on its own gain ahead of `gainNode` (all but the lane
|
||||
// rewritten every tick); the FX handle re-aims the lane on rate changes and edits.
|
||||
function attachClipStages(
|
||||
ctx: AudioContext,
|
||||
el: HTMLMediaElement,
|
||||
source: AudioNode,
|
||||
gainNode: GainNode,
|
||||
timing: AutomationTiming,
|
||||
): { fx: ElementFxHandle | null; laneNode: GainNode } {
|
||||
): { fx: ElementFxHandle | null; laneNode: GainNode; clipGainNode: GainNode } {
|
||||
const laneNode = ctx.createGain();
|
||||
laneNode.connect(gainNode);
|
||||
const fx = attachElementFxChain(ctx, el, source, laneNode, timing, undefined, laneNode.gain);
|
||||
return { fx, laneNode };
|
||||
const clipGainNode = ctx.createGain();
|
||||
source.connect(clipGainNode);
|
||||
const fx = attachElementFxChain(ctx, el, clipGainNode, laneNode, timing, undefined, {
|
||||
param: laneNode.gain,
|
||||
gain: () => readAudioGroupVolume(el),
|
||||
gainAhead: true,
|
||||
});
|
||||
return { fx, laneNode, clipGainNode };
|
||||
}
|
||||
|
||||
type ScheduledSourceBase = {
|
||||
@@ -132,6 +117,8 @@ type ScheduledSourceBase = {
|
||||
gainNode: GainNode;
|
||||
/** The clip's volume lane, between its FX and `gainNode`; unity when it has none. */
|
||||
laneNode: GainNode;
|
||||
/** `data-volume` as clip gain, between the source and its FX. */
|
||||
clipGainNode: GainNode;
|
||||
/** FX chain spliced between source and gain, when the element carries one. */
|
||||
fx?: ElementFxHandle | null;
|
||||
compositionStart: number;
|
||||
@@ -188,7 +175,7 @@ export class WebAudioTransport {
|
||||
output: GainNode;
|
||||
/** False once the group's element is gone from the document. */
|
||||
isPresent(): boolean;
|
||||
/** Post-FX fader: `data-volume` plus the volume lane. */
|
||||
/** Post-FX fader: `data-volume` times the volume lane. */
|
||||
fader: GainNode;
|
||||
muteGain: GainNode;
|
||||
/** Kept so `setRate` can re-aim this bus's FX automation, the way it does
|
||||
@@ -439,7 +426,13 @@ export class WebAudioTransport {
|
||||
gainNode.connect(
|
||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||
);
|
||||
const { fx, laneNode } = attachClipStages(this._ctx, el, sourceNode, gainNode, timing);
|
||||
const { fx, laneNode, clipGainNode } = attachClipStages(
|
||||
this._ctx,
|
||||
el,
|
||||
sourceNode,
|
||||
gainNode,
|
||||
timing,
|
||||
);
|
||||
|
||||
this._rate = safeRate;
|
||||
this._rateAnchorCtx = scheduledAt;
|
||||
@@ -452,6 +445,7 @@ export class WebAudioTransport {
|
||||
sourceKind: "media-element",
|
||||
gainNode,
|
||||
laneNode,
|
||||
clipGainNode,
|
||||
compositionStart,
|
||||
mediaStart: _mediaStart,
|
||||
scheduledAt,
|
||||
@@ -528,14 +522,18 @@ export class WebAudioTransport {
|
||||
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
||||
muteGain.connect(output);
|
||||
// The group's fader, POST-FX: `data-volume` is the static position and the
|
||||
// volume lane rides it, which is where a DAW puts it and the order the
|
||||
// render bakes it in (`scheduleVolumeLane`'s own contract). Scheduling it
|
||||
// volume lane, scaled by it, rides it, which is where a DAW puts it and the
|
||||
// order the render bakes it in. Scheduling it
|
||||
// on `input` instead put the fader ahead of the effects, so any nonlinear
|
||||
// group effect — a compressor, the Giant preset — previewed differently
|
||||
// than it rendered.
|
||||
const fader = this._ctx.createGain();
|
||||
fader.gain.value = clampGroupVolume(readAudioGroupVolume(groupEl));
|
||||
fader.connect(muteGain);
|
||||
const faderLane: VolumeTarget = {
|
||||
param: fader.gain,
|
||||
gain: () => readAudioGroupVolume(resolveEl()),
|
||||
};
|
||||
const fx = attachElementFxChain(
|
||||
this._ctx,
|
||||
groupEl ?? { getAttribute: () => null },
|
||||
@@ -543,7 +541,7 @@ export class WebAudioTransport {
|
||||
fader,
|
||||
timing,
|
||||
undefined,
|
||||
groupEl ? fader.gain : undefined,
|
||||
groupEl ? faderLane : undefined,
|
||||
);
|
||||
|
||||
this._groups.set(groupId, {
|
||||
@@ -564,7 +562,7 @@ export class WebAudioTransport {
|
||||
muteGain.gain.value = live?.hasAttribute("data-hidden") ? 0 : 1;
|
||||
fx?.reanchor(at);
|
||||
// The handle books the fader from the element the bus was built with.
|
||||
if (live && live !== groupEl) scheduleVolumeLane(live, fader, at);
|
||||
if (live && live !== groupEl) scheduleVolume(faderLane, readElementAutomation(live), at);
|
||||
},
|
||||
dispose: () => {
|
||||
try {
|
||||
@@ -618,7 +616,7 @@ export class WebAudioTransport {
|
||||
|
||||
/**
|
||||
* Group mute, preview side — a separate gain from `input`'s volume fader
|
||||
* so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
|
||||
* so a mute toggle never fights `scheduleVolume`'s ramps on the
|
||||
* same param (the same hazard the design doc flags for §2.1). A no-op
|
||||
* until the group has an active member: at that point `groupInput` reads
|
||||
* the element's own `data-hidden` for its initial value, so there is
|
||||
@@ -703,6 +701,7 @@ export class WebAudioTransport {
|
||||
try {
|
||||
sourceNode.disconnect();
|
||||
scheduled.fx?.dispose();
|
||||
scheduled.clipGainNode.disconnect();
|
||||
scheduled.laneNode.disconnect();
|
||||
scheduled.gainNode.disconnect();
|
||||
} catch {
|
||||
@@ -760,7 +759,13 @@ export class WebAudioTransport {
|
||||
gainNode.connect(
|
||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||
);
|
||||
const { fx, laneNode } = attachClipStages(this._ctx, el, sourceNode, gainNode, timing);
|
||||
const { fx, laneNode, clipGainNode } = attachClipStages(
|
||||
this._ctx,
|
||||
el,
|
||||
sourceNode,
|
||||
gainNode,
|
||||
timing,
|
||||
);
|
||||
|
||||
this._rate = safeRate;
|
||||
this._rateAnchorCtx = scheduledAt;
|
||||
@@ -780,6 +785,7 @@ export class WebAudioTransport {
|
||||
// Playhead already past the clip end — discard the nodes we built.
|
||||
sourceNode.disconnect();
|
||||
fx?.dispose();
|
||||
clipGainNode.disconnect();
|
||||
laneNode.disconnect();
|
||||
gainNode.disconnect();
|
||||
return null;
|
||||
@@ -796,6 +802,7 @@ export class WebAudioTransport {
|
||||
sourceKind: "buffer",
|
||||
gainNode,
|
||||
laneNode,
|
||||
clipGainNode,
|
||||
compositionStart,
|
||||
mediaStart,
|
||||
scheduledAt,
|
||||
@@ -881,6 +888,7 @@ export class WebAudioTransport {
|
||||
if (isBufferSource(source)) source.sourceNode.stop();
|
||||
source.sourceNode.disconnect();
|
||||
source.fx?.dispose();
|
||||
source.clipGainNode.disconnect();
|
||||
source.laneNode.disconnect();
|
||||
source.gainNode.disconnect();
|
||||
} catch {
|
||||
@@ -915,13 +923,14 @@ export class WebAudioTransport {
|
||||
* divergence this ceiling exists to close. Automation lanes hid it, because
|
||||
* they schedule ramps onto the param directly and never pass through here.
|
||||
*/
|
||||
setElementVolume(el: HTMLMediaElement, volume: number): void {
|
||||
setElementVolume(el: HTMLMediaElement, volume: number, clipGain = 1): void {
|
||||
const safeVolume = clampAudioGain(volume);
|
||||
for (const source of this._activeSources) {
|
||||
if (source.el !== el) continue;
|
||||
try {
|
||||
if (source.sourceKind === "media-element") source.el.volume = 1;
|
||||
source.gainNode.gain.value = safeVolume;
|
||||
source.clipGainNode.gain.value = clampAudioGain(clipGain);
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.setElementVolume", err);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAudioElements, volumeLaneKeyframes } from "./audioMixer.js";
|
||||
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
||||
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
||||
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
|
||||
|
||||
const lanes = (points: HfAutomationLane["points"], target = "volume") => ({
|
||||
lanes: [{ target, points }],
|
||||
@@ -15,6 +17,7 @@ describe("volumeLaneKeyframes", () => {
|
||||
]),
|
||||
5,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ time: 5, volume: 1 },
|
||||
@@ -30,6 +33,7 @@ describe("volumeLaneKeyframes", () => {
|
||||
]),
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
// Not `data-volume` at t=0 — the lane's own first value.
|
||||
expect(out?.[0]).toEqual({ time: 0, volume: 0.3 });
|
||||
@@ -43,6 +47,7 @@ describe("volumeLaneKeyframes", () => {
|
||||
]),
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
);
|
||||
expect(out?.at(-1)).toEqual({ time: 4, volume: 0.5 });
|
||||
});
|
||||
@@ -55,6 +60,7 @@ describe("volumeLaneKeyframes", () => {
|
||||
]),
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
const bent = volumeLaneKeyframes(
|
||||
lanes([
|
||||
@@ -63,6 +69,7 @@ describe("volumeLaneKeyframes", () => {
|
||||
]),
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
expect(straight?.length).toBe(2);
|
||||
expect(bent?.length ?? 0).toBeGreaterThan(30);
|
||||
@@ -73,8 +80,61 @@ describe("volumeLaneKeyframes", () => {
|
||||
expect(mid?.volume ?? 1).toBeLessThan(0.4);
|
||||
});
|
||||
|
||||
it("samples a segment bent through a via point, as preview plays it", () => {
|
||||
const out = volumeLaneKeyframes(
|
||||
lanes([
|
||||
{ t: 0, v: 0, viaX: 0.5, viaY: 0.9 },
|
||||
{ t: 2, v: 1 },
|
||||
]),
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
expect(out?.length ?? 0).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
it("bakes gain times the lane under the ceiling through a ramp that crosses it", () => {
|
||||
const out = volumeLaneKeyframes(
|
||||
lanes([
|
||||
{ t: 0, v: 0 },
|
||||
{ t: 10, v: 3 },
|
||||
]),
|
||||
0,
|
||||
10,
|
||||
2,
|
||||
);
|
||||
expect(out?.map((k) => [k.time, k.volume])).toEqual([
|
||||
[0, 0],
|
||||
[expect.closeTo((MAX_AUDIO_GAIN / 2 / 3) * 10, 9), expect.closeTo(MAX_AUDIO_GAIN, 9)],
|
||||
[10, expect.closeTo(MAX_AUDIO_GAIN, 9)],
|
||||
]);
|
||||
expect(interpolateVolumeGain(out!, 5)).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("leaves the ceiling's share after pre-FX clip gain, including at a mid-ramp seek", () => {
|
||||
const out = volumeLaneKeyframes(
|
||||
lanes([
|
||||
{ t: 0, v: 0 },
|
||||
{ t: 10, v: 3 },
|
||||
]),
|
||||
0,
|
||||
10,
|
||||
2,
|
||||
true,
|
||||
)!;
|
||||
expect(interpolateVolumeGain(out, 5) * 2).toBeCloseTo(3, 9);
|
||||
expect(interpolateVolumeGain(out, 9) * 2).toBeCloseTo(MAX_AUDIO_GAIN, 9);
|
||||
});
|
||||
|
||||
it("caps a constant lane times gain even though both factors are individually valid", () => {
|
||||
expect(volumeLaneKeyframes(lanes([{ t: 0, v: 3 }]), 0, 10, 2)).toEqual([
|
||||
{ time: 0, volume: MAX_AUDIO_GAIN },
|
||||
{ time: 10, volume: MAX_AUDIO_GAIN },
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to bake for a track whose only lane is an FX one", () => {
|
||||
expect(volumeLaneKeyframes(lanes([{ t: 0, v: 200 }], "fx.n1.frequency"), 0, 2)).toBeNull();
|
||||
expect(volumeLaneKeyframes(lanes([{ t: 0, v: 200 }], "fx.n1.frequency"), 0, 2, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -322,6 +322,31 @@ describe.skipIf(!HAS_BROWSER)("browser render", () => {
|
||||
expect(db(rms(readWav(outPath).samples))).toBeLessThan(db(rms(readWav(input).samples)) - 3);
|
||||
}, 180_000);
|
||||
|
||||
it("applies clip gain ahead of the chain, so a compressor sees it", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input, 0.5, 200);
|
||||
const chain: HfAudioFxChain = {
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
type: "compressor",
|
||||
enabled: true,
|
||||
params: { ...defaultAudioFxParams("compressor"), threshold: -40, ratio: 20 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const levelAt = async (inputGain: number): Promise<number> => {
|
||||
const outPath = join(dir, `gain-${inputGain}.wav`);
|
||||
await applyAudioFxChain(input, chain, outPath, { trackId: "t", inputGain });
|
||||
return db(rms(readWav(outPath).samples));
|
||||
};
|
||||
// After the chain, a quarter gain would land exactly 12.04 dB down; ahead of
|
||||
// it, the compressor takes most of that back.
|
||||
const drop = (await levelAt(1)) - (await levelAt(0.25));
|
||||
expect(drop).toBeGreaterThan(0);
|
||||
expect(drop).toBeLessThan(6);
|
||||
}, 180_000);
|
||||
|
||||
it("shifts pitch up an octave, matching the preview worklet", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input, 0.5, 220);
|
||||
|
||||
@@ -333,6 +333,11 @@ function applyEnvelopeToPlanes(
|
||||
}
|
||||
}
|
||||
|
||||
/** The clip's `data-volume`, ahead of the chain where an editor puts clip gain. */
|
||||
function applyInputGain(planes: readonly Float32Array[], gain = 1): void {
|
||||
if (gain !== 1) applyEnvelopeToPlanes(planes, 1, () => gain);
|
||||
}
|
||||
|
||||
/** Inputs over `aboveBytes` render in windows: Node reads no file past 2 GiB whole, and the page would hold it twice. */
|
||||
export interface AudioFxStreamingLimits {
|
||||
aboveBytes: number;
|
||||
@@ -435,6 +440,7 @@ async function renderInSegments(
|
||||
trackId: string;
|
||||
signal?: AbortSignal;
|
||||
envelope?: AudioFxEnvelope;
|
||||
inputGain?: number;
|
||||
},
|
||||
limits: AudioFxStreamingLimits,
|
||||
): Promise<boolean> {
|
||||
@@ -457,6 +463,7 @@ async function renderInSegments(
|
||||
throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
|
||||
}
|
||||
const planes = deinterleave(readFrames(inFd, header, seg.readFrom, seg.readTo), channels);
|
||||
applyInputGain(planes, options.inputGain);
|
||||
// A fresh page per window, so the browser never holds more than one window of audio.
|
||||
const page = await openPage();
|
||||
let outPlanes: Float32Array[];
|
||||
@@ -523,6 +530,7 @@ export async function applyAudioFxChain(
|
||||
signal?: AbortSignal;
|
||||
automation?: HfAutomation;
|
||||
envelope?: AudioFxEnvelope;
|
||||
inputGain?: number;
|
||||
streaming?: Partial<AudioFxStreamingLimits>;
|
||||
},
|
||||
): Promise<{ path: string; envelopeBaked: boolean }> {
|
||||
@@ -559,6 +567,7 @@ export async function applyAudioFxChain(
|
||||
}
|
||||
const input = readWav(inputWav);
|
||||
const planes = deinterleave(input.samples, input.channels);
|
||||
applyInputGain(planes, options.inputGain);
|
||||
// An empty track has nothing to process — and an OfflineAudioContext of zero
|
||||
// length throws, which is fatal for the WHOLE render rather than this track:
|
||||
// the error travels past the mixer's per-track failure collector. ffmpeg
|
||||
|
||||
@@ -299,6 +299,76 @@ describe.skipIf(!HAS_FFMPEG)(
|
||||
expect(Math.abs(groupedTail - flatTail)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("applies a clip's data-volume as a gain on its volume lane", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-clip-lane-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-clip-lane-work-"));
|
||||
tempDirs.push(projectDir, workDir);
|
||||
|
||||
writeTone(join(projectDir, "a.wav"), 440, 2, 0.5);
|
||||
const laneOut = join(projectDir, `lane-${MIXED_AUDIO_FILENAME}`);
|
||||
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
||||
const automation = JSON.stringify({
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 1 },
|
||||
{ t: 2, v: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lane = await processCompositionAudio(
|
||||
[{ ...track("a", 2, 0.5), automation }],
|
||||
projectDir,
|
||||
workDir,
|
||||
laneOut,
|
||||
2,
|
||||
);
|
||||
const flat = await processCompositionAudio([track("a", 2)], projectDir, workDir, flatOut, 2);
|
||||
expect(lane.success).toBe(true);
|
||||
expect(flat.success).toBe(true);
|
||||
|
||||
expect(meanVolumeDb(flatOut) - meanVolumeDb(laneOut)).toBeCloseTo(6.02, 0);
|
||||
});
|
||||
|
||||
it("applies a group's data-volume as a gain on its volume lane", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-lane-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-lane-work-"));
|
||||
tempDirs.push(projectDir, workDir);
|
||||
|
||||
writeTone(join(projectDir, "a.wav"), 440, 2, 0.5);
|
||||
const laneOut = join(projectDir, `lane-${MIXED_AUDIO_FILENAME}`);
|
||||
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
||||
const groupAutomation = JSON.stringify({
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 1 },
|
||||
{ t: 2, v: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lane = await processCompositionAudio(
|
||||
[{ ...track("a", 2), groupId: "vo", groupVolume: 0.5, groupAutomation }],
|
||||
projectDir,
|
||||
workDir,
|
||||
laneOut,
|
||||
2,
|
||||
);
|
||||
const flat = await processCompositionAudio([track("a", 2)], projectDir, workDir, flatOut, 2);
|
||||
expect(lane.success).toBe(true);
|
||||
expect(flat.success).toBe(true);
|
||||
|
||||
expect(meanVolumeDb(flatOut) - meanVolumeDb(laneOut)).toBeCloseTo(6.02, 0);
|
||||
});
|
||||
|
||||
// Members sum at unity (normalize=0), so an over-unity sum used to hard-clip
|
||||
// at ±1 in the 16-bit intermediate BEFORE the group's fader and FX chain ran
|
||||
// — pulling the group down then operated on distortion. Every existing probe
|
||||
|
||||
@@ -602,6 +602,71 @@ describe("processCompositionAudio", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
gain: 0.5,
|
||||
level: 1,
|
||||
points: [
|
||||
{ time: 0, volume: 0 },
|
||||
{ time: 2, volume: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
gain: 2,
|
||||
level: 3,
|
||||
points: [
|
||||
{ time: 0, volume: 0 },
|
||||
{ time: expect.closeTo(1.3270239018, 8), volume: expect.closeTo(1.9905358528, 8) },
|
||||
{ time: 2, volume: expect.closeTo(1.9905358528, 8) },
|
||||
],
|
||||
},
|
||||
])(
|
||||
"applies pre-FX clip gain $gain with a ceiling-aware lane",
|
||||
async ({ gain, level, points }) => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
writeFileSync(join(baseDir, "vo.wav"), "stub");
|
||||
const automation = JSON.stringify({
|
||||
version: 1,
|
||||
lanes: [
|
||||
{
|
||||
target: "volume",
|
||||
points: [
|
||||
{ t: 0, v: 0 },
|
||||
{ t: 2, v: level },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "vo",
|
||||
src: "vo.wav",
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: gain,
|
||||
automation,
|
||||
fxChain: gainChain,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
const fxOptions = applyAudioFxChainMock.mock.calls[0]?.[3];
|
||||
expect(fxOptions?.inputGain).toBe(gain);
|
||||
expect(fxOptions?.envelope?.keyframes).toEqual(points);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["the master pass", {}, { masterFxChain: gainChain }],
|
||||
["a group bus", { groupId: "bus", groupFxChain: gainChain }, {}],
|
||||
|
||||
@@ -15,7 +15,7 @@ import { parseHTML } from "linkedom";
|
||||
import { extractAudioMetadata, type AudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isNotMediaPayload } from "../utils/notMediaPayload.js";
|
||||
import { urlInputArgs } from "../utils/urlInput.js";
|
||||
import { clampAudioGain } from "@hyperframes/core/audio-gain";
|
||||
import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
|
||||
import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-fade";
|
||||
import {
|
||||
downloadToTemp,
|
||||
@@ -40,12 +40,17 @@ import type {
|
||||
} from "./audioMixer.types.js";
|
||||
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
||||
import {
|
||||
enabledAudioFxNodes,
|
||||
HF_AUDIO_FX_ATTR,
|
||||
parseAudioFxChain,
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import {
|
||||
gainedVolume,
|
||||
gainedVolumePoints,
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
isBentSegment,
|
||||
volumeSegmentSampleCount,
|
||||
parseAutomation,
|
||||
resolveAutomation,
|
||||
sampleAutomationLane,
|
||||
@@ -487,9 +492,6 @@ function ffmpegFailure(
|
||||
};
|
||||
}
|
||||
|
||||
/** Extra samples per second inside a segment the baker cannot draw straight. */
|
||||
const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
|
||||
|
||||
/**
|
||||
* A volume lane as keyframes for the PCM baker.
|
||||
*
|
||||
@@ -498,15 +500,19 @@ const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
|
||||
* sampled, or the bake would quietly straighten it.
|
||||
*
|
||||
* Times come out in composition seconds, which is what the baker subtracts the
|
||||
* track start from. Returns null when the track has no volume lane.
|
||||
* track start from. Values are `gainedVolume` of the track's `data-volume`.
|
||||
* Returns null when the track has no volume lane.
|
||||
*/
|
||||
export function volumeLaneKeyframes(
|
||||
automation: { lanes: HfAutomationLane[] },
|
||||
trackStart: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
gainAhead = false,
|
||||
): AudioVolumeKeyframe[] | null {
|
||||
const lane = automation.lanes.find((l) => l.target === VOLUME_TARGET);
|
||||
if (!lane || lane.points.length === 0) return null;
|
||||
const points = gainedVolumePoints(lane, gain, gainAhead);
|
||||
|
||||
const out: AudioVolumeKeyframe[] = [];
|
||||
const push = (t: number, v: number): void => {
|
||||
@@ -515,24 +521,24 @@ export function volumeLaneKeyframes(
|
||||
|
||||
// The envelope holds its first value before the first point, rather than
|
||||
// falling back to `data-volume`.
|
||||
const first = lane.points[0]!;
|
||||
const first = points[0]!;
|
||||
if (first.t > 0) push(0, first.v);
|
||||
|
||||
for (let i = 0; i < lane.points.length; i += 1) {
|
||||
const a = lane.points[i]!;
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const a = points[i]!;
|
||||
push(a.t, a.v);
|
||||
const b = lane.points[i + 1];
|
||||
if (!b || !a.curve) continue;
|
||||
const steps = Math.max(2, Math.ceil((b.t - a.t) * CURVED_SEGMENT_SAMPLES_PER_SEC));
|
||||
const b = points[i + 1];
|
||||
if (!b || !isBentSegment(a)) continue;
|
||||
const steps = volumeSegmentSampleCount(b.t - a.t);
|
||||
for (let k = 1; k < steps; k += 1) {
|
||||
const t = a.t + ((b.t - a.t) * k) / steps;
|
||||
push(t, sampleAutomationLane(lane, t));
|
||||
push(t, gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead));
|
||||
}
|
||||
}
|
||||
|
||||
// Hold the last value to the clip's end, so the baker does not ramp away
|
||||
// from it toward whatever it would otherwise assume.
|
||||
const last = lane.points[lane.points.length - 1]!;
|
||||
const last = points[points.length - 1]!;
|
||||
if (duration > last.t) push(duration, last.v);
|
||||
return out;
|
||||
}
|
||||
@@ -1345,11 +1351,9 @@ export async function processCompositionAudio(
|
||||
// envelope belongs on their output. A missing or broken chain is fatal
|
||||
// for the whole mix rather than a per-track warning — quietly rendering
|
||||
// the dry signal ships a mix that sounds plausible and is wrong.
|
||||
const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
|
||||
const automation = element.automation
|
||||
? resolveAutomation(
|
||||
parseAutomation(element.automation),
|
||||
element.fxChain ? parseAudioFxChain(element.fxChain) : undefined,
|
||||
)
|
||||
? resolveAutomation(parseAutomation(element.automation), chain)
|
||||
: null;
|
||||
|
||||
// Computed before the chain runs, not after: the FX pass bakes the
|
||||
@@ -1359,9 +1363,19 @@ export async function processCompositionAudio(
|
||||
// A volume lane supersedes keyframes probed from the timeline: the two
|
||||
// would fight, and the lane is the explicit one. `lint` warns when a
|
||||
// track carries both.
|
||||
//
|
||||
// With clip gain ahead of the chain, the lane carries its share of the capped product.
|
||||
const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
|
||||
const laneKeyframes = automation
|
||||
? volumeLaneKeyframes(automation, element.start, element.end - element.start)
|
||||
? volumeLaneKeyframes(
|
||||
automation,
|
||||
element.start,
|
||||
element.end - element.start,
|
||||
element.volume ?? 1,
|
||||
gainBeforeFx,
|
||||
)
|
||||
: null;
|
||||
const tweened = !laneKeyframes && (element.volumeKeyframes?.length ?? 0) > 0;
|
||||
const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes;
|
||||
const envelope =
|
||||
envelopeKeyframes && envelopeKeyframes.length > 0
|
||||
@@ -1374,11 +1388,10 @@ export async function processCompositionAudio(
|
||||
|
||||
let bakedEnvelope = false;
|
||||
let tailSeconds = 0;
|
||||
if (element.fxChain) {
|
||||
if (chain) {
|
||||
// The chain is serialised into the attribute, the same way colour
|
||||
// grading carries its config, so there is no side-car file to find,
|
||||
// resolve or lose.
|
||||
const chain = parseAudioFxChain(element.fxChain);
|
||||
// The rendered WAV is longer than the input by exactly this much, so
|
||||
// the mix has to be told to let it through.
|
||||
tailSeconds = chainTailSeconds(chain, automation ?? undefined);
|
||||
@@ -1391,6 +1404,7 @@ export async function processCompositionAudio(
|
||||
signal: effectiveSignal,
|
||||
...(automation ? { automation } : {}),
|
||||
...(envelope ? { envelope } : {}),
|
||||
...(gainBeforeFx ? { inputGain: clipGain(element.volume ?? 1, tweened) } : {}),
|
||||
},
|
||||
);
|
||||
audioSrcPath = fxResult.path;
|
||||
@@ -1418,7 +1432,7 @@ export async function processCompositionAudio(
|
||||
mediaStart: element.mediaStart,
|
||||
duration: element.end - element.start,
|
||||
// Gain is already in the samples when baked, so mix at unity.
|
||||
volume: bakedEnvelope ? 1.0 : (element.volume ?? 1.0),
|
||||
volume: bakedEnvelope || (gainBeforeFx && !tweened) ? 1.0 : (element.volume ?? 1.0),
|
||||
volumeKeyframes: bakedEnvelope ? undefined : (envelopeKeyframes ?? undefined),
|
||||
...(element.fadeIn ? { fadeIn: element.fadeIn } : {}),
|
||||
...(element.fadeOut ? { fadeOut: element.fadeOut } : {}),
|
||||
@@ -1576,7 +1590,9 @@ export async function processCompositionAudio(
|
||||
meta.fxChain ? parseAudioFxChain(meta.fxChain) : undefined,
|
||||
)
|
||||
: null;
|
||||
const laneKeyframes = automation ? volumeLaneKeyframes(automation, 0, totalDuration) : null;
|
||||
const laneKeyframes = automation
|
||||
? volumeLaneKeyframes(automation, 0, totalDuration, meta.volume)
|
||||
: null;
|
||||
const envelope =
|
||||
laneKeyframes && laneKeyframes.length > 0
|
||||
? { keyframes: laneKeyframes, trackStart: 0, baseVolume: meta.volume }
|
||||
|
||||
@@ -777,7 +777,7 @@ describe("audio_volume_tween_overrides_gain", () => {
|
||||
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 () => {
|
||||
it("stays quiet at unity, without a tween, or when a lane makes the tween inert", async () => {
|
||||
const unity = await lintHyperframeHtml(
|
||||
withScript(`data-volume="1"`, `tl.to("#bgm", { volume: 0 });`),
|
||||
);
|
||||
|
||||
@@ -875,7 +875,7 @@ function findVolumeTweenOverridesGainFindings(ctx: LintContext): HyperframeLintF
|
||||
// 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.
|
||||
// A lane silences the tween (its own rule says so) and scales by data-volume.
|
||||
.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));
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DomEditSelection } from "./domEditing";
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
import { formatTimingValue } from "./propertyPanelHelpers";
|
||||
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
||||
import { audioGainToFaderPosition } from "@hyperframes/core/audio-gain";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -163,7 +164,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits +12 dB of boost from the upper half of the volume fader", () => {
|
||||
it("commits +12 dB of boost from the upper half of the gain fader", () => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
|
||||
const host = document.createElement("div");
|
||||
@@ -182,7 +183,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
|
||||
const volumeTrack = host.querySelector('[data-flat-slider-track="true"][aria-label="Gain"]')!;
|
||||
Object.defineProperty(volumeTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
@@ -197,10 +198,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("writes through the envelope instead of the attribute once volume is automated", () => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const onCommitVolumeAt = vi.fn();
|
||||
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
|
||||
it("shows the clip's gain and its lane's level as two sliders", () => {
|
||||
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -211,28 +210,62 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
element={element}
|
||||
styles={{}}
|
||||
onSetStyle={vi.fn()}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetAttribute={vi.fn()}
|
||||
onSetHtmlAttribute={vi.fn()}
|
||||
onSetAttributeBatch={vi.fn()}
|
||||
volumeAutomated
|
||||
onCommitVolumeAt={onCommitVolumeAt}
|
||||
automatedVolumeValue={0.25}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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: 100 }));
|
||||
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
|
||||
});
|
||||
expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
|
||||
expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
|
||||
const [gain, volume] = host.querySelectorAll('[data-flat-slider-track="true"]');
|
||||
expect(labelsOf(host).slice(0, 2)).toEqual(["Gain", "Volume"]);
|
||||
expect(gain?.getAttribute("aria-valuenow")).toBe(String(audioGainToFaderPosition(0.5)));
|
||||
expect(volume?.getAttribute("aria-valuenow")).toBe(String(audioGainToFaderPosition(0.25)));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"writes Volume through the lane, never data-volume (automated: %s)",
|
||||
(automated) => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const onCommitVolumeAt = vi.fn();
|
||||
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()}
|
||||
onSetAttributeBatch={vi.fn()}
|
||||
volumeAutomated={automated}
|
||||
onCommitVolumeAt={onCommitVolumeAt}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const volumeTrack = host.querySelector(
|
||||
'[data-flat-slider-track="true"][aria-label="Volume"]',
|
||||
)!;
|
||||
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: 100 }));
|
||||
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
|
||||
});
|
||||
expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
|
||||
expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
|
||||
act(() => root.unmount());
|
||||
},
|
||||
);
|
||||
|
||||
it("commits a new rate value on slider track pointerdown", () => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const element = makeVideoElement();
|
||||
@@ -252,7 +285,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1];
|
||||
const rateTrack = host.querySelector('[data-flat-slider-track="true"][aria-label="Speed"]')!;
|
||||
Object.defineProperty(rateTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
@@ -284,7 +317,9 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2];
|
||||
const mediaStartTrack = host.querySelector(
|
||||
'[data-flat-slider-track="true"][aria-label="Media start"]',
|
||||
)!;
|
||||
Object.defineProperty(mediaStartTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
@@ -465,7 +500,7 @@ describe("FlatMediaSection — audio clips", () => {
|
||||
it("shows Fade in / Fade out rows for audio, and for video only when it carries audio", () => {
|
||||
const audio = renderWithRate(makeAudioElement());
|
||||
expect(labelsOf(audio.host)).toEqual(
|
||||
expect.arrayContaining(["Volume", "Speed", "Media start", "Fade in", "Fade out"]),
|
||||
expect.arrayContaining(["Gain", "Volume", "Speed", "Media start", "Fade in", "Fade out"]),
|
||||
);
|
||||
act(() => audio.root.unmount());
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function FlatMediaSection({
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
onSetAttributeBatch: CommitDomAttributeBatch;
|
||||
/** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
|
||||
/** A volume lane in the timeline drives the level; the Volume slider writes a keyframe. */
|
||||
volumeAutomated?: boolean;
|
||||
onAutomateVolume?: () => void;
|
||||
onRemoveVolumeAutomation?: () => void;
|
||||
@@ -88,14 +88,10 @@ export function FlatMediaSection({
|
||||
const isVisualMedia = isVideo || isImage;
|
||||
const el = element.element;
|
||||
|
||||
// While a lane owns the level, the envelope's live value at the playhead is
|
||||
// what the slider must show — the static attribute is only what the engine
|
||||
// falls back to outside automation.
|
||||
const volume =
|
||||
volumeAutomated && automatedVolumeValue !== undefined
|
||||
? automatedVolumeValue
|
||||
: (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
|
||||
const volumeFaderPosition = audioGainToFaderPosition(volume);
|
||||
// Gain is `data-volume`, ahead of the effects; Volume is the lane's level at the
|
||||
// playhead, unity without one. What plays is gain times level, as in an editor.
|
||||
const gain = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
|
||||
const volume = volumeAutomated && automatedVolumeValue !== undefined ? automatedVolumeValue : 1;
|
||||
const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
|
||||
const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
||||
const playbackRate =
|
||||
@@ -246,9 +242,25 @@ export function FlatMediaSection({
|
||||
)}
|
||||
{(isVideo || isAudio) && (
|
||||
<>
|
||||
{/* While a lane owns the level, a commit writes a keyframe at the
|
||||
playhead instead of the plain attribute — same slider, same
|
||||
gesture, the write just goes through the envelope. */}
|
||||
<FlatSlider
|
||||
label="Gain"
|
||||
value={audioGainToFaderPosition(gain)}
|
||||
min={AUDIO_GAIN_FADER_MIN}
|
||||
max={AUDIO_GAIN_FADER_MAX}
|
||||
tier={gain === 1 ? "default" : "explicitCustom"}
|
||||
displayValue={audioGainToText(gain)}
|
||||
centerTick
|
||||
onCommit={(next) =>
|
||||
void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
|
||||
}
|
||||
onCommitText={(text) => {
|
||||
const next = parseGainInput(text);
|
||||
if (next === null) return false;
|
||||
void onSetAttribute("volume", formatAudioGain(next));
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
{/* A commit writes a keyframe at the playhead, starting a lane when there is none. */}
|
||||
<div
|
||||
className="hf-volume-row flex items-center gap-1"
|
||||
data-volume-automated={volumeAutomated ? "" : undefined}
|
||||
@@ -256,25 +268,17 @@ export function FlatMediaSection({
|
||||
<div className="min-w-0 flex-1">
|
||||
<FlatSlider
|
||||
label="Volume"
|
||||
value={volumeFaderPosition}
|
||||
value={audioGainToFaderPosition(volume)}
|
||||
min={AUDIO_GAIN_FADER_MIN}
|
||||
max={AUDIO_GAIN_FADER_MAX}
|
||||
tier={volume === 1 ? "default" : "explicitCustom"}
|
||||
displayValue={audioGainToText(volume)}
|
||||
centerTick
|
||||
onCommit={(next) => {
|
||||
const gain = audioFaderPositionToGain(next);
|
||||
if (volumeAutomated) {
|
||||
onCommitVolumeAt?.(gain);
|
||||
} else {
|
||||
void onSetAttribute("volume", formatAudioGain(gain));
|
||||
}
|
||||
}}
|
||||
onCommit={(next) => onCommitVolumeAt?.(audioFaderPositionToGain(next))}
|
||||
onCommitText={(text) => {
|
||||
const gain = parseGainInput(text);
|
||||
if (gain === null) return false;
|
||||
if (volumeAutomated) onCommitVolumeAt?.(gain);
|
||||
else void onSetAttribute("volume", formatAudioGain(gain));
|
||||
const next = parseGainInput(text);
|
||||
if (next === null) return false;
|
||||
onCommitVolumeAt?.(next);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -59,22 +59,16 @@ describe("useVolumeAutomation", () => {
|
||||
expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds a new lane at the level the slider already shows", () => {
|
||||
it("seeds a new lane at unity, which data-volume then multiplies", () => {
|
||||
// Automating a track must not change how loud it is.
|
||||
const { binding, onSetAttributeQuiet } = bind({ volume: "0.55" });
|
||||
act(() => binding.onAutomateVolume());
|
||||
expect(onSetAttributeQuiet).toHaveBeenCalledWith(
|
||||
"data-automation",
|
||||
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
|
||||
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a missing data-volume as unity", () => {
|
||||
const { binding, onSetAttributeQuiet } = bind({});
|
||||
act(() => binding.onAutomateVolume());
|
||||
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps FX lanes when adding the volume one", () => {
|
||||
const automation = JSON.stringify({
|
||||
version: 1,
|
||||
|
||||
@@ -108,13 +108,8 @@ export function useVolumeAutomation(
|
||||
onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
|
||||
};
|
||||
};
|
||||
// `??` alone would let an empty `data-volume` through as Number("") === 0, so
|
||||
// automating the track would seed its lane at silence. The engine reads the same
|
||||
// empty value as unity.
|
||||
const raw = element.dataAttributes?.["volume"];
|
||||
const parsed = raw ? Number(raw) : 1;
|
||||
const current = Number.isFinite(parsed) ? parsed : 1;
|
||||
const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
|
||||
// Unity: `data-volume` multiplies the lane, so seeding it there would play it twice.
|
||||
const volume = laneBinding(VOLUME_TARGET, 1, writeOwn);
|
||||
const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
|
||||
const rate = laneBinding(
|
||||
RATE_TARGET,
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
{
|
||||
"@hyperframes/aws-lambda": { "packed": 70874, "unpacked": 258166 },
|
||||
"@hyperframes/cli": { "packed": 7902966, "unpacked": 23722874 },
|
||||
"@hyperframes/core": { "packed": 1636442, "unpacked": 3936240 },
|
||||
"@hyperframes/engine": { "packed": 428347, "unpacked": 1436293 },
|
||||
"@hyperframes/gcp-cloud-run": { "packed": 49412, "unpacked": 192672 },
|
||||
"@hyperframes/inspector": { "packed": 808905, "unpacked": 3645432 },
|
||||
"@hyperframes/lint": { "packed": 168745, "unpacked": 654607 },
|
||||
"@hyperframes/parsers": { "packed": 191000, "unpacked": 831243 },
|
||||
"@hyperframes/player": { "packed": 157145, "unpacked": 541601 },
|
||||
"@hyperframes/producer": { "packed": 768210, "unpacked": 2807938 },
|
||||
"@hyperframes/sdk": { "packed": 90662, "unpacked": 325069 },
|
||||
"@hyperframes/shader-transitions": { "packed": 212864, "unpacked": 825937 },
|
||||
"@hyperframes/studio": { "packed": 5998328, "unpacked": 24360208 },
|
||||
"@hyperframes/studio-server": { "packed": 141718, "unpacked": 559199 }
|
||||
"@hyperframes/aws-lambda": {
|
||||
"packed": 70874,
|
||||
"unpacked": 258166
|
||||
},
|
||||
"@hyperframes/cli": {
|
||||
"packed": 7902966,
|
||||
"unpacked": 23722874
|
||||
},
|
||||
"@hyperframes/core": {
|
||||
"packed": 1636442,
|
||||
"unpacked": 3942104
|
||||
},
|
||||
"@hyperframes/engine": {
|
||||
"packed": 428347,
|
||||
"unpacked": 1436293
|
||||
},
|
||||
"@hyperframes/gcp-cloud-run": {
|
||||
"packed": 49412,
|
||||
"unpacked": 192672
|
||||
},
|
||||
"@hyperframes/inspector": {
|
||||
"packed": 808905,
|
||||
"unpacked": 3645432
|
||||
},
|
||||
"@hyperframes/lint": {
|
||||
"packed": 168745,
|
||||
"unpacked": 654607
|
||||
},
|
||||
"@hyperframes/parsers": {
|
||||
"packed": 191000,
|
||||
"unpacked": 831243
|
||||
},
|
||||
"@hyperframes/player": {
|
||||
"packed": 157145,
|
||||
"unpacked": 541601
|
||||
},
|
||||
"@hyperframes/producer": {
|
||||
"packed": 768210,
|
||||
"unpacked": 2807938
|
||||
},
|
||||
"@hyperframes/sdk": {
|
||||
"packed": 90662,
|
||||
"unpacked": 325069
|
||||
},
|
||||
"@hyperframes/shader-transitions": {
|
||||
"packed": 212864,
|
||||
"unpacked": 825937
|
||||
},
|
||||
"@hyperframes/studio": {
|
||||
"packed": 5998328,
|
||||
"unpacked": 24360208
|
||||
},
|
||||
"@hyperframes/studio-server": {
|
||||
"packed": 141718,
|
||||
"unpacked": 559199
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user