Skip to content

fix(core): resolve sub-composition sibling asset paths in render and asset discovery - #2994

Merged
miguel-heygen merged 1 commit into
mainfrom
fix-subcomp-sibling-assets-render
Aug 4, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix-subcomp-sibling-assets-render

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Follow-up to #2983, which fixed the Studio preview half of #2956. Same root cause, the rest of the surface.

What

rewriteAssetPath takes an optional assetExists probe. 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's assets/logo.png convention) stays as authored. Callers that can see the filesystem supply the probe, so the module keeps no node:fs import.

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 buildSubCompositionHtml only. 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.html referencing sibling _shared.css + frame-01.png, and ../shared/theme.css). Before, the inlined root document emitted:

<link href="_shared.css" rel="stylesheet">
<link href="../shared/theme.css" rel="stylesheet">
url("frame-01.png")
src="frame-01.png"

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:

  • HEVC preview lint skips any video it cannot resolve, so a real HEVC sibling was never reported — and when a same-named file existed at the project root, it probed the wrong file.
  • Project lint asset scans, publish proxy baking, and media-treatment source resolution have the same resolution step.

How

  • packages/parsers/src/rewriteSubCompPaths.ts — the AssetExists probe, threaded through rewriteAssetPaths / 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 script src now go through the same resolver as the body pass.
  • Probe supplied by: the preview bundler, the producer compiler, the studio preview builder, the HEVC preview lint, the project lint's asset scans, publish proxy baking, media-treatment.

Behavior is unchanged for every caller that does not pass a probe.

Test plan

  • Unit tests added/updated, red first: 5 in rewriteSubCompPaths.test.ts (sibling, query/hash, root-relative left alone, ../ unaffected, no-probe default), 3 in inlineSubCompositions.test.ts (hoisted links, hoisted script srcs, markup + hoisted CSS + inline styles), 3 new in hevcPreviewLint.test.ts (sibling resolved, sibling preferred over a same-named root file, root-relative still resolved).
  • Manual testing performed — same generated project: the inlined root document now emits design/styleframes/_shared.css, design/shared/theme.css, and design/styleframes/frame-01.png; the bundler preview renders styled, and a real hyperframes render produces a correctly styled MP4 frame (was unstyled).
  • Suites green: parsers 908, core 1434, studio-server 402, lint 514, producer unit lane 556. The 2 pre-existing transcribe failures in the CLI package reproduce on main and are untouched here.
  • Documentation updated — n/a

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.

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 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.

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:

  1. Non-relative URL (absolute / data: / protocol) → unchanged.
  2. compDir === "." || !compDir (composition at project root) → unchanged.
  3. ../ or .. → route through existing join + resolve (parent-relative, unchanged).
  4. Plain relative WITHOUT probe → return unchanged (backward-compat: no caller breakage).
  5. Plain relative WITH probe: split path/suffix via splitPathSuffix, compute sibling = resolve("/", join(compDir, filePart)).slice(1), check assetExists(sibling). If true → return sibling + 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.png from blocks/hero.html) ✓
  • ../ still routes through the existing needsRewrite path 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

  1. existsSync per 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.

  2. Windows path separator handling. The shared implementation uses node:path's native join / dirname / resolve (not path/posix). On Windows, join produces \\-separated strings. existsSync accepts 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.

  3. splitPathSuffix matches the first ? OR #. If foo.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.

  4. "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

@miguel-heygen
miguel-heygen merged commit 4e7fcf7 into main Aug 4, 2026
58 checks passed
@miguel-heygen
miguel-heygen deleted the fix-subcomp-sibling-assets-render branch August 4, 2026 04:13
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.

2 participants