Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/studio/src/hooks/useRenderClipContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,42 @@ describe("useRenderClipContent", () => {
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", () => {
usePlayerStore.setState({ thumbnailMode: "adaptive" });

Expand Down
27 changes: 17 additions & 10 deletions packages/studio/src/hooks/useRenderClipContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ export function normalizeCompositionSrc(
}

/** 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.startsWith("http")) return src;
const base = `/api/projects/${pid}/preview/`;
const idx = src.indexOf(base);
return idx !== -1 ? decodeURIComponent(src.slice(idx + base.length)) : null;
try {
const parsed = new URL(src, origin);
const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname;
return parsed.pathname.startsWith(base)
? decodeURIComponent(parsed.pathname.slice(base.length))
: null;
} catch {
return null;
}
}

/**
Expand Down Expand Up @@ -61,14 +70,12 @@ function renderAudioClip(
labelColor: string,
context: TimelineClipRenderContext,
): 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
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative
// returns the DECODED path, so it must be re-encoded here.
const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
const audioUrl = encodedRelative
? `/api/projects/${pid}/preview/${encodedRelative}`
: (el.src ?? "");
const waveformUrl = encodedRelative
? `/api/projects/${pid}/waveform/${encodedRelative}`
: undefined;
Expand Down Expand Up @@ -184,7 +191,7 @@ export function useRenderClipContent({
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);

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
// (the error event fires and the shimmer never resolves) — render the
// image itself as the strip.
Expand Down
28 changes: 28 additions & 0 deletions packages/studio/src/player/components/thumbnailUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 38 additions & 7 deletions packages/studio/src/player/components/thumbnailUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
23 changes: 14 additions & 9 deletions packages/studio/src/player/hooks/useTimelinePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function useTimelinePlayer() {
state.duration,
resolvedDuration,
),
state.timelineProjectId,
),
);

Expand All @@ -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],
);
Expand Down
66 changes: 64 additions & 2 deletions packages/studio/src/player/lib/mediaProbe.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +31,7 @@ vi.mock("mediabunny", () => ({
beforeEach(() => {
resetMediaProbeRegistry();
vi.clearAllMocks();
requestedSources.length = 0;
getDurationFromMetadata.mockResolvedValue(5);
});

Expand Down Expand Up @@ -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);
});
});
40 changes: 26 additions & 14 deletions packages/studio/src/player/lib/mediaProbe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";

export interface MediaProbeResult {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}),
Expand Down
Loading