fix(studio): route rooted timeline media through preview (#3061)

This commit is contained in:
Miguel Ángel
2026-08-05 22:14:28 -07:00
committed by GitHub
parent 96861cbafc
commit 88853f170f
7 changed files with 223 additions and 42 deletions
@@ -51,6 +51,34 @@ describe("computeThumbnailStrip", () => {
});
describe("resolveMediaPreviewUrl", () => {
it("reroutes same-origin root media resolved by the preview iframe", () => {
expect(
resolveMediaPreviewUrl(
"http://localhost:5190/assets/clip.mp4",
"proj-1",
"http://localhost:5190",
),
).toBe("/api/projects/proj-1/preview/assets/clip.mp4");
});
it("preserves empty, canonical preview, and same-origin API sources", () => {
expect(resolveMediaPreviewUrl("", "proj-1", "http://localhost:5190")).toBe("");
expect(
resolveMediaPreviewUrl(
"http://localhost:5190/api/projects/proj-1/preview/assets/clip.mp4",
"proj-1",
"http://localhost:5190",
),
).toBe("http://localhost:5190/api/projects/proj-1/preview/assets/clip.mp4");
expect(
resolveMediaPreviewUrl(
"http://localhost:5190/api/media/clip.mp4",
"proj-1",
"http://localhost:5190",
),
).toBe("http://localhost:5190/api/media/clip.mp4");
});
it("routes composition-relative paths through the project preview endpoint", () => {
expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe(
"/api/projects/proj-1/preview/assets/image.png",
@@ -90,12 +90,43 @@ export function encodePreviewPath(relativePath: string): string {
* (parent) document. Composition-relative paths (e.g. "assets/image.png") are
* routed through the project preview endpoint with each segment encoded.
*
* Already-loadable URLs pass through untouched: absolute http(s) URLs, plus
* `data:` and `blob:` URLs. Routing a `data:`/`blob:` URL through the preview
* endpoint would percent-encode the whole thing into a multi-KB path segment
* that the server rejects with HTTP 431 (Request Header Fields Too Large).
* External http(s), `data:`, and `blob:` URLs pass through untouched. A
* same-origin absolute URL outside the project preview endpoint is the browser's
* resolved form of a root-relative authored path, so route it back through the
* active project instead of accidentally fetching the Studio shell.
*/
export function resolveMediaPreviewUrl(src: string, projectId: string): string {
if (/^(?:https?:|data:|blob:)/i.test(src)) return src;
return `/api/projects/${projectId}/preview/${encodePreviewPath(src)}`;
export function resolveMediaPreviewUrl(
src: string,
projectId: string,
studioOrigin?: string,
): string {
if (!src) return src;
if (/^(?:data:|blob:)/i.test(src)) return src;
let relativePath = src;
let suffix = "";
if (/^https?:/i.test(src)) {
let parsed: URL;
try {
parsed = new URL(src);
} catch {
return src;
}
if (!studioOrigin || parsed.origin !== studioOrigin) return src;
const previewPath = new URL(`/api/projects/${projectId}/preview/`, studioOrigin).pathname;
if (parsed.pathname.startsWith(previewPath)) return src;
if (parsed.pathname.startsWith("/api/")) return src;
try {
relativePath = parsed.pathname
.replace(/^\/+/, "")
.split("/")
.map(decodeURIComponent)
.join("/");
} catch {
return src;
}
suffix = `${parsed.search}${parsed.hash}`;
}
return `/api/projects/${projectId}/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`;
}
@@ -86,6 +86,7 @@ export function useTimelinePlayer() {
state.duration,
resolvedDuration,
),
state.timelineProjectId,
),
);
@@ -105,15 +106,19 @@ export function useTimelinePlayer() {
// Asynchronously enrich media elements still missing sourceDuration
// (header-only probe, cheap), applying each resolved value to the store.
void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => {
usePlayerStore.setState((state) => {
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
const patched = state.elements.slice();
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
return { elements: patched };
});
});
void probeMissingSourceDurations(
mergedElements,
state.timelineProjectId,
(key, durationSeconds) => {
usePlayerStore.setState((state) => {
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
const patched = state.elements.slice();
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
return { elements: patched };
});
},
);
},
[setElements, setTimelineReady, setDuration],
);
@@ -1,15 +1,24 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMediaProbeDiagnostics, probeMediaUrl, resetMediaProbeRegistry } from "./mediaProbe";
import {
applyCachedSourceDurations,
getMediaProbeDiagnostics,
probeMediaUrl,
probeMissingSourceDurations,
resetMediaProbeRegistry,
} from "./mediaProbe";
const dispose = vi.fn();
const getDurationFromMetadata = vi.fn(async () => 5);
const requestedSources: string[] = [];
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {}
constructor(readonly url: string) {
requestedSources.push(url);
}
},
Input: class {
getDurationFromMetadata = getDurationFromMetadata;
@@ -22,6 +31,7 @@ vi.mock("mediabunny", () => ({
beforeEach(() => {
resetMediaProbeRegistry();
vi.clearAllMocks();
requestedSources.length = 0;
getDurationFromMetadata.mockResolvedValue(5);
});
@@ -78,4 +88,56 @@ describe("media probe registry", () => {
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
});
it("probes same-origin rooted media through the active project preview", async () => {
const apply = vi.fn();
await probeMissingSourceDurations(
[
{
id: "clip",
tag: "video",
src: `${window.location.origin}/assets/clip.mp4`,
},
],
"project-a",
apply,
);
expect(requestedSources).toEqual([
`${window.location.origin}/api/projects/project-a/preview/assets/clip.mp4`,
]);
expect(apply).toHaveBeenCalledWith("clip", 5);
expect(
applyCachedSourceDurations(
[
{
id: "clip",
tag: "video",
src: `${window.location.origin}/assets/clip.mp4`,
},
],
"project-a",
),
).toEqual([
{
id: "clip",
tag: "video",
src: `${window.location.origin}/assets/clip.mp4`,
sourceDuration: 5,
},
]);
await probeMissingSourceDurations(
[
{
id: "clip",
tag: "video",
src: `${window.location.origin}/assets/clip.mp4`,
},
],
"project-a",
apply,
);
expect(requestedSources).toHaveLength(1);
});
});
+26 -14
View File
@@ -1,3 +1,4 @@
import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
export interface MediaProbeResult {
@@ -85,6 +86,10 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
return cached?.result;
}
function resolveProbeSource(src: string, projectId: string | null): string {
return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
}
function evictMetadataOverflow(): void {
const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
if (overflow <= 0) return;
@@ -106,11 +111,11 @@ function evictMetadataOverflow(): void {
*/
export function applyCachedSourceDurations<
T extends { src?: string; tag: string; sourceDuration?: number },
>(elements: T[]): T[] {
>(elements: T[], projectId: string | null): T[] {
return elements.map((el) => {
const tag = el.tag.toLowerCase();
if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
const cached = getCachedProbe(el.src);
const cached = getCachedProbe(resolveProbeSource(el.src, projectId));
return cached?.duration && cached.duration > 0
? { ...el, sourceDuration: cached.duration }
: el;
@@ -124,20 +129,27 @@ export function applyCachedSourceDurations<
*/
export async function probeMissingSourceDurations<
T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
>(elements: T[], apply: (key: string, durationSeconds: number) => void): Promise<void> {
const needs = elements.filter(
(el) =>
el.src &&
el.sourceDuration == null &&
["video", "audio"].includes(el.tag.toLowerCase()) &&
!getCachedProbe(el.src) &&
!hasFreshFailure(normalizeUrl(el.src)),
);
>(
elements: T[],
projectId: string | null,
apply: (key: string, durationSeconds: number) => void,
): Promise<void> {
const needs = elements.flatMap((el) => {
if (
!el.src ||
el.sourceDuration != null ||
!["video", "audio"].includes(el.tag.toLowerCase())
) {
return [];
}
const source = resolveProbeSource(el.src, projectId);
return !getCachedProbe(source) && !hasFreshFailure(normalizeUrl(source))
? [{ el, source }]
: [];
});
if (needs.length === 0) return;
await Promise.allSettled(
needs.map(async (el) => {
const source = el.src;
if (!source) return;
needs.map(async ({ el, source }) => {
const result = await probeMediaUrl(source);
if (result) apply(el.key ?? el.id, result.duration);
}),