Skip to content

fix(studio): route rooted timeline media through preview - #3061

Merged
miguel-heygen merged 1 commit into
mainfrom
codex/fix-studio-unsupported-media-errors
Aug 6, 2026
Merged

fix(studio): route rooted timeline media through preview#3061
miguel-heygen merged 1 commit into
mainfrom
codex/fix-studio-unsupported-media-errors

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

Fix repeated UnsupportedInputFormatError exceptions when Studio loads timelines whose media uses root-relative paths.

  • Route same-origin rooted video, image, and audio sources through the active project's preview endpoint.
  • Use that same canonical URL for metadata-probe caching and failure suppression.
  • Preserve external HTTP(S), data, blob, canonical project-preview, and other Studio API URLs.

Fixes #3060

Why

The composition iframe resolves paths such as /assets/clip.mp4 against the Studio origin. The timeline then received that absolute URL and treated it as an already-loadable external source. At the root URL, Studio serves its HTML shell, so the media decoder repeatedly tried to parse HTML as video or audio.

Besides noisy exceptions, the non-canonical cache key caused fresh timeline derivations to retry the same invalid source.

How

resolveMediaPreviewUrl remains the single source of truth for timeline media routing. Callers now provide the Studio origin so it can distinguish:

  • same-origin rooted composition media, which belongs to the active project preview;
  • already-canonical or Studio API URLs, which pass through;
  • external, data, and blob URLs, which also pass through.

Metadata probing uses the resolved URL for probe, success cache, and failure cache decisions, so each source has one identity throughout the pipeline.

