From 02ef616684c161e6c3f89a52141cc5708ea64560 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 00:08:56 -0700 Subject: [PATCH 1/2] fix(player): bound the parent audio proxy to its clip window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Co-authored-by: Miguel Ángel --- packages/player/src/parent-media.test.ts | 49 +++++++++++++++++++++++ packages/player/src/parent-media.ts | 51 ++++++++++++++++++------ 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/player/src/parent-media.test.ts b/packages/player/src/parent-media.test.ts index 347f3cf45..45f0a2534 100644 --- a/packages/player/src/parent-media.test.ts +++ b/packages/player/src/parent-media.test.ts @@ -1,6 +1,22 @@ 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: () => {}, @@ -73,6 +89,39 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => { 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. diff --git a/packages/player/src/parent-media.ts b/packages/player/src/parent-media.ts index e4283b3ea..28df23255 100644 --- a/packages/player/src/parent-media.ts +++ b/packages/player/src/parent-media.ts @@ -28,6 +28,12 @@ export interface ProxyEntry { el: HTMLMediaElement; start: number; duration: number; + /** + * The iframe media element this proxy mirrors, when adopted from the DOM. + * Its `data-start`/`data-duration` are re-read each tick so live timeline + * edits (trim/move) bound the proxy correctly. Null for URL-driven proxies. + */ + source?: HTMLMediaElement | null; /** * Count of consecutive steady-state samples in which the proxy's * `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`. @@ -118,11 +124,33 @@ export class ParentMediaManager { for (const m of this._entries) m.el.playbackRate = rate; } - playAll(): void { - for (const m of this._entries) { - if (!m.el.src) continue; - m.el.play().catch((err: unknown) => this._reportPlaybackError(err)); + private _playEntry(m: ProxyEntry): void { + if (!m.el.src) return; + m.el.play().catch((err: unknown) => this._reportPlaybackError(err)); + } + + // Re-read the source clip's live timing so trims/moves bound the proxy + // (adopt-time values go stale when the timeline is edited). + private _refreshEntryBounds(m: ProxyEntry): void { + if (!m.source?.isConnected) return; + m.start = parseFloat(m.source.getAttribute("data-start") || "0"); + m.duration = parseFloat(m.source.getAttribute("data-duration") || "Infinity"); + } + + // Pause the proxy outside its clip window; resume it on re-entry during + // parent-owned playback. Returns whether the proxy is within the window. + private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean { + if (relTime < 0 || relTime >= m.duration) { + if (!m.el.paused) m.el.pause(); + m.driftSamples = 0; + return false; } + if (this._audioOwner === "parent" && !this._isPaused() && m.el.paused) this._playEntry(m); + return true; + } + + playAll(): void { + for (const m of this._entries) this._playEntry(m); } pauseAll(): void { @@ -145,11 +173,9 @@ export class ParentMediaManager { mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void { const force = options?.force === true; for (const m of this._entries) { + this._refreshEntryBounds(m); const relTime = timelineSeconds - m.start; - if (relTime < 0 || relTime >= m.duration) { - m.driftSamples = 0; - continue; - } + if (!this._gateEntryPlayback(m, relTime)) continue; if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) { m.driftSamples += 1; if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) { @@ -275,6 +301,7 @@ export class ParentMediaManager { tag: "audio" | "video", start: number, duration: number, + source?: HTMLMediaElement | null, ): ProxyEntry | null { if (this._entries.some((m) => m.el.src === src)) return null; @@ -287,7 +314,7 @@ export class ParentMediaManager { const rate = this._getPlaybackRate(); if (rate !== 1) el.playbackRate = rate; - const entry: ProxyEntry = { el, start, duration, driftSamples: 0 }; + const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source }; this._entries.push(entry); return entry; } @@ -312,15 +339,13 @@ export class ParentMediaManager { const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity"); const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const); - const created = this._createEntry(src, tag, start, duration); + const created = this._createEntry(src, tag, start, duration, iframeEl); // If already under parent ownership and playing, the new proxy must catch // up immediately — bypass the jitter-coalescing gate. if (created && this._audioOwner === "parent") { this.mirrorTime(this._getCurrentTime(), { force: true }); - if (!this._isPaused() && created.el.src) { - created.el.play().catch((err: unknown) => this._reportPlaybackError(err)); - } + if (!this._isPaused()) this._playEntry(created); } } From e1b76df0fb12b361dd6b1852818b5664b202dbb9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 01:12:23 -0700 Subject: [PATCH 2/2] fix(core,studio): bound trimmed audio playback to the clip window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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