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
@@ -108,6 +108,42 @@ describe("useRenderClipContent", () => {
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform); if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
}); });
it("routes root-relative iframe media back through the active project", () => {
usePlayerStore.setState({ thumbnailMode: "adaptive" });
const resolvedRootMedia = `${window.location.origin}/assets/clip.mp4`;
const video = renderClipContent(
{
id: "video",
tag: "video",
start: 0,
duration: 4,
track: 0,
src: resolvedRootMedia,
},
null,
);
const audio = renderClipContent({
id: "audio",
tag: "audio",
start: 0,
duration: 4,
track: 1,
src: resolvedRootMedia,
});
expect(isValidElement<{ videoSrc: string }>(video)).toBe(true);
expect(isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)).toBe(true);
if (isValidElement<{ videoSrc: string }>(video)) {
expect(video.props.videoSrc).toBe("/api/projects/my-project/preview/assets/clip.mp4");
}
if (isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)) {
expect(audio.props).toMatchObject({
audioUrl: "/api/projects/my-project/preview/assets/clip.mp4",
waveformUrl: "/api/projects/my-project/waveform/assets/clip.mp4",
});
}
});
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => { it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
usePlayerStore.setState({ thumbnailMode: "adaptive" }); usePlayerStore.setState({ thumbnailMode: "adaptive" });
@@ -27,12 +27,21 @@ export function normalizeCompositionSrc(
} }
/** Resolve a media src to its project-relative preview path, or null. */ /** Resolve a media src to its project-relative preview path, or null. */
function resolvePreviewRelative(src: string | undefined, pid: string): string | null { function resolvePreviewRelative(
src: string | undefined,
pid: string,
origin: string,
): string | null {
if (!src) return null; if (!src) return null;
if (!src.startsWith("http")) return src; try {
const base = `/api/projects/${pid}/preview/`; const parsed = new URL(src, origin);
const idx = src.indexOf(base); const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname;
return idx !== -1 ? decodeURIComponent(src.slice(idx + base.length)) : null; return parsed.pathname.startsWith(base)
? decodeURIComponent(parsed.pathname.slice(base.length))
: null;
} catch {
return null;
}
} }
/** /**
@@ -61,14 +70,12 @@ function renderAudioClip(
labelColor: string, labelColor: string,
context: TimelineClipRenderContext, context: TimelineClipRenderContext,
): ReactNode { ): ReactNode {
const srcRelative = resolvePreviewRelative(el.src, pid); const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
// Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative // what the assets panel loads — a raw segment 404s. resolvePreviewRelative
// returns the DECODED path, so it must be re-encoded here. // returns the DECODED path, so it must be re-encoded here.
const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null; const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
const audioUrl = encodedRelative
? `/api/projects/${pid}/preview/${encodedRelative}`
: (el.src ?? "");
const waveformUrl = encodedRelative const waveformUrl = encodedRelative
? `/api/projects/${pid}/waveform/${encodedRelative}` ? `/api/projects/${pid}/waveform/${encodedRelative}`
: undefined; : undefined;
@@ -184,7 +191,7 @@ export function useRenderClipContent({
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id); !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
if ((el.tag === "video" || el.tag === "img") && el.src) { if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = resolveMediaPreviewUrl(el.src, pid); const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
// Still images can't be decoded by VideoThumbnail's <video> extractor // Still images can't be decoded by VideoThumbnail's <video> extractor
// (the error event fires and the shimmer never resolves) — render the // (the error event fires and the shimmer never resolves) — render the
// image itself as the strip. // image itself as the strip.
@@ -51,6 +51,34 @@ describe("computeThumbnailStrip", () => {
}); });
describe("resolveMediaPreviewUrl", () => { 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", () => { it("routes composition-relative paths through the project preview endpoint", () => {
expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe( expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe(
"/api/projects/proj-1/preview/assets/image.png", "/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 * (parent) document. Composition-relative paths (e.g. "assets/image.png") are
* routed through the project preview endpoint with each segment encoded. * routed through the project preview endpoint with each segment encoded.
* *
* Already-loadable URLs pass through untouched: absolute http(s) URLs, plus * External http(s), `data:`, and `blob:` URLs pass through untouched. A
* `data:` and `blob:` URLs. Routing a `data:`/`blob:` URL through the preview * same-origin absolute URL outside the project preview endpoint is the browser's
* endpoint would percent-encode the whole thing into a multi-KB path segment * resolved form of a root-relative authored path, so route it back through the
* that the server rejects with HTTP 431 (Request Header Fields Too Large). * active project instead of accidentally fetching the Studio shell.
*/ */
export function resolveMediaPreviewUrl(src: string, projectId: string): string { export function resolveMediaPreviewUrl(
if (/^(?:https?:|data:|blob:)/i.test(src)) return src; src: string,
return `/api/projects/${projectId}/preview/${encodePreviewPath(src)}`; 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, state.duration,
resolvedDuration, resolvedDuration,
), ),
state.timelineProjectId,
), ),
); );
@@ -105,7 +106,10 @@ export function useTimelinePlayer() {
// Asynchronously enrich media elements still missing sourceDuration // Asynchronously enrich media elements still missing sourceDuration
// (header-only probe, cheap), applying each resolved value to the store. // (header-only probe, cheap), applying each resolved value to the store.
void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => { void probeMissingSourceDurations(
mergedElements,
state.timelineProjectId,
(key, durationSeconds) => {
usePlayerStore.setState((state) => { usePlayerStore.setState((state) => {
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key); const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || state.elements[idx].sourceDuration != null) return {}; if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
@@ -113,7 +117,8 @@ export function useTimelinePlayer() {
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds }; patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
return { elements: patched }; return { elements: patched };
}); });
}); },
);
}, },
[setElements, setTimelineReady, setDuration], [setElements, setTimelineReady, setDuration],
); );
@@ -1,15 +1,24 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 dispose = vi.fn();
const getDurationFromMetadata = vi.fn(async () => 5); const getDurationFromMetadata = vi.fn(async () => 5);
const requestedSources: string[] = [];
vi.mock("mediabunny", () => ({ vi.mock("mediabunny", () => ({
ALL_FORMATS: {}, ALL_FORMATS: {},
UrlSource: class { UrlSource: class {
constructor(readonly url: string) {} constructor(readonly url: string) {
requestedSources.push(url);
}
}, },
Input: class { Input: class {
getDurationFromMetadata = getDurationFromMetadata; getDurationFromMetadata = getDurationFromMetadata;
@@ -22,6 +31,7 @@ vi.mock("mediabunny", () => ({
beforeEach(() => { beforeEach(() => {
resetMediaProbeRegistry(); resetMediaProbeRegistry();
vi.clearAllMocks(); vi.clearAllMocks();
requestedSources.length = 0;
getDurationFromMetadata.mockResolvedValue(5); getDurationFromMetadata.mockResolvedValue(5);
}); });
@@ -78,4 +88,56 @@ describe("media probe registry", () => {
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull(); await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2); 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"; import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
export interface MediaProbeResult { export interface MediaProbeResult {
@@ -85,6 +86,10 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
return cached?.result; return cached?.result;
} }
function resolveProbeSource(src: string, projectId: string | null): string {
return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
}
function evictMetadataOverflow(): void { function evictMetadataOverflow(): void {
const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries; const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
if (overflow <= 0) return; if (overflow <= 0) return;
@@ -106,11 +111,11 @@ function evictMetadataOverflow(): void {
*/ */
export function applyCachedSourceDurations< export function applyCachedSourceDurations<
T extends { src?: string; tag: string; sourceDuration?: number }, T extends { src?: string; tag: string; sourceDuration?: number },
>(elements: T[]): T[] { >(elements: T[], projectId: string | null): T[] {
return elements.map((el) => { return elements.map((el) => {
const tag = el.tag.toLowerCase(); const tag = el.tag.toLowerCase();
if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el; 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 return cached?.duration && cached.duration > 0
? { ...el, sourceDuration: cached.duration } ? { ...el, sourceDuration: cached.duration }
: el; : el;
@@ -124,20 +129,27 @@ export function applyCachedSourceDurations<
*/ */
export async function probeMissingSourceDurations< export async function probeMissingSourceDurations<
T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string }, 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( elements: T[],
(el) => projectId: string | null,
el.src && apply: (key: string, durationSeconds: number) => void,
el.sourceDuration == null && ): Promise<void> {
["video", "audio"].includes(el.tag.toLowerCase()) && const needs = elements.flatMap((el) => {
!getCachedProbe(el.src) && if (
!hasFreshFailure(normalizeUrl(el.src)), !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; if (needs.length === 0) return;
await Promise.allSettled( await Promise.allSettled(
needs.map(async (el) => { needs.map(async ({ el, source }) => {
const source = el.src;
if (!source) return;
const result = await probeMediaUrl(source); const result = await probeMediaUrl(source);
if (result) apply(el.key ?? el.id, result.duration); if (result) apply(el.key ?? el.id, result.duration);
}), }),