Test plan

  • Unit tests added/updated for URL routing, video thumbnails, audio waveforms, metadata probing, and cache reuse
  • Full Studio test suite: 3,478 passed
  • Studio TypeScript typecheck
  • oxlint, oxfmt, Fallow, file-size, and tracked-artifact gates
  • Manual browser regression on a media-heavy timeline: clean-load page errors reduced from 26 to 0; media probes returned canonical 206 media responses and the timeline mounted normally
  • Documentation updated (not applicable)

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 at 3be3c2c2b — routes same-origin rooted timeline media (/assets/clip.mp4, resolved by the composition iframe against the Studio origin) back through resolveMediaPreviewUrl so the media decoder no longer parses the Studio HTML shell as video/audio; preserves external HTTP(S), data:, blob:, canonical project-preview, and other /api/* URLs, and threads the same resolved URL through probe/cache identity so canonicalization is uniform across renderer + probe.

Lens checks:

  • Over-scoping (Lens 9, parity/scope): resolveMediaPreviewUrl at packages/studio/src/player/components/thumbnailUtils.ts guards on parsed.origin !== studioOrigin, pathname.startsWith(previewPath), and pathname.startsWith('/api/') — external, canonical preview, and other same-origin API sources pass through. !studioOrigin short-circuits back to the pre-fix code path, so callers that don't yet plumb origin are backward-compatible.
  • Cache/probe identity (Lens 1 invariant preservation + probe cache-key hygiene): both applyCachedSourceDurations and probeMissingSourceDurations in packages/studio/src/player/lib/mediaProbe.ts now key the cache/failure lookup on the resolved URL via resolveProbeSource — same source has one identity throughout the pipeline; failure cache no longer poisons the Studio-shell URL for the project-preview URL (or vice versa).
  • Query/hash preservation: resolveMediaPreviewUrl splits the incoming URL into path (encoded via encodePreviewPath) + ${parsed.search}${parsed.hash} suffix — signed-URL/frag suffixes survive re-routing.
  • Test-pins-the-invariant (per pre-existing-test-comments-encode-invariants): three new invariants added — useRenderClipContent.test.ts for root-relative video+audio routing, thumbnailUtils.test.ts for reroute + pass-through cases (empty / canonical preview / same-origin /api/*), and mediaProbe.test.ts for probe-through-preview + cache-reuse. Each new invariant has an assertion pinning the fix behavior.

Nit (non-blocker): the two try/catch fall-through-to-src paths in resolveMediaPreviewUrl and resolvePreviewRelative are silent — safe fallback, but no telemetry if a genuinely malformed URL reaches the resolver. Not blocking; pre-fix behavior was the same.

No P1/P2 blockers. Holding stamp pending required CI matrix completion — 3 lanes still IN_PROGRESS: Render on windows-latest, Test, Tests on windows-latest. Will exact-head stamp when all 8 required checks show SUCCESS.

— Via

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 3be3c2c2b. All 8 required contexts green at this exact head, enumerated from the branch ruleset rather than from check names, with cancelled runs filtered before taking the latest per context.

Via covered the routing summary and the pass-through set, so I'll stick to what I checked underneath it.

I confirmed the root cause at source rather than from the description

timelineDOM.ts:333 reads (mediaEl as HTMLMediaElement | HTMLImageElement).src — the DOM property, which the browser resolves against the iframe's base. That is exactly the mechanism the PR describes, and it's worth pinning because it also tells you the fix belongs downstream rather than at the scan: the neighbouring getAttribute("src") reads at :161 and :356 keep the raw authored value for inner videos, and switching :333 to match would break composition-relative paths that genuinely need resolving. Fixing it in resolveMediaPreviewUrl is the right call.

One stale comment falls out of that, non-blocking. timelineDOM.ts:330-332 says the resolved absolute URL wins "so the Studio can then fetch the asset regardless of whether the attribute value was relative or absolute." That was true for the relative case it was written for, but root-relative is precisely the case where it wasn't — the resolved URL fetched the shell. Now that the recovery lives elsewhere, that comment reads as a justification for the thing that broke, and a future reader could act on it.

The contract audit

resolveMediaPreviewUrl gained an optional third parameter, so the three call sites this PR doesn't touch still compile and keep the old pass-through. Optional-param changes are where a fix silently half-lands, so I checked whether any of them can reach the new branch: AssetPreviewOverlay.tsx:124, AssetCard.tsx:125, and AudioRow.tsx:42 all pass an asset path off the project's asset list, never a DOM-resolved absolute URL. They can't reach it, and leaving them alone is correct.

applyCachedSourceDurations and probeMissingSourceDurations both took a new required parameter in the middle of their signature. Their only callers are in useTimelinePlayer.ts, both updated, so that change is fully covered.

resolvePreviewRelative is the one that changed shape rather than just gaining a parameter — it lost the !src.startsWith("http") early return, so a bare relative path now resolves to null instead of passing through. That's safe only because its single caller now feeds it the already-resolved URL. It's module-private with exactly one call site, so the composition holds, but it's the part of this diff that would break first if a second caller ever appeared.

Cache identity

The body claims one identity through probe, success cache, and failure cache, and that holds: probeMediaUrl derives a single key = normalizeUrl(url) and uses it for cache, failed, inflight, and the probe itself, while both call sites now pass the resolved source and hasFreshFailure is given the same normalization.

The failure mode I went looking for was a poisoned failure entry while timelineProjectId is still null — a probe against the broken URL recording a failure that then suppresses the good one for the whole TTL. It doesn't happen: resolveProbeSource returns the raw src in the null case, so that failure lands under a different key and the resolved key starts clean once the project resolves.

A side effect worth stating since it isn't in the body: the sidebar already keyed its probes on the relative /api/projects/.../preview/... form, so routing the timeline to the same form means the two now share cache entries instead of probing the same file under two identities.

Two small things, neither worth a change on its own

The parsed.pathname.startsWith(previewPath) bail at thumbnailUtils.ts:117 is subsumed by the /api/ bail on the next line — any preview path starts with /api/. It documents intent, so I'd leave it, but it isn't load-bearing.

Audio clips with blob:/data: sources quietly get better here. Previously resolvePreviewRelative returned the blob URL itself and it was percent-encoded into a waveform path; now it returns null and waveformUrl is simply omitted. That's a fix, not a regression, and it isn't mentioned anywhere.

The assumption this rests on

Any same-origin absolute URL outside /api/ is now treated as a root-relative authored path and routed into the project. That's right for composition-authored media, and it's the whole point of the fix. It does mean a composition referencing something Studio itself serves from root would now 404 against the project instead of loading. I don't think that's reachable for real media, and the comment states the assumption plainly, which is what I'd want. Flagging it only so the choice is on the record.

— Rames Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 stamp at 3be3c2c — R1 was clean (no P1/P2 blockers); required CI matrix now fully green (all 8 lanes). Confirming approval to close Miguel's explicit "Via and Rames stamp" ask; jrusso1020 also approved independently at this head. — Via

@miguel-heygen
miguel-heygen merged commit 88853f1 into main Aug 6, 2026
45 checks passed
@miguel-heygen
miguel-heygen deleted the codex/fix-studio-unsupported-media-errors branch August 6, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio floods page errors for unsupported timeline media

3 participants