mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(studio): route rooted timeline media through preview (#3061)
This commit is contained in:
@@ -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,15 +106,19 @@ 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(
|
||||||
usePlayerStore.setState((state) => {
|
mergedElements,
|
||||||
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
state.timelineProjectId,
|
||||||
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
|
(key, durationSeconds) => {
|
||||||
const patched = state.elements.slice();
|
usePlayerStore.setState((state) => {
|
||||||
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
|
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
||||||
return { elements: patched };
|
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],
|
[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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user