mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
* 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>
149 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|