Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/core/src/audioAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";
import { MAX_PLAYBACK_RATE, MIN_PLAYBACK_RATE } from "./playbackRateBounds.js";

export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";

Expand Down Expand Up @@ -80,14 +81,19 @@ export class AudioAutomationError extends Error {

export const VOLUME_TARGET = "volume";

/** Playback-rate lane: `v` is a speed multiplier over clip-local time. See `speedRamp.ts`. */
export const RATE_TARGET = "rate";

export type HfAutomationTarget =
| { kind: "volume" }
| { kind: "rate" }
| { kind: "fx"; nodeId: string; param: string }
| { kind: "preset"; presetId: string };

/** Split a target string. Returns null for anything unrecognised. */
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
if (target === VOLUME_TARGET) return { kind: "volume" };
if (target === RATE_TARGET) return { kind: "rate" };
const parts = target.split(".");
// `fx.preset.<id>` before the 3-part fx form, because it IS a 3-part fx form
// with a reserved node id — an effect can never be called "preset", since ids
Expand Down Expand Up @@ -169,6 +175,16 @@ export const VOLUME_RANGE: AutomationRange = {
default: 1,
};

export const RATE_RANGE: AutomationRange = {
min: MIN_PLAYBACK_RATE,
max: MAX_PLAYBACK_RATE,
step: 0.05,
unit: "x",
label: "Speed",
scale: "log",
default: 1,
};

/**
* Resolve a lane's target against a chain. Returns null when the target names
* a node or parameter that is not there — the effect was deleted, or the
Expand All @@ -181,6 +197,7 @@ export function resolveAutomationRange(
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return VOLUME_RANGE;
if (parsed.kind === "rate") return RATE_RANGE;
if (parsed.kind === "preset") {
// Only for a preset the chain actually carries, so a lane left behind by a
// removed preset resolves to nothing and is dropped at read time — the same
Expand Down Expand Up @@ -335,7 +352,12 @@ export function normalizeAutomation(automation: HfAutomation): HfAutomation {
const lanes: HfAutomationLane[] = [];
for (const lane of automation.lanes) {
if (!parseAutomationTarget(lane.target)) continue;
const range = lane.target === VOLUME_TARGET ? VOLUME_RANGE : null;
const range =
lane.target === VOLUME_TARGET
? VOLUME_RANGE
: lane.target === RATE_TARGET
? RATE_RANGE
: null;
const points = normalizePoints(lane.points ?? [], range);
if (points.length > 0) lanes.push({ target: lane.target, points });
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/playbackRateBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** The one clamp for a playback rate: the constant attribute and the rate lane alike. */
export const MIN_PLAYBACK_RATE = 0.1;
export const MAX_PLAYBACK_RATE = 10;
10 changes: 6 additions & 4 deletions packages/core/src/runtime/audioAutomationVolume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ import {
const cache = new Map<string, HfAutomationLane | null>();
const CACHE_LIMIT = 64;

function laneFromAttr(raw: string): HfAutomationLane | null {
const hit = cache.get(raw);
/** The lane for `target` inside a `data-automation` value, or null when absent or unreadable. */
export function laneFromAttr(raw: string, target: string = VOLUME_TARGET): HfAutomationLane | null {
const key = `${target}\0${raw}`;
const hit = cache.get(key);
if (hit !== undefined) return hit;
let lane: HfAutomationLane | null = null;
try {
lane = parseAutomation(raw).lanes.find((l) => l.target === VOLUME_TARGET) ?? null;
lane = parseAutomation(raw).lanes.find((l) => l.target === target) ?? null;
} catch {
// Unreadable automation plays the track flat rather than silencing it.
lane = null;
}
if (cache.size > CACHE_LIMIT) cache.clear();
cache.set(raw, lane);
cache.set(key, lane);
return lane;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/runtime/clock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,21 @@ describe("TransportClock", () => {
expect(clock.getSource()).toBe("monotonic");
});

it("maps audio position back to composition time through a rate lane", () => {
const { clock } = createClock({ duration: 20 });
const rate = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
};
const audioEl = createMockAudioEl(4, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 1, mediaStart: 0, rate });
expect(clock.now()).toBeCloseTo(1 + 2 + (4 - 3.641) / 3, 2);
});

it("accounts for compositionStart offset", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(2.0, false);
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/runtime/clock.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { timeAtSourceTime, type RateSpec } from "../speedRamp.js";

export type TransportClockSnapshot = {
time: number;
playing: boolean;
Expand All @@ -11,6 +13,8 @@ export type AudioClockSource =
el: HTMLMediaElement;
compositionStart: number;
mediaStart: number;
/** The clip's rate lane; a constant rate is read from `el.playbackRate`. */
rate?: RateSpec;
}
| {
currentTimeSeconds: number;
Expand Down Expand Up @@ -54,12 +58,14 @@ export class TransportClock {
if ("currentTimeSeconds" in this._audioSource) {
audioTime = this._audioSource.currentTimeSeconds;
} else {
const { el, compositionStart, mediaStart } = this._audioSource;
const { el, compositionStart, mediaStart, rate } = this._audioSource;
if (!el.paused && Number.isFinite(el.currentTime)) {
audioTime =
((el.currentTime - mediaStart) / (el.playbackRate > 0 ? el.playbackRate : 1)) *
this._rate +
compositionStart;
typeof rate === "object"
? timeAtSourceTime(rate, el.currentTime - mediaStart) + compositionStart
: ((el.currentTime - mediaStart) / (el.playbackRate > 0 ? el.playbackRate : 1)) *
this._rate +
compositionStart;
}
}
if (audioTime !== null) {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4171,6 +4171,20 @@ describe("derived duration floor recomputation", () => {
expect(await runFrames(1)).toBe(25);
});

it("re-derives when a speed-ramp lane is edited", async () => {
mountComposition(`<video data-start="0"></video>`);
const video = document.querySelector("video")!;
setNativeDuration(video, 10);
initSandboxRuntimeModular();
expect(await runFrames(2)).toBe(10);

video.setAttribute(
"data-automation",
JSON.stringify({ version: 1, lanes: [{ target: "rate", points: [{ t: 0, v: 2 }] }] }),
);
expect(await runFrames(1)).toBe(5);
});

it("re-derives when a clip is moved later on the timeline", async () => {
mountComposition(`<video data-start="0" data-duration="10"></video>`);
initSandboxRuntimeModular();
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import {
patchWebGLVideoTextureCompat,
} from "./adapters/video-texture-compat";
import { forceDispatchSeekEvent, waitForSeekCompletion } from "./adapters/seek-dispatch";
import { sourceTimeAt } from "../speedRamp";
import { createWaapiAdapter } from "./adapters/waapi";
import {
readElementPlaybackRate,
readElementRateSpec,
readElementPlaybackStart,
refreshRuntimeMediaCache,
resolveRuntimeMediaClipDuration,
Expand Down Expand Up @@ -1068,6 +1070,7 @@ export function initSandboxRuntimeModular(): void {
"data-hf-auto-start",
MEDIA_START_BASIS_ATTR,
"data-playback-rate",
"data-automation",
"data-playback-start",
"data-media-start",
// `data-start` may be an expression referencing another element by id, and
Expand Down Expand Up @@ -3366,7 +3369,7 @@ export function initSandboxRuntimeModular(): void {
const timelineDuration = getTimelineDurationSeconds(timeline);
const sourceTime =
readElementPlaybackStart(node) +
Math.max(0, timeSeconds - start) * readElementPlaybackRate(node);
sourceTimeAt(readElementRateSpec(node), Math.max(0, timeSeconds - start));
const localTime = Math.max(
0,
timelineDuration != null && timelineDuration > 0
Expand Down Expand Up @@ -3800,7 +3803,12 @@ export function initSandboxRuntimeModular(): void {
const mediaStart = readElementPlaybackStart(rawEl);
if (Number.isFinite(start) && state.currentTime >= start && state.currentTime < end) {
if (!rawEl.paused) {
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
clock.attachAudioSource({
el: rawEl,
compositionStart: start,
mediaStart,
rate: readElementRateSpec(rawEl),
});
foundActive = true;
} else if (!rawEl.error && rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
// Audio is buffering — freeze visuals at last known position
Expand Down Expand Up @@ -3953,6 +3961,8 @@ export function initSandboxRuntimeModular(): void {
// that existed before (#3458).
const route = classifyWebAudioMediaRoute(rawEl);
reportWebAudioMediaRoute(rawEl, route);
// Decoded buffers cannot follow a rate curve without shifting pitch; the media element can.
if (typeof readElementRateSpec(rawEl) !== "number") continue;
// The cross-origin verdict's BEST outcome is decode, since a CDN that
// sends `Access-Control-Allow-Origin` (the author just never wrote the
// `crossorigin` attribute) decodes fine and keeps the whole FX graph.
Expand Down
31 changes: 25 additions & 6 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ describe("readElementPlaybackRate", () => {
expect(readElementPlaybackRate(el)).toBe(1);
});

it("clamps to [0.1, 5]", () => {
it("clamps to [0.1, 10]", () => {
const el = document.createElement("video");
Object.defineProperty(el, "defaultPlaybackRate", { value: 0.01, writable: true });
expect(readElementPlaybackRate(el)).toBe(0.1);
Object.defineProperty(el, "defaultPlaybackRate", { value: 10, writable: true });
expect(readElementPlaybackRate(el)).toBe(5);
Object.defineProperty(el, "defaultPlaybackRate", { value: 20, writable: true });
expect(readElementPlaybackRate(el)).toBe(10);
});

it("defaults to 1 for NaN/negative/zero", () => {
Expand Down Expand Up @@ -165,16 +165,16 @@ describe("refreshRuntimeMediaCache", () => {
expect(result.mediaClips[0].playbackRate).toBe(1);
});

it("clamps playback rate to [0.1, 5]", () => {
it("clamps playback rate to [0.1, 10]", () => {
const el1 = createVideo({ "data-start": "0", "data-duration": "5" });
Object.defineProperty(el1, "defaultPlaybackRate", { value: 0.01, writable: true });
const r1 = refreshRuntimeMediaCache();
expect(r1.mediaClips[0].playbackRate).toBe(0.1);
document.body.innerHTML = "";
const el2 = createVideo({ "data-start": "0", "data-duration": "5" });
Object.defineProperty(el2, "defaultPlaybackRate", { value: 10, writable: true });
Object.defineProperty(el2, "defaultPlaybackRate", { value: 20, writable: true });
const r2 = refreshRuntimeMediaCache();
expect(r2.mediaClips[0].playbackRate).toBe(5);
expect(r2.mediaClips[0].playbackRate).toBe(10);
});

it("adjusts fallback duration by playback rate", () => {
Expand Down Expand Up @@ -365,6 +365,25 @@ describe("syncRuntimeMedia", () => {
document.body.innerHTML = "";
});

describe("speed ramp", () => {
it("seeks to the integrated source time and plays at the instantaneous rate", () => {
const rate = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
};
const clip = createMockClip({ start: 1, end: 5, rate });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3, playing: false, playbackRate: 1 });
expect(clip.el.currentTime).toBeCloseTo(3.641, 2);
syncRuntimeMedia({ clips: [clip], timeSeconds: 3, playing: true, playbackRate: 1 });
expect(clip.el.playbackRate).toBeCloseTo(3, 5);
expect(clip.el.preservesPitch).toBe(true);
});
});

describe("volume automation lane", () => {
const DUCK = JSON.stringify({
version: 1,
Expand Down
27 changes: 22 additions & 5 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { swallow } from "./diagnostics";
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
import { readElementPlaybackRate, readMediaStart } from "./playbackRate.js";
import { readElementPlaybackRate, readElementRateSpec, readMediaStart } from "./playbackRate.js";
import {
rateAt,
readPreservePitch,
sourceTimeAt,
timeAtSourceTime,
type RateSpec,
} from "../speedRamp.js";
import { clampAudioGain } from "../audioGain.js";
import { isMemberGroupHidden } from "../audioGroups.js";
import { findInjectedRenderFrame } from "./renderFrameSibling.js";
export { readElementPlaybackRate, resolveNaturalMediaTimelineDuration } from "./playbackRate.js";
export {
readElementPlaybackRate,
readElementRateSpec,
resolveNaturalMediaTimelineDuration,
} from "./playbackRate.js";

export function readElementPlaybackStart(el: Element): number {
return readMediaStart(el);
Expand Down Expand Up @@ -43,6 +54,8 @@ export type RuntimeMediaClip = {
end: number;
volume: number | null;
playbackRate: number;
/** The rate lane when the clip has one; otherwise `playbackRate`. */
rate?: RateSpec;
loop: boolean;
/** Source media duration in seconds (from el.duration). Used for loop wrapping. */
sourceDuration: number | null;
Expand Down Expand Up @@ -94,14 +107,15 @@ export function refreshRuntimeMediaCache(params?: {
if (!Number.isFinite(start)) continue;
const mediaStart = readElementPlaybackStart(el);
const playbackRate = readElementPlaybackRate(el);
const rate = readElementRateSpec(el);
const loop = el.loop;
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
let duration =
params?.resolveDurationSeconds?.(el) ?? Number.parseFloat(el.dataset.duration ?? "");
if ((!Number.isFinite(duration) || duration < 0) && sourceDuration != null) {
// Effective duration accounts for playback rate:
// at 0.5x, a 10s source plays for 20s on the timeline
duration = Math.max(0, (sourceDuration - mediaStart) / playbackRate);
duration = Math.max(0, timeAtSourceTime(rate, sourceDuration - mediaStart));
}
const hasKnownDuration = Number.isFinite(duration) && duration >= 0;
const end = hasKnownDuration ? start + duration : Number.POSITIVE_INFINITY;
Expand All @@ -114,6 +128,7 @@ export function refreshRuntimeMediaCache(params?: {
end,
volume: Number.isFinite(volumeRaw) ? volumeRaw : null,
playbackRate,
rate,
loop,
sourceDuration,
};
Expand Down Expand Up @@ -243,7 +258,8 @@ export function syncRuntimeMedia(params: {
for (const clip of params.clips) {
const { el } = clip;
if (!el.isConnected) continue;
let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart;
const clipRate = clip.rate ?? clip.playbackRate;
let relTime = sourceTimeAt(clipRate, params.timeSeconds - clip.start) + clip.mediaStart;
const isNonLoopVideo = el.tagName === "VIDEO" && !clip.loop;
const isHeldVideoTail =
isNonLoopVideo &&
Expand Down Expand Up @@ -369,7 +385,8 @@ export function syncRuntimeMedia(params: {
if (el.preload !== "auto") el.preload = "auto";
try {
// Per-element rate × global transport rate
el.playbackRate = clip.playbackRate * params.playbackRate;
el.playbackRate = rateAt(clipRate, params.timeSeconds - clip.start) * params.playbackRate;
el.preservesPitch = readPreservePitch(el);
} catch (err) {
// ignore unsupported playbackRate
swallow("runtime.media.site1", err);
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/runtime/playbackRate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveNaturalMediaTimelineDuration } from "./playbackRate";
import {
resolveNaturalMediaTimelineDuration,
resolveNaturalMediaTimelineDurationFromValues,
} from "./playbackRate";

function elementWith(attributes: Record<string, string>): Pick<Element, "getAttribute"> {
return {
Expand Down Expand Up @@ -29,3 +32,23 @@ describe("resolveNaturalMediaTimelineDuration", () => {
expect(resolveNaturalMediaTimelineDuration(elementWith({}), Number.NaN)).toBeNull();
});
});

describe("rate lane duration", () => {
it("resolves natural media duration through the lane", () => {
const lane = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
};
expect(resolveNaturalMediaTimelineDurationFromValues(4, 0, lane)).toBeCloseTo(
2 + (4 - 3.641) / 3,
2,
);
expect(resolveNaturalMediaTimelineDurationFromValues(10, 0, lane)).toBeCloseTo(
2 + (10 - 3.641) / 3,
2,
);
});
});
Loading
Loading