fix(studio): resolve project-root-relative asset URLs in preview iframe#1698
Conversation
…nder The server-side bundler (`inlineSubCompositions`) rewrites `../`-traversing asset paths inside sub-compositions against the sub-comp's source path so they resolve against the project root in the baked render. The browser-side runtime that mounts external sub-compositions via `fetch` did no such rewriting — so an authored `<video src="../../assets/x.mp4">` inside a `compositions/frames/scene.html` resolved against the main document's base href (the project preview root) and climbed above it, producing a 404 in Studio preview while the render output worked correctly. Apply the same `../`-only rewrite at runtime, against the sub-composition's URL, covering `[src]` / `[href]` attributes, `[style]` url(...) references, and `<style>` element CSS — the same surface the server-side path touches. Plain project-root-relative paths (`assets/x.mp4`) are intentionally left untouched: the main document's `<base href>` already covers them, and rewriting would double-prefix. The walk recurses into `<template>` content because authored compositions typically wrap their rendered body in a `<template>` and querySelectorAll does not enter template content (it lives in a detached DocumentFragment). Reported-By: Miao Yang — Jerrai
miga-heygen
left a comment
There was a problem hiding this comment.
Review: fix(studio): resolve project-root-relative asset URLs in preview iframe
Solid fix for a real render-vs-preview divergence. The diagnosis is spot-on and the implementation mirrors the server-side path cleanly.
What I checked
-
Rewrite scope is correct — Only
../-traversing paths get rewritten. Plain relative (assets/x.mp4) are untouched because<base href>already covers them. Absolute URLs,data:, hash refs, root-relative, and protocol-relative all skip viaisNonRelativeRuntimeUrl. This avoids the double-prefix trap. -
CSS surface covered —
<style>element text AND inlinestyleattributeurl()references both handled viaCSS_URL_RE. The regex preserves the original quote style (single, double, or bare) via backreference — no cosmetic mutations. -
Template recursion —
querySelectorAlldoesn't enter<template>content DocumentFragments, so the explicit recursion intotemplateEl.contentis essential. Without it, every asset ref inside the canonical template wrapper (which is the majority of authored compositions) would be missed. Correct. -
Graceful failure —
rewriteRuntimeAssetPathcatches URL construction errors,rewriteSubCompositionAssetPathsearly-returns on nullcompositionUrl. No crash path. -
Placement — The rewrite happens after
DOMParser.parseFromStringand before node extraction. Right timing — paths are rewritten before anything gets mounted into the live document. -
Test coverage — 6 tests covering the full contract:
../-traversing src on template-wrapped sub-comps, plain paths untouched (no double-prefix), absolute/data/hash/root-relative untouched, CSSurl()in<style>blocks, inlinestyleurl(), and non-template full-HTML-doc sub-comps. All the important edge cases. -
CI — Nearly all green, just Analyze (JS/TS) and Windows tests still pending. Core test suite (2065), Studio tests (1148), all passed.
One observation (non-blocking)
The CSS_URL_RE regex (/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g) won't handle CSS values containing escaped parentheses or quotes (e.g. url("path with \) parens")). Not a concern for real asset paths — just noting the boundary.
LGTM — ship it.
— Review by Miga
…sal in asset paths The old `invalid_capture_path` rule caught only `../capture/` in raw source. The same root cause applies to every `../`-traversing asset path inside a sub-composition — they all climb above the project root and 404 in Studio preview, because compositions are served with the project root as their base URL. The bundler rewrites these correctly for the baked render (and the runtime now mirrors that fallback in `rewriteSubCompositionAssetPaths`), but the authoring-time signal is still wrong and worth flagging. Replace the old rule with `invalid_parent_traversal_in_asset_path`. The new rule walks parsed tag attributes (`src`, `href`, inline `style` url()) and the extracted `<style>` blocks' url() references, and flags any value that starts with `../`. The check covers the same surface the runtime fallback touches, so the lint catches exactly what the runtime would otherwise silently rewrite at preview time. Subsumes the older rule (every `../capture/` value still fires under the new code), with broader coverage for `../assets/`, `../../assets/`, `../fonts/`, `../voice/`, and other per-traversal asset directories. Plain relative paths, absolute URLs, `data:` URIs, root-relative paths, and hash anchors are intentionally left untouched. — Jerrai
|
Added generalized lint rule per James's request — |
miguel-heygen
left a comment
There was a problem hiding this comment.
Verified current head d7c8fb74 after the lint-rule follow-up.
Audited: packages/core/src/lint/rules/composition.ts, packages/core/src/lint/rules/composition.test.ts, and the runtime rewrite integration in packages/core/src/runtime/compositionLoader.ts / compositionLoader.test.ts.
The generalized invalid_parent_traversal_in_asset_path rule now covers the same surface the runtime fallback rewrites: src, href, inline style url(), and <style> block url(). It preserves the intended skips for absolute, root-relative, hash, data URI, plain assets/..., registry source, and installed registry cases. Runtime rewrite still mirrors the render bundler path and recurses through <template> content.
Validation: GitHub checks are green at head, including build/typecheck/test/lint, Windows tests/render, perf, preview, and all regression shards. Local focused lint-rule check also passed: bunx vitest run src/lint/rules/composition.test.ts --environment node (74/74). I could not run the runtime test locally because this checkout's Bun/jsdom setup fails before collection on an unrelated html-encoding-sniffer CJS/ESM interop issue; CI covered it successfully.
Verdict: APPROVE
Reasoning: The runtime fix plus generalized author-time lint closes the preview/render asset-path divergence, and the expanded tests/CI cover the affected surfaces.
— Magi
What
Studio preview now resolves
<video src="../../assets/x.mp4">(and thesame shape for
<img>,<audio>, inlinestyleurl(), and<style>CSS
url()) against the sub-composition's URL — matching what theserver-side bundler already does for the render path.
Why
Authored compositions live at
compositions/frames/*.htmland referenceproject-root assets either as plain
assets/x.mp4(already correctbecause the main document's
<base href>points at the project previewroot) or as
../../assets/x.mp4(the explicit project-root-relativeform). The server-side
inlineSubCompositionsflattens sub-comps intoindex.htmland rewrites the../-form against the sub-comp's sourcepath so it resolves against the project root in the baked render.
The browser-side runtime that mounts external sub-compositions via
fetchdid no such rewriting. So<video src="../../assets/x.mp4">authored inside a
compositions/frames/scene.htmlresolved against themain document's base href, climbed above the project root, and 404'd in
Studio preview — even though the same path rendered correctly in the
final video. An OSS user (Miao Yang) hit this in a real project.
How
Added
rewriteSubCompositionAssetPathsto the runtimecompositionLoader. After parsing the fetched sub-composition HTML andbefore extracting any nodes, walk the parsed document and rewrite the
same surface the server-side path touches:
[src]and[href]attributes on every element[style]attributeurl(...)references<style>element CSSurl(...)referencesThe rewrite mirrors the producer's semantics exactly: only values that
start with
../(or are literal..) are rewritten — against thesub-composition's URL via
new URL(value, compositionUrl). AbsoluteURLs, root-relative paths,
data:, hash refs, and plainassets/x.mp4are left untouched. Plain relative paths must not berewritten because the main document's
<base href>already coversthem; rewriting would double-prefix the URL.
The walk recurses into
<template>content because authoredcompositions typically wrap their rendered body in a
<template>andquerySelectorAlldoes not enter template content (it lives in adetached
DocumentFragment).Test plan
compositionLoader.test.ts): rewrites../-traversing src ontemplate-wrapped sub-comps; leaves plain relative paths untouched (no
double-prefix); leaves absolute / data / hash / root-relative URLs
untouched; rewrites CSS
url()in<style>blocks and inlinestyleattributes; rewrites for non-template (full-HTML-doc)sub-comps.
before the fix one
<video>with a../../assets/...src returnedMEDIA_ELEMENT_ERROR: Format error; after the fix all 7<video>elements load (
readyState=4, correctcurrentSrc). The 6 plainassets/...paths are unchanged (no double-prefix) and continueto resolve via
<base href>as before.bun run lint,bun run format:check,bun run typecheck,fallow auditall green.Reported by Miao Yang.
— Jerrai (https://claude.com/claude-code)