From cbcadbfbd089de11fe30f1054fdcf54956aad780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 28 Sep 2026 16:14:29 +0000 Subject: [PATCH] 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 --- .../core/src/runtime/init.swapScenes.test.ts | 4 + packages/core/src/runtime/media.ts | 2 +- packages/core/src/runtime/mediaProxy.test.ts | 298 ++++++++++++++++-- packages/core/src/runtime/mediaProxy.ts | 42 ++- packages/core/src/runtime/proxyWait.ts | 19 ++ .../player/src/hyperframes-player.test.ts | 6 +- packages/player/src/parent-media.test.ts | 132 ++++++++ packages/player/src/parent-media.ts | 58 +++- .../src/helpers/mediaProxyPreview.ts | 2 +- .../src/helpers/proxyTranscoder.test.ts | 105 ++++++ .../src/helpers/proxyTranscoder.ts | 35 +- .../studio-server/src/routes/preview.test.ts | 45 +++ packages/studio-server/src/routes/preview.ts | 20 +- .../src/routes/thumbnail.test.ts | 38 +++ .../studio-server/src/routes/thumbnail.ts | 5 + .../player/hooks/timelinePlayerSync.test.ts | 1 + .../src/player/hooks/timelinePlayerSync.ts | 1 + packages/studio/vite.browser.ts | 1 + 18 files changed, 741 insertions(+), 73 deletions(-) create mode 100644 packages/core/src/runtime/proxyWait.ts diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 246e5981da..5935a3c6e0 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -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: '' }; 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: `

A two

${video}${video}` }, B]).html, ); + await tick(); const videos = Array.from(sceneHost("a").querySelectorAll("video")); expect(videos.map((v) => v.src)).toEqual([proxied, proxied]); }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index b3eb0db730..1428f8d88e 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -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 void>(); // A seeking video still paints its previous frame, and one still fetching its first data paints none (its seek diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index a86133c4bb..77fa32b20b 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -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