From 3be3c2c2bd33ef3dc23a032b92e4731034ae118e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 5 Aug 2026 21:38:12 -0700 Subject: [PATCH] fix(studio): route rooted timeline media through preview --- .../src/hooks/useRenderClipContent.test.ts | 36 ++++++++++ .../studio/src/hooks/useRenderClipContent.ts | 27 +++++--- .../player/components/thumbnailUtils.test.ts | 28 ++++++++ .../src/player/components/thumbnailUtils.ts | 45 +++++++++++-- .../src/player/hooks/useTimelinePlayer.ts | 23 ++++--- .../studio/src/player/lib/mediaProbe.test.ts | 66 ++++++++++++++++++- packages/studio/src/player/lib/mediaProbe.ts | 40 +++++++---- 7 files changed, 223 insertions(+), 42 deletions(-) diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 8342f180b9..5b85989ad6 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -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" }); diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index 14b05a5583..cbd3c6934f 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -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