fix(studio-server): uncopied HEVC clips no longer stall Studio while their preview copies are made (#4580)

* fix(studio-server): a cold proxy copy answers 202 at once and the preview retries until it lands

* fix(studio): timeline filmstrips wait for a clip's proxy copy instead of failing on the 202

* fix(studio-server): a copy that cannot be made reports its failure instead of answering 202 forever

* test(studio-server): pin that a full queue is not remembered and a reset probe stops waiting

* test(studio-server): pin that a failure outside ffmpeg is remembered too

* style(studio-server): keep comment share at base in the transcoder and decoder

* fix(core): a clip's proxy URL goes on the video only once the copy is served, so every reader of it works

* fix(player): the parent media mirror follows a clip's src, so it plays the preview copy once it lands

* perf(player): the mirror re-points a clip only when the parent takes over playback

* style(core): shorten the sync-state comment in the proxy swap

* fix(studio): the timeline follows a clip re-pointed at its preview copy

* fix(core): a failed readiness check is retried instead of leaving a clip on its original

* fix(studio-server): a composition card rendered while preview copies are made is not cached

* fix(core): frame captures wait for a pending preview copy as long as for a loading video

* fix(studio-server): the preview-copy check uses the shared realpath from main

* fix(player): re-pointing a clip onto another clip's file no longer plays that file twice

* fix(player): a clip that replaces another on the same file keeps its sound
This commit is contained in:
Miguel Ángel
2026-09-28 16:14:29 +00:00
committed by GitHub
parent 8fd5fd5a32
commit cbcadbfbd0
18 changed files with 741 additions and 73 deletions
@@ -165,6 +165,7 @@ const quietMedia = () => {
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
};
const proxyHostile = () => {
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("x", { status: 206 }));
window.__HF_MEDIA_CODEC_MAP__ = {
"/clip.mov": { codecName: "prores", browserHostile: true, representativeMime: null },
};
@@ -422,9 +423,11 @@ describe("__hfSwapScenes", () => {
const withVideo = { ...B, body: '<video data-var-src="clip" src="clip.mov"></video>' };
boot([A1, withVideo], root);
await tick();
await tick();
const video = sceneHost("b").querySelector("video")!;
expect(video.src).toBe(proxied);
await window.__hfSwapScenes!(preview([A2, withVideo]).html);
await tick();
expect(video.src).toBe(proxied);
});
@@ -439,6 +442,7 @@ describe("__hfSwapScenes", () => {
await window.__hfSwapScenes!(
preview([{ ...A2, body: `<p>A two</p>${video}${video}` }, B]).html,
);
await tick();
const videos = Array.from(sceneHost("a").querySelectorAll("video"));
expect(videos.map((v) => v.src)).toEqual([proxied, proxied]);
});
+1 -1
View File
@@ -21,7 +21,7 @@ export function readElementPlaybackStart(el: Element): number {
}
const HOLD_END_EVENTS = ["seeked", "loadeddata", "error", "emptied", "abort"] as const;
const HOLD_CAP_MS = 5000;
export const HOLD_CAP_MS = 5000;
const releaseHeldVideo = new WeakMap<HTMLMediaElement, () => void>();
// A seeking video still paints its previous frame, and one still fetching its first data paints none (its seek
+266 -32
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
hasMediaSyncStateForTest,
syncRuntimeMedia as syncRuntimeMediaWithDuration,
@@ -23,6 +23,7 @@ vi.mock("./bridge", () => ({
}));
import { postRuntimeMessage } from "./bridge";
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
const postRuntimeMessageMock = vi.mocked(postRuntimeMessage);
@@ -66,7 +67,20 @@ function isProxied(el: HTMLMediaElement): boolean {
return proxyVariant(el) !== null;
}
/** Lets a started swap ask for its copy (the default stub serves it) and finish. */
const settle = () => new Promise((resolveSettle) => setTimeout(resolveSettle, 0));
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("x", { status: 206 })),
);
});
afterEach(() => {
resetSeekDispatchState();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.restoreAllMocks();
document.body.innerHTML = "";
@@ -76,12 +90,13 @@ afterEach(() => {
});
describe("maybeProxyProactively", () => {
it("rewrites src and calls load() before first load for a hostile, undecodable asset", () => {
it("rewrites src and calls load() before first load for a hostile, undecodable asset", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": HEVC_ENTRY };
const el = createVideo("/video.mp4");
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(true);
expect(el.load).toHaveBeenCalledTimes(1);
@@ -90,17 +105,18 @@ describe("maybeProxyProactively", () => {
);
});
it("matches codec-map paths across case and Unicode normalization differences", () => {
it("matches codec-map paths across case and Unicode normalization differences", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/assets/Caf\u00e9.MP4": HEVC_ENTRY };
const el = createVideo("/assets/cafe\u0301.mp4");
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(true);
});
it("does not guess when two codec-map paths collide after normalization", () => {
it("does not guess when two codec-map paths collide after normalization", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {
"/assets/CLIP.mp4": HEVC_ENTRY,
"/assets/clip.MP4": H264_ENTRY,
@@ -109,22 +125,24 @@ describe("maybeProxyProactively", () => {
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
});
it("does not swap when canPlayType reports probably/maybe despite a hostile map entry", () => {
it("does not swap when canPlayType reports probably/maybe despite a hostile map entry", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": HEVC_ENTRY };
const el = createVideo("/video.mp4");
stubCanPlayType(el, "probably");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
});
it("swaps when hostile and representativeMime is null (no canPlayType check possible)", () => {
it("swaps when hostile and representativeMime is null (no canPlayType check possible)", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {
"/video.mp4": {
codecName: "prores",
@@ -136,55 +154,60 @@ describe("maybeProxyProactively", () => {
stubCanPlayType(el, "probably"); // should not even be consulted
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(true);
});
it("does not swap a non-hostile (browser-safe) map entry", () => {
it("does not swap a non-hostile (browser-safe) map entry", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": H264_ENTRY };
const el = createVideo("/video.mp4");
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
});
it("is a no-op in render mode even for a hostile entry", () => {
it("is a no-op in render mode even for a hostile entry", async () => {
window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: "seek" };
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": HEVC_ENTRY };
const el = createVideo("/video.mp4");
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("is a no-op when the codec map global is absent", () => {
it("is a no-op when the codec map global is absent", async () => {
const el = createVideo("/video.mp4");
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
});
it("never swaps an <audio> element even with a hostile-container src", () => {
it("never swaps an <audio> element even with a hostile-container src", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": HEVC_ENTRY };
const el = createAudio("/video.mp4");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("swaps an alpha-bearing hostile entry to a VP8 proxy", () => {
it("swaps an alpha-bearing hostile entry to a VP8 proxy", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {
"/video.mov": {
codecName: "prores",
@@ -197,12 +220,13 @@ describe("maybeProxyProactively", () => {
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(proxyVariant(el)).toBe("vp8");
expect(el.load).toHaveBeenCalledTimes(1);
});
it("is a no-op when the render-frame sibling image signals render mode", () => {
it("is a no-op when the render-frame sibling image signals render mode", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": HEVC_ENTRY };
const el = createVideo("/video.mp4");
el.id = "clip-1";
@@ -212,6 +236,7 @@ describe("maybeProxyProactively", () => {
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
@@ -224,30 +249,32 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
Object.defineProperty(el, "videoWidth", { value: 0, configurable: true });
}
it("swaps once when videoWidth is 0 for a same-origin local video absent from the (present) map — unlisted-asset rescue", () => {
it("swaps once when videoWidth is 0 for a same-origin local video absent from the (present) map — unlisted-asset rescue", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {};
const el = createVideo("/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(true);
expect(proxyVariant(el)).toBe("auto");
expect(el.load).toHaveBeenCalledTimes(1);
});
it("never swaps when the codec map global is absent (opt-out surface serves no proxies)", () => {
it("never swaps when the codec map global is absent (opt-out surface serves no proxies)", async () => {
const el = createVideo("/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("swaps a mapped alpha entry to a VP8 proxy", () => {
it("swaps a mapped alpha entry to a VP8 proxy", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {
"/video.mov": {
codecName: "prores",
@@ -260,17 +287,19 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(proxyVariant(el)).toBe("vp8");
expect(el.load).toHaveBeenCalledTimes(1);
});
it("does not proxy a mapped browser-safe codec on the metadata path", () => {
it("does not proxy a mapped browser-safe codec on the metadata path", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": H264_ENTRY };
const el = createVideo("/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
@@ -282,54 +311,60 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
);
});
it("a second zero-width metadata event does not loop (no second swap)", () => {
it("a second zero-width metadata event does not loop (no second swap)", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {};
const el = createVideo("/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
handleMetadataForProxy(el);
await settle();
expect(el.load).toHaveBeenCalledTimes(1);
});
it("does nothing when videoWidth is non-zero", () => {
it("does nothing when videoWidth is non-zero", async () => {
const el = createVideo("/video.mp4");
Object.defineProperty(el, "videoWidth", { value: 640, configurable: true });
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
});
it("is a no-op in render mode", () => {
it("is a no-op in render mode", async () => {
window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: "seek" };
const el = createVideo("/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
});
it("never swaps <audio>", () => {
it("never swaps <audio>", async () => {
const el = createAudio("/video.mp4");
Object.defineProperty(el, "videoWidth", { value: 0, configurable: true });
handleMetadataForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("cross-origin src with zero videoWidth: no swap attempted, diagnostic still emitted (with its console.info line)", () => {
it("cross-origin src with zero videoWidth: no swap attempted, diagnostic still emitted (with its console.info line)", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
window.__HF_MEDIA_CODEC_MAP__ = {};
const el = createVideo("https://cdn.example.com/video.mp4");
markZeroWidth(el);
handleMetadataForProxy(el);
await settle();
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
@@ -347,7 +382,7 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
expect(line).toContain("https://cdn.example.com/video.mp4");
});
it("resolves a ../-traversing sub-composition src (already rewritten to an absolute, prefixed URL) via longest-suffix map matching", () => {
it("resolves a ../-traversing sub-composition src (already rewritten to an absolute, prefixed URL) via longest-suffix map matching", async () => {
const base = document.createElement("base");
base.href = `${window.location.origin}/api/projects/proj1/preview/`;
document.head.appendChild(base);
@@ -361,37 +396,41 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
stubCanPlayType(el, "");
maybeProxyProactively(el);
await settle();
expect(isProxied(el)).toBe(true);
});
});
describe("handleErrorForProxy (tertiary trigger)", () => {
it("swaps once on an error event for a zero-stream (video-only hostile) file unlisted in the (present) map", () => {
it("swaps once on an error event for a zero-stream (video-only hostile) file unlisted in the (present) map", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {};
const el = createVideo("/video.mp4");
handleErrorForProxy(el);
await settle();
expect(isProxied(el)).toBe(true);
expect(el.load).toHaveBeenCalledTimes(1);
});
it("never swaps when the codec map global is absent (opt-out surface serves no proxies)", () => {
it("never swaps when the codec map global is absent (opt-out surface serves no proxies)", async () => {
const el = createVideo("/video.mp4");
handleErrorForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("does not swap a mapped browser-SAFE entry that errors (corrupt file — proxying can't help); diagnoses instead", () => {
it("does not swap a mapped browser-SAFE entry that errors (corrupt file — proxying can't help); diagnoses instead", async () => {
window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": H264_ENTRY };
const el = createVideo("/video.mp4");
handleErrorForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(el.load).not.toHaveBeenCalled();
@@ -403,23 +442,26 @@ describe("handleErrorForProxy (tertiary trigger)", () => {
);
});
it("never swaps <audio> on error", () => {
it("never swaps <audio> on error", async () => {
window.__HF_MEDIA_CODEC_MAP__ = {};
const el = createAudio("/video.mp4");
handleErrorForProxy(el);
await settle();
expect(isProxied(el)).toBe(false);
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("the proxy URL itself erroring: no second swap, diagnostic reports the failure instead", () => {
it("the proxy URL itself erroring: no second swap, diagnostic reports the failure instead", async () => {
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await settle();
expect(el.load).toHaveBeenCalledTimes(1);
postRuntimeMessageMock.mockClear();
handleErrorForProxy(el);
await settle();
expect(el.load).toHaveBeenCalledTimes(1); // no second load()/swap
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
@@ -432,7 +474,7 @@ describe("handleErrorForProxy (tertiary trigger)", () => {
});
describe("swapToProxy", () => {
it("does not poison swap state when the source URL is malformed", () => {
it("does not poison swap state when the source URL is malformed", async () => {
const el = createVideo("/video.mp4");
Object.defineProperty(el, "currentSrc", {
value: "http://[",
@@ -440,6 +482,7 @@ describe("swapToProxy", () => {
});
swapToProxy(el, HEVC_ENTRY, "reactive");
await settle();
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
@@ -449,7 +492,7 @@ describe("swapToProxy", () => {
}),
);
});
it("evicts per-source sync state so the swapped element is treated as a first tick", () => {
it("evicts per-source sync state so the swapped element is treated as a first tick", async () => {
const el = createVideo("/video.mp4");
// Populate sync state as a real active-clip tick would.
syncRuntimeMedia({
@@ -473,26 +516,30 @@ describe("swapToProxy", () => {
expect(hasMediaSyncStateForTest(el)).toBe(true);
swapToProxy(el, HEVC_ENTRY, "reactive");
await settle();
expect(hasMediaSyncStateForTest(el)).toBe(false);
});
it("preserves existing query strings when appending hf-proxy", () => {
it("preserves existing query strings when appending hf-proxy", async () => {
const el = createVideo("/video.mp4?v=2");
swapToProxy(el, HEVC_ENTRY, "proactive");
await settle();
const url = new URL(el.src, document.baseURI);
expect(url.searchParams.get("v")).toBe("2");
expect(url.searchParams.get("hf-proxy")).toBe("h264");
});
it("emits the diagnostic and a single console.info line exactly once per element", () => {
it("emits the diagnostic and a single console.info line exactly once per element", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await settle();
swapToProxy(el, HEVC_ENTRY, "proactive"); // idempotent re-call, e.g. from another trigger
await settle();
const fallbackCalls = postRuntimeMessageMock.mock.calls.filter(
([msg]) => (msg as { code?: string }).code === "runtime_media_proxy_fallback",
@@ -501,18 +548,205 @@ describe("swapToProxy", () => {
expect(infoSpy).toHaveBeenCalledTimes(1);
});
it("is idempotent: a second call for an already-swapped element does not re-swap", () => {
it("is idempotent: a second call for an already-swapped element does not re-swap", async () => {
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await settle();
const srcAfterFirstSwap = el.src;
swapToProxy(el, HEVC_ENTRY, "reactive");
await settle();
expect(el.src).toBe(srcAfterFirstSwap);
expect(el.load).toHaveBeenCalledTimes(1);
});
});
describe("waiting for the proxy copy", () => {
function stubProxyRoute(...statuses: number[]) {
const route = vi.fn(async () => {
const status = statuses.length > 1 ? statuses.shift()! : statuses[0]!;
const retry = status === 202 || status === 503 ? { "Retry-After": "2" } : undefined;
return new Response(status === 206 ? "x" : null, { status, headers: retry });
});
vi.stubGlobal("fetch", route);
return route;
}
it("keeps the original src until the copy is served, so no reader of src gets a copy still being made", async () => {
vi.useFakeTimers();
const route = stubProxyRoute(202, 503, 206);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(3_000);
expect(el.getAttribute("src")).toBe("/video.mp4");
expect(el.load).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(10_000);
expect(route).toHaveBeenCalledTimes(3);
expect(route).toHaveBeenCalledWith(
expect.stringContaining("hf-proxy=h264"),
expect.objectContaining({ headers: { Range: "bytes=0-0" } }),
);
expect(proxyVariant(el)).toBe("h264");
expect(el.load).toHaveBeenCalledTimes(1);
});
it("ignores the original's own metadata and error while the copy is being made", async () => {
vi.useFakeTimers();
window.__HF_MEDIA_CODEC_MAP__ = {};
const route = stubProxyRoute(202, 206);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
postRuntimeMessageMock.mockClear();
Object.defineProperty(el, "videoWidth", { value: 0 });
handleMetadataForProxy(el);
handleErrorForProxy(el);
await vi.advanceTimersByTimeAsync(0);
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
expect(route).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_000);
expect(isProxied(el)).toBe(true);
});
it("diagnoses a copy that cannot be made once, after a few asks, and the element keeps its original", async () => {
vi.useFakeTimers();
window.__HF_MEDIA_CODEC_MAP__ = {};
const route = stubProxyRoute(502);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
postRuntimeMessageMock.mockClear();
Object.defineProperty(el, "videoWidth", { value: 0 });
handleMetadataForProxy(el);
await vi.advanceTimersByTimeAsync(60_000);
expect(isProxied(el)).toBe(false);
expect(route).toHaveBeenCalledTimes(4);
expect(postRuntimeMessageMock).toHaveBeenCalledTimes(1);
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
code: "runtime_media_proxy_unavailable",
details: expect.objectContaining({ reason: "proxy_playback_failed" }),
}),
);
});
it("asks again after a dropped request", async () => {
vi.useFakeTimers();
const route = vi
.fn()
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue(new Response("x", { status: 206 }));
vi.stubGlobal("fetch", route);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(3_000);
expect(route).toHaveBeenCalledTimes(2);
expect(isProxied(el)).toBe(true);
});
it("asks again after a transient error answer", async () => {
vi.useFakeTimers();
const route = stubProxyRoute(500, 206);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(3_000);
expect(route).toHaveBeenCalledTimes(2);
expect(isProxied(el)).toBe(true);
});
it("holds frame captures until a served copy shows its first frame", async () => {
vi.useFakeTimers();
stubProxyRoute(206);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
let captured = false;
void waitForSeekCompletion().then(() => (captured = true));
await vi.advanceTimersByTimeAsync(100);
expect(isProxied(el)).toBe(true);
expect(captured).toBe(false);
el.dispatchEvent(new Event("loadeddata"));
await vi.advanceTimersByTimeAsync(0);
expect(captured).toBe(true);
});
it("holds frame captures for a copy still being made no longer than a loading video", async () => {
vi.useFakeTimers();
stubProxyRoute(202);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
let captured = false;
void waitForSeekCompletion().then(() => (captured = true));
await vi.advanceTimersByTimeAsync(4_900);
expect(captured).toBe(false);
await vi.advanceTimersByTimeAsync(200);
expect(captured).toBe(true);
expect(isProxied(el)).toBe(false);
});
it("stops waiting when the element leaves the document", async () => {
vi.useFakeTimers();
const route = stubProxyRoute(202);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
postRuntimeMessageMock.mockClear();
await vi.advanceTimersByTimeAsync(0);
el.remove();
await vi.advanceTimersByTimeAsync(60_000);
expect(route).toHaveBeenCalledTimes(1);
expect(el.load).not.toHaveBeenCalled();
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
});
it("keeps one wait for a new file even after the dropped wait for the old one wakes", async () => {
vi.useFakeTimers();
const asks: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (href: string) => {
asks.push(new URL(href).pathname);
return new Response(null, { status: 202, headers: { "Retry-After": "2" } });
}),
);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(0);
el.setAttribute("src", "/other.mp4");
swapToProxy(el, HEVC_ENTRY, "reactive");
await vi.advanceTimersByTimeAsync(2_100);
swapToProxy(el, HEVC_ENTRY, "reactive");
await vi.advanceTimersByTimeAsync(0);
expect(asks.filter((path) => path === "/other.mp4")).toHaveLength(2);
});
it("drops a wait whose element was pointed at another file, and can proxy the new one", async () => {
vi.useFakeTimers();
const route = stubProxyRoute(202);
const el = createVideo("/video.mp4");
swapToProxy(el, HEVC_ENTRY, "proactive");
await vi.advanceTimersByTimeAsync(0);
el.setAttribute("src", "/other.mp4");
const newFile = stubProxyRoute(206);
swapToProxy(el, HEVC_ENTRY, "reactive");
await vi.advanceTimersByTimeAsync(0);
expect(newFile).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_000);
expect(route).toHaveBeenCalledTimes(1);
expect(newFile, "the dropped wait asks no more").toHaveBeenCalledTimes(1);
expect(new URL(el.src).pathname).toBe("/other.mp4");
expect(isProxied(el)).toBe(true);
});
});
describe("deriveCodecMapKey", () => {
it("returns the decoded, query-stripped pathname for a same-origin src", () => {
const el = createVideo("/assets/my%20clip.mp4?foo=bar");
+32 -10
View File
@@ -1,10 +1,12 @@
import { postRuntimeMessage } from "./bridge";
import { swallow } from "./diagnostics";
import { evictMediaSyncState } from "./media";
import { evictMediaSyncState, HOLD_CAP_MS } from "./media";
import { findInjectedRenderFrame } from "./renderFrameSibling";
import type { RuntimeJson } from "./types";
import { isVideoElement } from "./domRealm";
import { swappedElements } from "./proxySrc";
import { waitForServedProxy } from "./proxyWait";
import { registerSeekCompletion } from "./adapters/seek-dispatch";
/**
* One entry per project-root-relative asset pathname, injected by the
@@ -47,6 +49,10 @@ type ProxyTrigger = "proactive" | "reactive" | "tertiary";
// still gets its own single diagnostic.
const unavailableDiagnosedElements = new WeakSet<HTMLMediaElement>();
// Elements whose swap has started, with the src it started for. Until the copy is served they
// keep that src, so nothing that reads it (timeline, player mirror) loads a copy still being made.
const proxyRequested = new WeakMap<HTMLMediaElement, string | null>();
function currentSrcValue(el: HTMLMediaElement): string {
return el.currentSrc || el.src;
}
@@ -215,6 +221,7 @@ export function swapToProxy(
trigger: ProxyTrigger = "reactive",
): void {
if (swappedElements.has(el)) return;
if (proxyRequested.has(el) && proxyRequested.get(el) === el.getAttribute("src")) return;
const originalSrc = currentSrcValue(el);
let proxiedSrc: string;
try {
@@ -224,15 +231,30 @@ export function swapToProxy(
emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc);
return;
}
swappedElements.set(el, el.getAttribute("src"));
// The swapped src points at a different file — sync state (drift offsets,
// seek-retry latches, volume tracking) computed against the original
// source must not carry over, or the next tick misreads a fresh file's
// buffering as drift. Evict before `load()` so the very next sync tick
// treats this element as a first tick.
evictMediaSyncState(el);
el.src = proxiedSrc;
el.load();
const originalAttr = el.getAttribute("src");
proxyRequested.set(el, originalAttr);
const live = () => el.isConnected && el.getAttribute("src") === originalAttr;
const swap = waitForServedProxy(proxiedSrc, live).then((served) => {
if (!live()) {
if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el);
return;
}
if (!served) {
emitUnavailableDiagnostic(el, "proxy_playback_failed", originalSrc);
return;
}
swappedElements.set(el, originalAttr);
// Sync state measured on the original would read the new file's buffering as drift.
evictMediaSyncState(el);
el.src = proxiedSrc;
el.load();
return new Promise((landed) => {
el.addEventListener("loadeddata", landed, { once: true });
el.addEventListener("error", landed, { once: true });
});
});
// A frame capture holds for the copy as it held for a loading video before, up to the same cap.
registerSeekCompletion(Promise.race([swap, new Promise((cap) => setTimeout(cap, HOLD_CAP_MS))]));
const codecName = entry?.codecName ?? null;
const details: Record<string, RuntimeJson> = {
asset: originalSrc,
+19
View File
@@ -0,0 +1,19 @@
const MAX_FAILED_ASKS = 3;
/** Asks for one byte of a `?hf-proxy=` URL until it is served, after each Retry-After: while the
* server answers 202 (copy being made) or 503 (queue full), and after up to 3 failed asks. */
export async function waitForServedProxy(href: string, live: () => boolean): Promise<boolean> {
let failed = 0;
while (live()) {
const res = await fetch(href, { headers: { Range: "bytes=0-0" }, cache: "no-store" }).catch(
() => null,
);
void res?.body?.cancel().catch(() => {});
const pending = res?.status === 202 || res?.status === 503;
if (!pending && res?.ok) return true;
if (!pending && ++failed > MAX_FAILED_ASKS) return false;
const seconds = Math.min(Number(res?.headers.get("Retry-After")) || 2, 30);
await new Promise((resolveWait) => setTimeout(resolveWait, seconds * 1000));
}
return false;
}
@@ -747,14 +747,14 @@ describe("HyperframesPlayer media MutationObserver scoping", () => {
expect(observedTargets).not.toContain(fakeDoc.body);
// Subtree is still required — sub-composition media can be deeply nested
// inside the host (e.g. wrapper div around the `<audio>`).
// Attribute observation on "preload" is required so the player creates
// parent proxies just-in-time when the preloader promotes a clip.
// Attribute observation on "preload" and "src" is required so the player creates
// parent proxies when the preloader promotes a clip, and follows a re-pointed one.
for (const call of observeSpy.mock.calls) {
expect(call[1]).toEqual({
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
attributeFilter: ["preload", "src"],
});
}
});
+132
View File
@@ -204,6 +204,138 @@ describe("ParentMediaManager across documents", () => {
});
});
describe("ParentMediaManager following its clips", () => {
afterEach(() => document.body.replaceChildren());
const flushObserver = () => new Promise((resolveFlush) => setTimeout(resolveFlush, 0));
function adoptedClip(mgr: ParentMediaManager, src: string): HTMLVideoElement {
const clip = document.createElement("video");
clip.setAttribute("src", src);
clip.setAttribute("data-start", "0");
clip.preload = "auto";
document.body.appendChild(clip);
mgr.setupFromIframe(document);
return clip;
}
const copy = "https://example.test/clip.mov?hf-proxy=h264";
it("keeps a re-pointed clip's proxy on its file while the composition owns playback", async () => {
const mgr = makeManager();
const clip = adoptedClip(mgr, "https://example.test/clip.mov");
clip.setAttribute("src", copy);
await flushObserver();
expect(mgr.entries.map((m) => m.el.src)).toEqual(["https://example.test/clip.mov"]);
});
it("re-points each proxy to its clip's current src when the parent takes over playback", async () => {
const mgr = makeManager();
const clip = adoptedClip(mgr, "https://example.test/clip.mov");
clip.setAttribute("src", copy);
await flushObserver();
mgr.promoteToParentProxy(document);
expect(mgr.entries.map((m) => m.el.src)).toEqual([copy]);
});
it("follows a re-pointed clip at once while the parent owns playback", async () => {
const mgr = makeManager();
const clip = adoptedClip(mgr, "https://example.test/clip.mov");
mgr.promoteToParentProxy(document);
clip.setAttribute("src", copy);
await flushObserver();
expect(mgr.entries.map((m) => m.el.src)).toEqual([copy]);
});
it("keeps one proxy per file when a clip is re-pointed at another clip's file", async () => {
const mgr = makeManager();
adoptedClip(mgr, "https://example.test/a.mov");
const b = adoptedClip(mgr, "https://example.test/b.mov");
mgr.promoteToParentProxy(document);
b.setAttribute("src", "https://example.test/a.mov");
await flushObserver();
expect(mgr.entries.map((m) => m.el.src)).toEqual(["https://example.test/a.mov"]);
});
it("keeps one proxy per file when the parent takes over after such a re-point", async () => {
const mgr = makeManager();
adoptedClip(mgr, "https://example.test/a.mov");
const b = adoptedClip(mgr, "https://example.test/b.mov");
b.setAttribute("src", "https://example.test/a.mov");
await flushObserver();
mgr.promoteToParentProxy(document);
expect(mgr.entries.map((m) => m.el.src)).toEqual(["https://example.test/a.mov"]);
});
const files = (mgr: ParentMediaManager) => mgr.entries.map((m) => m.el.src).sort();
it("keeps both proxies when two clips swap files while the parent owns playback", async () => {
const mgr = makeManager();
const a = adoptedClip(mgr, "https://example.test/a.mov");
const b = adoptedClip(mgr, "https://example.test/b.mov");
mgr.promoteToParentProxy(document);
a.setAttribute("src", "https://example.test/b.mov");
b.setAttribute("src", "https://example.test/a.mov");
await flushObserver();
expect(files(mgr)).toEqual(["https://example.test/a.mov", "https://example.test/b.mov"]);
});
it("keeps both proxies when two clips swap or chain files before the parent takes over", async () => {
const mgr = makeManager();
const a = adoptedClip(mgr, "https://example.test/a.mov");
const b = adoptedClip(mgr, "https://example.test/b.mov");
a.setAttribute("src", "https://example.test/b.mov");
b.setAttribute("src", "https://example.test/c.mov");
await flushObserver();
mgr.promoteToParentProxy(document);
expect(files(mgr)).toEqual(["https://example.test/b.mov", "https://example.test/c.mov"]);
});
it("keeps the proxy of a clip when another clip on the same file leaves", async () => {
const mgr = makeManager();
adoptedClip(mgr, "https://example.test/a.mov");
const twin = adoptedClip(mgr, "https://example.test/a.mov");
twin.remove();
await flushObserver();
expect(files(mgr)).toEqual(["https://example.test/a.mov"]);
});
it("gives a clip that replaces another on the same file its own proxy", async () => {
const mgr = makeManager();
const old = adoptedClip(mgr, "https://example.test/a.mov");
const replacement = document.createElement("video");
replacement.setAttribute("src", "https://example.test/a.mov");
replacement.setAttribute("data-start", "0");
replacement.preload = "auto";
document.body.replaceChild(replacement, old);
await flushObserver();
expect(mgr.entries.map((m) => m.source)).toEqual([replacement]);
});
it("drops the proxy of a re-pointed clip when the clip leaves", async () => {
const mgr = makeManager();
const clip = adoptedClip(mgr, "https://example.test/clip.mov");
clip.setAttribute("src", copy);
await flushObserver();
clip.remove();
await flushObserver();
expect(mgr.entries).toHaveLength(0);
});
});
describe("ParentMediaManager clip window", () => {
it("plays a proxy inside its clip window and pauses it at the clip end instant", () => {
const mgr = makeManager({ isPaused: false, owner: "parent" });
+50 -8
View File
@@ -268,6 +268,7 @@ export class ParentMediaManager {
if (isRealmHtmlMediaElement(el)) el.muted = true;
}
}
for (const m of [...this._entries]) this._repointToSource(m);
// One-shot alignment — bypass jitter-coalescing gate.
const t = this._getCurrentTime();
@@ -354,7 +355,7 @@ export class ParentMediaManager {
duration: number,
source?: HTMLMediaElement | null,
): ProxyEntry | null {
if (this._entries.some((m) => m.el.src === src)) return null;
if (this._urlTaken(src)) return null;
const el = tag === "video" ? document.createElement("video") : new Audio();
el.preload = "auto";
@@ -401,15 +402,46 @@ export class ParentMediaManager {
}
}
// The runtime re-points a clip (its preview copy, a new bound file). A proxy that is not
// playing keeps its file until the parent takes over, so the copy is not fetched twice.
private _followIframeMediaSrc(iframeEl: HTMLMediaElement): void {
const entry = this._entries.find((m) => m.source === iframeEl);
if (!entry) return this._adoptIframeMedia(iframeEl);
if (this._audioOwner !== "parent" || !this._repointToSource(entry)) return;
this.mirrorTime(this._getCurrentTime(), { force: true });
if (!this._isPaused()) this._playEntryIfActive(entry);
}
/** One proxy per file: a second clip on the same file is heard through the first one's proxy.
* A proxy's file is its clip's current one (a clip that left the page owns none). */
private _urlTaken(src: string, except?: ProxyEntry): boolean {
return this._entries.some((m) => {
if (m === except || (m.source && !m.source.isConnected)) return false;
return ((m.source && this._resolveIframeMediaSrc(m.source)) || m.el.src) === src;
});
}
private _repointToSource(entry: ProxyEntry): boolean {
const src = entry.source ? this._resolveIframeMediaSrc(entry.source) : null;
if (!src || entry.el.src === src) return false;
if (this._urlTaken(src, entry)) {
this._removeEntry(entry);
return false;
}
entry.el.src = src;
entry.el.load();
return true;
}
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
const src = this._resolveIframeMediaSrc(iframeEl);
if (!src) return;
const idx = this._entries.findIndex((m) => m.el.src === src);
if (idx === -1) return;
const entry = this._entries[idx];
const entry = this._entries.find((m) => m.source === iframeEl);
if (entry) this._removeEntry(entry);
}
private _removeEntry(entry: ProxyEntry): void {
entry.el.pause();
entry.el.src = "";
this._entries.splice(idx, 1);
this._entries.splice(this._entries.indexOf(entry), 1);
}
private _observeDynamicMedia(doc: Document): void {
@@ -419,6 +451,16 @@ export class ParentMediaManager {
// fallow-ignore-next-line complexity
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "src") {
const target = m.target;
if (
isRealmHtmlMediaElement(target) &&
target.matches("audio[data-start], video[data-start]")
) {
this._followIframeMediaSrc(target);
}
continue;
}
if (m.type === "attributes" && m.attributeName === "preload") {
const target = m.target;
if (
@@ -469,7 +511,7 @@ export class ParentMediaManager {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
attributeFilter: ["preload", "src"],
};
const targets = selectMediaObserverTargets(doc);
@@ -113,7 +113,7 @@ export async function injectMediaCodecMapIntoHtml(
proxyVariantFor(facts),
).catch(() => {
// Swallowed: the pre-warm is best-effort. A real `?hf-proxy=` request
// for this asset re-attempts the transcode and reports failure (502).
// for this asset hears the remembered failure (502) or re-attempts it.
});
}
// <-escape prevents a src path containing "</script>" from breaking out of
@@ -487,6 +487,13 @@ describe("resolveProxy", () => {
await flush();
}
await Promise.all(accepted);
// A full queue is not remembered: once there is room, the same clip transcodes.
const retry = resolveProxy(projectDir, sourcePaths.at(-1)!);
await flush();
expect(calls).toHaveLength(accepted.length + 1);
succeed(calls.at(-1)!);
await expect(retry).resolves.toBeTruthy();
});
it("honors bounded concurrency and queue environment overrides", async () => {
@@ -627,6 +634,104 @@ describe("resolveProxy", () => {
await expect(retry).resolves.toBeTruthy();
});
// A real ffprobe answers after a macrotask, so a failure lands after a zero wait gave up.
async function loadWithSlowProbe(
spawn: SpawnImpl,
ffmpegPath: () => string | undefined,
probe: () => Promise<unknown>,
): Promise<typeof import("./proxyTranscoder.js")> {
vi.resetModules();
vi.doMock("node:child_process", () => {
const mocked = { spawn };
return { ...mocked, default: mocked };
});
vi.doMock("@hyperframes/parsers/ff-binaries", () => ({ findFfBinary: ffmpegPath }));
vi.doMock("./mediaMetadata.js", () => ({
probeMediaMetadata: () =>
new Promise((resolveProbe) => setTimeout(resolveProbe, 5)).then(probe),
}));
return import("./proxyTranscoder.js");
}
const sleep = (ms: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
it("keeps an environment failure briefly, so an ask that stopped waiting hears it next", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const { spawn, calls } = createSpawnSpy();
let ffmpegPath: string | undefined;
const { resolveProxy, waitForProxy, ProxyWaitTimeoutError } = await loadWithSlowProbe(
spawn,
() => ffmpegPath,
async () => ({ kind: "video", color: { isHdr: false } }),
);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "video.mov");
writeFileSync(sourcePath, "source-bytes");
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toBeInstanceOf(
ProxyWaitTimeoutError,
);
await sleep(30);
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toThrow(
"ffmpeg binary not found",
);
ffmpegPath = FFMPEG_PATH;
now.mockReturnValue(1_000 + 10_001);
const retry = resolveProxy(projectDir, sourcePath);
await sleep(30);
expect(calls).toHaveLength(1);
succeed(calls[0]!);
await expect(retry).resolves.toBeTruthy();
now.mockRestore();
});
it("remembers a failure that is not a transcode error, so it is not retried on every ask", async () => {
const { spawn } = createSpawnSpy();
const probe = vi.fn(async () => {
throw new Error("EBUSY: file locked");
});
const { resolveProxy, waitForProxy, ProxyWaitTimeoutError } = await loadWithSlowProbe(
spawn,
() => FFMPEG_PATH,
probe,
);
const projectDir = tmpProject();
const sourcePath = join(projectDir, "video.mov");
writeFileSync(sourcePath, "source-bytes");
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toBeInstanceOf(
ProxyWaitTimeoutError,
);
await sleep(30);
await expect(waitForProxy(resolveProxy(projectDir, sourcePath), 0)).rejects.toThrow("EBUSY");
expect(probe).toHaveBeenCalledTimes(1);
});
it("marks a project's copy as in flight, and moves the mark once it lands", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy, proxyActivityMark } = await loadWithSlowProbe(
spawn,
() => FFMPEG_PATH,
async () => ({ kind: "video", color: { isHdr: false } }),
);
const projectDir = tmpProject();
const otherProjectDir = tmpProject();
const sourcePath = join(projectDir, "video.mov");
writeFileSync(sourcePath, "source-bytes");
const before = proxyActivityMark(projectDir);
const copy = resolveProxy(projectDir, sourcePath);
expect(proxyActivityMark(projectDir)).toBeNull();
expect(proxyActivityMark(otherProjectDir)).toBe(before);
await vi.waitFor(() => expect(calls).toHaveLength(1));
succeed(calls[0]!);
await copy;
expect(proxyActivityMark(projectDir)).not.toBeNull();
expect(proxyActivityMark(projectDir)).not.toBe(before);
expect(proxyActivityMark(join(projectDir, "missing"))).not.toBeNull();
});
it("rejects sources outside the project before probing or spawning", async () => {
const { spawn, calls } = createSpawnSpy();
const { resolveProxy, ProxySourceOutsideProjectError } = await loadModule(spawn, FFMPEG_PATH);
@@ -46,6 +46,8 @@ const MAX_QUEUED_TRANSCODES = boundedEnvInteger("HYPERFRAMES_PROXY_MAX_QUEUE", 8
const STDERR_TAIL_MAX_CHARS = 4000;
export const TRANSCODE_TIMEOUT_MS = 15 * 60 * 1000;
const FAILURE_CACHE_TTL_MS = 60 * 1000;
export const PROXY_PENDING_RETRY_AFTER_SECONDS = 2;
const ENVIRONMENT_FAILURE_TTL_MS = 5 * PROXY_PENDING_RETRY_AFTER_SECONDS * 1000;
const MAX_FAILURE_CACHE_ENTRIES = 128;
export const DEFAULT_PROXY_WAIT_TIMEOUT_MS = 2 * 60 * 1000;
@@ -62,7 +64,7 @@ export class ProxyTranscodeError extends Error {
}
/** "ffmpeg isn't installed" — an environment condition, not a per-source
* failure, so it is deliberately NOT remembered by the negative cache below
* failure, so the negative cache below keeps it only briefly
* (installing ffmpeg mid-session must recover without a server restart). */
class FfmpegUnavailableError extends ProxyTranscodeError {
constructor() {
@@ -270,7 +272,7 @@ function markCacheEntryUsed(cachePath: string): void {
// requests for a broken asset rethrow instantly instead of respawning ffmpeg
// on every retry the browser makes.
interface RememberedFailure {
error: ProxyTranscodeError;
error: unknown;
expiresAt: number;
}
@@ -302,9 +304,13 @@ function ensureHdrFilters(ffmpegPath: string): Promise<void> {
return promise;
}
function rememberFailure(cachePath: string, error: ProxyTranscodeError): void {
function rememberFailure(cachePath: string, error: unknown): void {
const ttlMs =
error instanceof FfmpegUnavailableError || error instanceof FfmpegMissingFilterError
? ENVIRONMENT_FAILURE_TTL_MS
: FAILURE_CACHE_TTL_MS;
failedTranscodes.delete(cachePath);
failedTranscodes.set(cachePath, { error, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS });
failedTranscodes.set(cachePath, { error, expiresAt: Date.now() + ttlMs });
while (failedTranscodes.size > MAX_FAILURE_CACHE_ENTRIES) {
const oldest = failedTranscodes.keys().next().value;
if (oldest === undefined) break;
@@ -462,6 +468,16 @@ async function transcodeToCache(
}
}
let settledProxyCount = 0;
/** Null while a copy for this project is being made; otherwise a mark that moves when any copy finishes. */
export function proxyActivityMark(projectDir: string): string | null {
if (!existsSync(projectDir)) return String(settledProxyCount);
const cacheDir = join(realpath(projectDir), CACHE_DIR_NAME) + sep;
for (const cachePath of inFlight.keys()) if (cachePath.startsWith(cacheDir)) return null;
return String(settledProxyCount);
}
/**
* Resolves the cached proxy variant for `absoluteSourcePath`, transcoding it at
* most once per cache key. Concurrent calls for the same key (including a
@@ -494,19 +510,12 @@ export async function resolveProxy(
const promise = transcodeToCache(source.projectDir, source.sourcePath, cachePath, variant)
.catch((err: unknown) => {
if (
err instanceof ProxyTranscodeError &&
!(err instanceof FfmpegUnavailableError) &&
!(err instanceof FfmpegMissingFilterError) &&
!(err instanceof ProxyCapacityError) &&
!(err instanceof ProxySourceOutsideProjectError)
) {
rememberFailure(cachePath, err);
}
if (!(err instanceof ProxyCapacityError)) rememberFailure(cachePath, err);
throw err;
})
.finally(() => {
inFlight.delete(cachePath);
settledProxyCount += 1;
});
inFlight.set(cachePath, promise);
return promise;
@@ -1014,8 +1014,15 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
"",
);
});
const { waitForProxy, ProxyWaitTimeoutError, PROXY_PENDING_RETRY_AFTER_SECONDS } =
await vi.importActual<typeof import("../helpers/proxyTranscoder.js")>(
"../helpers/proxyTranscoder.js",
);
vi.doMock("../helpers/proxyTranscoder.js", () => ({
resolveProxy,
waitForProxy,
ProxyWaitTimeoutError,
PROXY_PENDING_RETRY_AFTER_SECONDS,
ProxyTranscodeError: FakeProxyTranscodeError,
ProxyCapacityError: FakeProxyCapacityError,
PROXY_PARAMS_VERSION: "v1",
@@ -1352,6 +1359,44 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
expect(res.headers.get("Retry-After")).toBe("5");
});
it("answers 202 at once while the copy is made, then serves the copy once it lands", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const proxyPath = join(projectDir, "proxy.mp4");
let landCopy!: () => void;
const transcode = new Promise<string>((resolveCopy) => {
landCopy = () => {
writeFileSync(proxyPath, "proxy-bytes");
resolveCopy(proxyPath);
};
});
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: () => transcode,
});
const { mediaProxyDemand } = await import("../helpers/mediaCodecMap.js");
const before = mediaProxyDemand().proxyRequests;
const app = new Hono();
register(app, createAdapter(projectDir));
const url = "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264";
const pending = await Promise.race([
app.request(url),
new Promise<"held">((resolveHeld) => setTimeout(resolveHeld, 1000, "held")),
]);
expect(pending, "a cold copy must not hold the request for the transcode").not.toBe("held");
const cold = pending as Response;
expect(cold.status).toBe(202);
expect(cold.headers.get("Retry-After")).toBe("2");
expect(cold.headers.get("Cache-Control")).toBe("no-store");
expect(mediaProxyDemand().proxyRequests - before).toBe(0);
landCopy();
const ready = await app.request(url);
expect(ready.status).toBe(200);
expect(await ready.text()).toBe("proxy-bytes");
expect(mediaProxyDemand().proxyRequests - before).toBe(1);
});
it("rejects a path-traversal attempt through the proxied path (404, no transcode)", async () => {
const projectDir = createProjectDir();
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
+15 -5
View File
@@ -36,8 +36,11 @@ import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variable
import { injectPreviewVariables } from "../helpers/previewVariables.js";
import {
resolveProxy,
waitForProxy,
ProxyCapacityError,
ProxyTranscodeError,
ProxyWaitTimeoutError,
PROXY_PENDING_RETRY_AFTER_SECONDS,
} from "../helpers/proxyTranscoder.js";
import {
decideMediaProxyEligibility,
@@ -628,13 +631,17 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
let servedPath = file;
let servedContentType = contentType;
if (proxyVariant !== undefined) {
// Here, not at the eligibility gate above: one count per resolved proxy
// shares a unit with `prewarmsRequested`, and a revalidated repeat that
// 304s no longer counts as fresh demand.
recordProxyRequest();
try {
servedPath = await resolveProxy(project.dir, file, proxyVariant);
// A cached copy settles before any timer; a transcode never holds one of
// the browser's few connections to this host. 202 until the copy lands.
servedPath = await waitForProxy(resolveProxy(project.dir, file, proxyVariant), 0);
} catch (err) {
if (err instanceof ProxyWaitTimeoutError) {
return c.text("media proxy is being made", 202, {
"Retry-After": String(PROXY_PENDING_RETRY_AFTER_SECONDS),
"Cache-Control": "no-store",
});
}
if (err instanceof ProxyCapacityError) {
return c.text(err.message, 503, { "Retry-After": "5" });
}
@@ -642,6 +649,9 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
return c.text(message, 502);
}
// After the wait, not at the eligibility gate: one count per served proxy shares
// a unit with `prewarmsRequested`; a 304, a 202 or a failure serves none.
recordProxyRequest();
servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType;
}
@@ -17,6 +17,12 @@ import { pruneThumbnailCache, registerThumbnailRoutes } from "./thumbnail";
import { PREVIEW_CAPTURE_PARAM } from "./preview";
import type { StudioApiAdapter } from "../types";
import { createProjectSignature } from "../helpers/projectSignature.js";
import { proxyActivityMark } from "../helpers/proxyTranscoder.js";
vi.mock("../helpers/proxyTranscoder.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../helpers/proxyTranscoder.js")>()),
proxyActivityMark: vi.fn(() => "0"),
}));
const tempProjectDirs: string[] = [];
@@ -474,6 +480,38 @@ describe("registerThumbnailRoutes", () => {
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("does not cache a thumbnail rendered while a clip's preview copy is still being made", async () => {
const adapter = createAdapter();
await writeComposition(adapter, 1920, 1080);
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
vi.mocked(proxyActivityMark).mockReturnValueOnce(null).mockReturnValueOnce(null);
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const url = "http://localhost/projects/demo/thumbnail/index.html?t=3";
expect((await app.request(url)).status).toBe(200);
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false);
expect((await app.request(url)).status).toBe(200);
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(true);
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("does not cache a thumbnail when a preview copy starts or lands during the render", async () => {
const adapter = createAdapter();
await writeComposition(adapter, 1920, 1080);
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
vi.mocked(proxyActivityMark).mockReturnValueOnce("0").mockReturnValueOnce("1");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
expect(
(await app.request("http://localhost/projects/demo/thumbnail/index.html?t=3")).status,
).toBe(200);
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false);
});
it("does not cache pixels changed in flight behind a stale adapter signature", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
@@ -26,6 +26,7 @@ import {
mkdirWithinProject,
resolveWithinProject,
} from "../helpers/safePath.js";
import { proxyActivityMark } from "../helpers/proxyTranscoder.js";
import { PREVIEW_CAPTURE_PARAM } from "./preview.js";
const THUMBNAIL_CACHE_VERSION = "v4";
@@ -227,6 +228,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
cachePath,
c.req.raw.signal,
async (signal) => {
const previewCopiesAtStart = proxyActivityMark(project.dir);
const generated = await adapter.generateThumbnail!({
project,
compPath,
@@ -242,11 +244,14 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
signal,
});
if (!generated) return null;
const previewCopiesAtEnd = proxyActivityMark(project.dir);
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
const inputsUnchanged = (signature: string) =>
compositionInputSignature(project.dir, compPath, signature) === inputSignature;
// Both the adapter's signature and a fresh one: the adapter's can lag the watcher.
if (
previewCopiesAtStart === null ||
previewCopiesAtEnd !== previewCopiesAtStart ||
afterGeneration?.project.dir !== project.dir ||
!inputsUnchanged(afterGeneration.signature) ||
!inputsUnchanged(createProjectSignature(project.dir))
@@ -13,6 +13,7 @@ describe("timelineElementsChanged", () => {
["audio group hidden", { audioGroupHidden: true }],
["fade in", { fadeIn: 1 }],
["fade out", { fadeOut: 1 }],
["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
])("sees a clip whose %s changed with no timing change", (_name, change) => {
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
@@ -15,6 +15,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
"audioGroupHidden",
"fadeIn",
"fadeOut",
"src",
];
/** Whether a derived timeline changes any field that affects rendering. */
+1
View File
@@ -228,6 +228,7 @@ async function prepareThumbnailPage(
})
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await page.evaluate("window.__hfWaitForSeekCompletion?.()");
await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath);
await page.evaluate("document.fonts?.ready");
await new Promise((resolve) => setTimeout(resolve, 200));