fix(core): the decoded-audio cache keys a clip the same way everywhere (#6306)

* fix(core): the preview keeps a voiceover's decoded audio across an in-place reload

The decode cache and the release pass keyed the same asset two ways, so a clip the release pass was handed as live could lose its buffer and be re-fetched mid-playback.

* refactor(core): resolve the decode key through the runtime's own assetUrl

Smaller than the inline URL resolution it replaces, which tipped the producer package-size budget, and one owner for "resolve against the document".

* refactor(core): drop the decode-key comment the comment ratchet rejects

* fix(core): an unreadable clip src no longer stops the preview from playing

Resolving the decode key threw on a src that is not a URL, out of the release pass and through play(), so one bad clip silenced the whole preview instead of just failing its own fetch.
This commit is contained in:
Miguel Ángel
2026-10-25 14:52:35 +00:00
committed by GitHub
parent 9b80c2d01e
commit 80ba3f59ec
3 changed files with 54 additions and 3 deletions
+8
View File
@@ -6030,6 +6030,14 @@ describe("initSandboxRuntimeModular", () => {
expect(cached()).toEqual(audios.map((a) => a.getAttribute("src")).sort());
expect(fetchMock).toHaveBeenCalledTimes(12);
});
it("plays the film when a clip's src cannot be read as a URL", async () => {
mountAudio("http://localhost:PORT/vo.mp3");
await startPlayback();
expect(window.__player?.isPlaying()).toBe(true);
});
});
// `el.volume` stops at 1, so a video's authored boost can only be carried by a Web Audio gain.
@@ -1597,6 +1597,8 @@ describe("WebAudioTransport", () => {
}) as unknown as HTMLMediaElement;
const failedSrcs = (t: WebAudioTransport) =>
(t as unknown as { _failedSrcs: Set<string> })._failedSrcs;
// Keys are resolved against the document, so one asset has one key however it is written.
const key = (src: string) => new URL(src, document.baseURI).href;
it("does NOT blacklist a transient fetch failure — a later play retries and succeeds", async () => {
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
@@ -1608,7 +1610,7 @@ describe("WebAudioTransport", () => {
const first = await transport.decodeAudioElement(el("tts.wav"));
expect(first).toBeNull();
expect(failedSrcs(transport).has("tts.wav")).toBe(false); // not permanently silenced
expect(failedSrcs(transport).has(key("tts.wav"))).toBe(false); // not permanently silenced
const second = await transport.decodeAudioElement(el("tts.wav"));
expect(second).not.toBeNull(); // self-heals once the asset is available
@@ -1627,12 +1629,44 @@ describe("WebAudioTransport", () => {
const first = await transport.decodeAudioElement(el("corrupt.wav"));
expect(first).toBeNull();
expect(failedSrcs(transport).has("corrupt.wav")).toBe(true); // bad data is permanent
expect(failedSrcs(transport).has(key("corrupt.wav"))).toBe(true); // bad data is permanent
const second = await transport.decodeAudioElement(el("corrupt.wav"));
expect(second).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1); // short-circuited, no re-fetch
vi.unstubAllGlobals();
});
it("keeps a listed clip's decoded audio while its load is between resources", async () => {
// An in-place sub-composition reload resets the root voiceover's resource selection, so
// `currentSrc` reads empty and the relative attribute behind it keyed a second cache slot:
// the release pass then dropped the buffer of a clip it was handed as live.
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
vi.stubGlobal("fetch", fetchMock);
const vo = document.createElement("audio");
vo.setAttribute("src", "vo.mp3");
let currentSrc = new URL("vo.mp3", document.baseURI).href;
Object.defineProperty(vo, "currentSrc", { configurable: true, get: () => currentSrc });
expect(await transport.decodeAudioElement(vo)).not.toBeNull();
currentSrc = "";
transport.releaseDecodedExcept([vo]);
currentSrc = new URL("vo.mp3", document.baseURI).href;
expect(await transport.decodeAudioElement(vo)).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1); // the buffer it was told to keep is still there
vi.unstubAllGlobals();
});
it("keys a clip whose src cannot be read as a URL without throwing", () => {
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
const bad = document.createElement("audio");
bad.setAttribute("src", "http://localhost:PORT/vo.mp3");
expect(() => transport.releaseDecodedExcept([bad])).not.toThrow();
});
});
});
+10 -1
View File
@@ -19,6 +19,7 @@ import { clampAudioGain } from "../audioGain.js";
import { getDebugSurface } from "./globals.js";
import { readElementPlaybackRate } from "./media.js";
import { classifyWebAudioMediaRoute, reportWebAudioMediaRoute } from "./webAudioRoute.js";
import { assetUrl } from "./assetUrl.js";
const MASTER_FX_ATTRS: FxAttributes = { automation: null };
@@ -145,7 +146,15 @@ function isBufferSource(
return source.sourceKind === "buffer";
}
const decodeKey = (el: HTMLMediaElement): string | null => el.currentSrc || el.getAttribute("src");
const decodeKey = (el: HTMLMediaElement): string | null => {
const src = el.currentSrc || el.getAttribute("src");
if (!src) return null;
try {
return assetUrl(src);
} catch {
return src;
}
};
// A pause to scrub or a reschedule mid-play keeps the output awake; a real idle lets it sleep.
const IDLE_SUSPEND_MS = 10000;