mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(studio): route rooted timeline media through preview (#3061)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user