Repository navigation
fix(core): resolve sub-composition sibling asset paths in render and asset discovery - #2994
Conversation
Extends the studio-preview fix to the render path and the asset-discovery utilities, which share the same resolver and had the same defect. `rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the composition's own directory when that sibling exists on disk; project-root refs with no sibling (the registry's `assets/logo.png` convention) stay as authored. Callers that can see the filesystem supply the probe, so the module stays free of node:fs. Also fixes a second defect in the inliner: `<head>` <link> hrefs and external script srcs are hoisted into the root document but never went through the rewrite at all, so even the documented `../` form escaped the project and 404'd at render time. Wired into the preview bundler, the producer compiler, the studio preview builder, the HEVC preview lint, the project lint's asset scans, publish proxy baking, and media-treatment source resolution.
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: APPROVE at 48a04f1b87.
Two-defect fix, consolidation win, thorough tests. Clean follow-up to #2983 — same discriminator (assetExists probe) now shared by every asset-resolution site (preview + render + inliner + bundler + HEVC lint + project lint + publish-proxy-bake + media-treatment). No blockers.
Defect 1 — Sibling resolution now covers render + discovery (extended from #2983's thumbnail path)
rewriteAssetPath(compSrcPath, relativePath, assetExists?) in parsers/rewriteSubCompPaths.ts:79-98 adds the probe. Semantics:
- Non-relative URL (absolute / data: / protocol) → unchanged.
compDir === "." || !compDir(composition at project root) → unchanged.../or..→ route through existingjoin + resolve(parent-relative, unchanged).- Plain relative WITHOUT probe → return unchanged (backward-compat: no caller breakage).
- Plain relative WITH probe: split path/suffix via
splitPathSuffix, computesibling = resolve("/", join(compDir, filePart)).slice(1), checkassetExists(sibling). If true → returnsibling + suffix(query/hash preserved). Else → return unchanged.
The disk-check-as-discriminator moves from studio-server-local (#2983) into shared parsers. Every caller now uses the SAME resolver, so preview + render + discovery agree on which convention (sibling vs project-root) a given ref means — closing the studio-server-vs-producer divergence risk I raised in the #2983 review.
Test coverage at rewriteSubCompPaths.test.ts:57-101:
- Sibling resolves against comp dir ✓
- Query/hash preserved (
frame.png?v=2→design/styleframes/frame.png?v=2) ✓ - Project-root-relative left alone when no sibling (
assets/logo.pngfromblocks/hero.html) ✓ ../still routes through the existingneedsRewritepath without consulting the probe ✓- No-op without probe (backward compat) ✓
Defect 2 — Hoisted <link href> / external <script src> were bypassing the rewriter ENTIRELY
inlineSubCompositions.ts:302-320 — head-hoisted <script src> and <link href> extraction previously used (s.getAttribute("src") || "").trim() with NO rewrite. Even the documented ../ form was being pushed as-authored → escaped the project → 404 at render time. Fix: resolveSubAssetPath = raw => rewriteAssetPath(src, raw, assetExists) wraps the extraction so every hoisted attribute goes through the same resolver as the body-attribute pass.
The content-region <script> extraction at :335 got the same fix.
Test coverage at inlineSubCompositions.test.ts:576-618 pins all three cases (hoisted links, hoisted scripts, markup + hoisted CSS + inline styles) — and specifically asserts ../shared/theme.css becomes design/shared/theme.css (the previously-escaping case), not just the sibling case.
Consolidation
studio-server/subComposition.ts loses 60 lines: removes the local resolvePreviewAssetPath and rewriteCssUrls helpers, delegates to shared rewriteAssetPaths / rewriteInlineStyleAssetUrls / rewriteCssAssetUrls from @hyperframes/core. One resolver, uniform behavior across preview / render / discovery — this is the "same root cause, the rest of the surface" outcome from the PR body.
HEVC lint sibling-vs-same-named-root — the strongest test
hevcPreviewLint.test.ts:33-45:
const dir = makeProject(["clip.mp4", "design/styleframes/clip.mp4"]);
collectLocalVideoCandidates(dir, [{ html: `<video src="clip.mp4"></video>`, compSrcPath: "design/styleframes/frame-01.html" }]);
expect([...candidates.keys()]).toEqual([join(dir, "design/styleframes/clip.mp4")]);
Both a sibling AND a same-named project-root file exist. Pre-fix behavior would probe the wrong file (project root). Post-fix correctly prefers the sibling. This is the "wrong-file" case the PR body calls out, and the test pins the fix.
Every caller consistently wires the probe
Verified existsSync(join(projectDir, path)) (or equivalent resolve) at each site:
producer/htmlCompiler.ts:971—existsSync(resolve(projectDir, path))studio-server/subComposition.ts:38—existsSync(join(projectDir, path))core/htmlBundler.ts:902-905—resolveEntryPath(path) !== null && existsSync(resolved)(bundler-side path resolution)cli/publishProxyBake.ts:149-150—existsSync(resolve(absProjectDir, path))cli/media-treatment.ts:502—existsSync(join(projectDir, path))lint/hevcPreviewLint.ts:95—existsSync(join(projectDir, path))lint/project.ts:133/287/335— three sites, all consistent
Callers that CANNOT see the filesystem (browser-facing) simply omit the probe → backward-compat behavior.
Non-blockers
-
existsSyncper attribute per composition. For N sub-comps × M asset refs, this is N×M sync stat calls. For preview/thumbnail path this is fine (already tolerated in #2983). For render + discovery paths that touch large projects, marginal per-render cost — probably in the low-ms range and not a hot loop. Same non-blocker I raised on #2983. -
Windows path separator handling. The shared implementation uses
node:path's nativejoin/dirname/resolve(notpath/posix). On Windows,joinproduces\\-separated strings.existsSyncaccepts either separator on Windows so the probe still returns true; the concern is only if the rewritten path (sibling + suffix) with backslashes gets written into HTML — some browsers/renderers might not handle backslashes cleanly. Pacific's dev/prod matrix (Mac dev + Linux prod) avoids this in practice. Non-blocker; noting for anyone touching this on Windows. -
splitPathSuffixmatches the first?OR#. Iffoo.png#frag?v=2(fragment before query — non-standard but possible), suffix would be#frag?v=2. Correctly preserved as one blob. Not a concern; noting for completeness. -
"Not covered" section acknowledges
lintProject's missing-asset scan doesn't reach sub-composition markup. Separate pre-existing gap; probe is wired regardless so it's correct if/when that scan reaches those sources. Author's own callout — not this PR's regression.
Test-plan checkboxes match my read
Author's test plan: 5 parser tests, 3 inliner tests, 3 HEVC lint tests. Suites green (parsers 908, core 1434, studio-server 402, lint 514, producer unit 556). The 2 pre-existing transcribe failures in CLI are unrelated. CI: 41 passing, 11 running, 0 failing at time of review.
— Review by Via
Follow-up to #2983, which fixed the Studio preview half of #2956. Same root cause, the rest of the surface.
What
rewriteAssetPathtakes an optionalassetExistsprobe. A plain relative ref authored inside a sub-composition (_shared.css,clip.mp4) resolves against the composition's own directory when that sibling exists on disk; a project-root ref with no sibling (the registry'sassets/logo.pngconvention) stays as authored. Callers that can see the filesystem supply the probe, so the module keeps nonode:fsimport.It also fixes a second, separate defect in the inliner:
<head><link>hrefs and external<script src>are hoisted into the root document but never went through the rewrite at all — so even the documented../form escaped the project and 404'd at render time.Why
#2983 fixed
buildSubCompositionHtmlonly. The producer/bundler inliner shares the same resolver and had the same defect, so a project whose styleframes lived in a subdirectory rendered unstyled in the actual MP4, not just in thumbnails.Reproduced on a generated project (
design/styleframes/frame-01.htmlreferencing sibling_shared.css+frame-01.png, and../shared/theme.css). Before, the inlined root document emitted:All four resolve against the project root: three 404, and the
../one escapes the project entirely.The same misresolution silently degraded the asset-discovery utilities, which answer "where is this file on disk" for a ref in a sub-composition:
How
packages/parsers/src/rewriteSubCompPaths.ts— theAssetExistsprobe, threaded throughrewriteAssetPaths/rewriteInlineStyleAssetUrls/rewriteCssAssetUrls. Query strings and hashes survive the rewrite (frame.png?v=2).packages/core/src/compiler/inlineSubCompositions.ts— accepts the probe; hoisted<link href>and external scriptsrcnow go through the same resolver as the body pass.Behavior is unchanged for every caller that does not pass a probe.
Test plan
rewriteSubCompPaths.test.ts(sibling, query/hash, root-relative left alone,../unaffected, no-probe default), 3 ininlineSubCompositions.test.ts(hoisted links, hoisted script srcs, markup + hoisted CSS + inline styles), 3 new inhevcPreviewLint.test.ts(sibling resolved, sibling preferred over a same-named root file, root-relative still resolved).design/styleframes/_shared.css,design/shared/theme.css, anddesign/styleframes/frame-01.png; the bundler preview renders styled, and a realhyperframes renderproduces a correctly styled MP4 frame (was unstyled).transcribefailures in the CLI package reproduce on main and are untouched here.Not covered
lintProject's missing-asset scan never reaches sub-composition markup in the shape I tested (a genuinely missing<img>/<audio>in a sub-composition is not reported either). That is a separate gap, not this bug; the probe is wired into its resolution step regardless, so it is correct if and when that scan reaches those sources.