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);
});
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" });
@@ -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;
}
}
/**
@@ -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;
@@ -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.