mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user