Files
hyperframes/packages/player/src/parent-media.test.ts
T
a95e49dbda fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window

When iframe autoplay is blocked, audible playback is promoted to a parent-frame
audio proxy. The proxy read the clip's data-start/data-duration once at adopt
time and mirrorTime() only skipped (never paused) the element outside that
window — so a trimmed/moved music clip kept playing the full source past its
on-timeline end, even though the iframe element was correctly paused.

Fix: the proxy keeps a reference to its source iframe element and re-reads
data-start/data-duration each mirror tick (live trims/moves apply), pauses the
proxy when the playhead leaves [start, start+duration), and resumes it when the
playhead re-enters during parent-owned playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,studio): bound trimmed audio playback to the clip window

Trimmed audio played to the source file's natural end instead of
stopping at the clip edge, on every audio path:

- WebAudio (the audible path in Studio): schedulePlayback now passes
  the clip's data-duration as the third start() arg, so the decoded
  buffer stops at the trimmed edge instead of running to the file end.
- Runtime element gating: the duration resolver caps each clip by its
  own data-duration (min of source length, host window, authored
  duration), so a trimmed <audio>/<video> element pauses at its edge.

Studio trim UX:

- Resize live-patches the media-start/playback-start offset, so a
  start-edge drag trims into the source instead of only repositioning
  the clip.
- AudioWaveform windows the rendered peaks to the trimmed slice so the
  waveform tracks the clip edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(player,core): gate proxy playback to the live clip window

Review follow-ups on the parent-audio-proxy / WebAudio bound:
- seekAll now re-reads live source bounds (_refreshEntryBounds) before
  gating, so a paused scrub right after a trim/move uses the current clip
  window instead of the adopt-time one.
- playAll and clip adoption only start a proxy when the playhead is inside
  the clip's window (_playEntryIfActive), so bulk starts / promotion no
  longer blip audio for clips outside their window until the next tick.
- The WebAudio buffer is now bounded by the host-composition window too
  (matching resolveDurationSeconds), so a sub-composition-nested clip stops
  at the same edge on the WebAudio and HTMLMedia paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds

A bounded WebAudio source's wall-clock length is baked into start()'s duration
arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in
place on a later rate change does not rescale that bound, so a trimmed clip ends
early (fast) or late (slow). setRate now reports whether the rate changed and
exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active
clips at the new rate when any bounded source is live. The per-clip schedule
loop is extracted to a shared closure so play() and the rate path agree.

Also guard _refreshEntryBounds against a non-numeric duration attribute parsing
to NaN, which would make every window check false and let the proxy play past
its clip end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:14:49 -07:00

149 lines
5.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { ParentMediaManager, type ProxyEntry } from "./parent-media";
// A fake media element whose paused state is driven by play()/pause() stubs.
function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
const el = new Audio();
let paused = initiallyPaused;
Object.defineProperty(el, "paused", { get: () => paused });
el.pause = () => {
paused = true;
};
el.play = () => {
paused = false;
return Promise.resolve();
};
el.src = "https://example.test/music.mp3";
return el;
}
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
const mgr = new ParentMediaManager({
dispatchEvent: () => {},
getMuted: () => false,
getVolume: () => 1,
getPlaybackRate: () => 1,
getCurrentTime: () => 0,
isPaused: () => overrides.isPaused ?? true,
});
return mgr;
}
describe("ParentMediaManager audio-src proxy lifecycle", () => {
it("replaces the audio-src proxy instead of stacking a second one", () => {
const mgr = makeManager();
mgr.setupFromUrl("https://example.test/a.mp3");
expect(mgr.entries).toHaveLength(1);
mgr.setupFromUrl("https://example.test/b.mp3");
// The old proxy must be gone, not accumulated alongside the new one.
expect(mgr.entries).toHaveLength(1);
expect(mgr.entries[0].el.src).toBe("https://example.test/b.mp3");
});
it("is a no-op when the same audio-src URL is set again", () => {
const mgr = makeManager();
mgr.setupFromUrl("https://example.test/a.mp3");
const first = mgr.entries[0];
mgr.setupFromUrl("https://example.test/a.mp3");
expect(mgr.entries).toHaveLength(1);
// Same element reference — not torn down and rebuilt.
expect(mgr.entries[0]).toBe(first);
});
it("clears the audio-src proxy on teardownUrlAudio", () => {
const mgr = makeManager();
mgr.setupFromUrl("https://example.test/a.mp3");
const el = mgr.entries[0].el;
mgr.teardownUrlAudio();
expect(mgr.entries).toHaveLength(0);
// The proxy's source is reset so it stops preloading.
expect(el.src).not.toBe("https://example.test/a.mp3");
});
it("teardownUrlAudio removes only the url proxy, leaving other entries", () => {
const mgr = makeManager();
// Simulate an iframe-adopted entry already in the pool.
const adopted: ProxyEntry = {
el: new Audio(),
start: 0,
duration: Infinity,
driftSamples: 0,
};
adopted.el.src = "https://example.test/iframe-clip.mp4";
mgr.entries.push(adopted);
mgr.setupFromUrl("https://example.test/a.mp3");
expect(mgr.entries).toHaveLength(2);
mgr.teardownUrlAudio();
expect(mgr.entries).toHaveLength(1);
expect(mgr.entries[0]).toBe(adopted);
});
it("teardownUrlAudio is safe to call with no audio-src set", () => {
const mgr = makeManager();
expect(() => mgr.teardownUrlAudio()).not.toThrow();
expect(mgr.entries).toHaveLength(0);
});
it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const el = makeFakeAudio(false); // already playing within the clip
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });
mgr.mirrorTime(3); // inside [0, 5) — stays playing
expect(el.paused).toBe(false);
mgr.mirrorTime(6); // past the trimmed end — must pause
expect(el.paused).toBe(true);
});
it("re-reads the source element's live data-duration so trims bound the proxy", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const source = new Audio();
source.setAttribute("data-start", "0");
source.setAttribute("data-duration", "30");
// jsdom reports isConnected=false unless attached; attach it.
document.body.appendChild(source);
const el = makeFakeAudio(false);
mgr.entries.push({ el, start: 0, duration: 30, driftSamples: 0, source });
mgr.mirrorTime(20); // within 30 → playing
expect(el.paused).toBe(false);
// User trims the clip to 10s; the proxy must pick it up and pause at 20s.
source.setAttribute("data-duration", "10");
mgr.mirrorTime(20);
expect(el.paused).toBe(true);
source.remove();
});
it("does not duplicate or hijack a clip the composition already owns", () => {
const mgr = makeManager();
// The composition already adopted a clip with this URL.
const adopted: ProxyEntry = {
el: new Audio(),
start: 0,
duration: Infinity,
driftSamples: 0,
};
adopted.el.src = "https://example.test/shared.mp3";
mgr.entries.push(adopted);
// Pointing audio-src at the same URL must not create a second proxy...
mgr.setupFromUrl("https://example.test/shared.mp3");
expect(mgr.entries).toHaveLength(1);
expect(mgr.entries[0]).toBe(adopted);
// ...and removing audio-src must not tear down the composition's own clip
// (teardown targets the tracked proxy by reference, not by URL match).
mgr.teardownUrlAudio();
expect(mgr.entries).toHaveLength(1);
expect(mgr.entries[0]).toBe(adopted);
});
});