diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index dbe96e95e..c88e40c30 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -3,6 +3,7 @@ import { join, resolve, isAbsolute, sep } from "path";
import * as cheerio from "cheerio";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
+import { rewriteAssetPaths } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
@@ -432,6 +433,20 @@ export async function bundleToSingleHtml(
$content(s).remove();
});
+ // Rewrite relative asset paths before inlining so ../foo.svg from
+ // compositions/ resolves correctly when the content moves to root.
+ const $assetEls = $innerRoot.length
+ ? $innerRoot.find("[src], [href]")
+ : $content("[src], [href]");
+ rewriteAssetPaths(
+ $assetEls.toArray(),
+ src,
+ (el, attr) => $content(el).attr(attr),
+ (el, attr, val) => {
+ $content(el).attr(attr, val);
+ },
+ );
+
if ($innerRoot.length) {
const innerCompId = $innerRoot.attr("data-composition-id");
const innerW = $innerRoot.attr("data-width");
diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts
new file mode 100644
index 000000000..dec8b814c
--- /dev/null
+++ b/packages/core/src/compiler/rewriteSubCompPaths.ts
@@ -0,0 +1,92 @@
+/**
+ * Rewrite relative asset paths in sub-composition content so they resolve
+ * correctly after the content is inlined into the root document.
+ *
+ * A sub-composition at "compositions/scene.html" referencing "../icon.svg"
+ * means the project root — but after inlining into root index.html, the
+ * "../" escapes the project directory and causes 404s. This function
+ * resolves each relative path against the sub-composition's directory,
+ * then normalizes it to be relative to the project root.
+ *
+ * Used by both the core bundler (preview) and the producer compiler (render)
+ * to ensure consistent behavior.
+ */
+
+import { join, resolve, dirname } from "path";
+
+/** Attributes that may contain relative asset paths. */
+const PATH_ATTRS = ["src", "href"] as const;
+
+/** Protocols and prefixes that should never be rewritten. */
+function isAbsoluteOrSpecial(val: string): boolean {
+ return (
+ !val ||
+ val.startsWith("http://") ||
+ val.startsWith("https://") ||
+ val.startsWith("//") ||
+ val.startsWith("data:") ||
+ val.startsWith("#")
+ );
+}
+
+/**
+ * Returns true only for paths that traverse up with `../`.
+ * Plain relative paths like `assets/foo.svg` are already correct from the
+ * root perspective — the browser resolves them against the served root, which
+ * is the project root, so they don't need rewriting.
+ */
+function needsRewrite(val: string): boolean {
+ return val.startsWith("../") || val === "..";
+}
+
+/**
+ * Rewrite a single relative path from a sub-composition's context to the
+ * project root context.
+ *
+ * @param compSrcPath - The `data-composition-src` value (e.g. "compositions/scene.html")
+ * @param relativePath - The asset path to rewrite (e.g. "../icon.svg")
+ * @returns The rewritten path relative to project root (e.g. "icon.svg"), or
+ * the original path if no rewriting is needed.
+ */
+export function rewriteAssetPath(compSrcPath: string, relativePath: string): string {
+ if (isAbsoluteOrSpecial(relativePath)) return relativePath;
+ if (!needsRewrite(relativePath)) return relativePath;
+ const compDir = dirname(compSrcPath);
+ if (!compDir || compDir === ".") return relativePath;
+ const resolved = join(compDir, relativePath);
+ const normalized = resolve("/", resolved).slice(1);
+ return normalized;
+}
+
+/**
+ * Rewrite all relative `src` and `href` attributes on elements within a
+ * DOM tree, adjusting paths from the sub-composition's directory context
+ * to the project root.
+ *
+ * @param elements - Iterable of DOM elements to scan (e.g. from querySelectorAll)
+ * @param compSrcPath - The `data-composition-src` value
+ * @param getAttr - Function to read an attribute from an element
+ * @param setAttr - Function to set an attribute on an element
+ */
+export function rewriteAssetPaths(
+ elements: Iterable,
+ compSrcPath: string,
+ getAttr: (el: T, attr: string) => string | null | undefined,
+ setAttr: (el: T, attr: string, value: string) => void,
+): void {
+ const compDir = dirname(compSrcPath);
+ if (!compDir || compDir === ".") return;
+
+ for (const el of elements) {
+ for (const attr of PATH_ATTRS) {
+ const val = (getAttr(el, attr) || "").trim();
+ if (isAbsoluteOrSpecial(val)) continue;
+ if (!needsRewrite(val)) continue;
+ const rewritten = join(compDir, val);
+ const normalized = resolve("/", rewritten).slice(1);
+ if (normalized !== val) {
+ setAttr(el, attr, normalized);
+ }
+ }
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8c2524624..5e0c796be 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -120,6 +120,7 @@ export type {
HyperframeLinterOptions,
} from "./lint/types";
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
+export { rewriteAssetPaths, rewriteAssetPath } from "./compiler/rewriteSubCompPaths";
// Inline scripts
export {
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index 3c2ff6afb..cf0841330 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -19,6 +19,7 @@ import {
clampDurations,
type ResolvedDuration,
type UnresolvedElement,
+ rewriteAssetPaths,
} from "@hyperframes/core";
import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
import {
@@ -578,6 +579,16 @@ function inlineSubCompositions(
scriptEl.remove();
}
+ // Rewrite relative asset paths before inlining so ../foo.svg from
+ // compositions/ resolves correctly when the content moves to root.
+ const rewriteTarget = innerRoot || contentDoc;
+ rewriteAssetPaths(
+ rewriteTarget.querySelectorAll("[src], [href]"),
+ srcPath,
+ (el, attr) => (el.getAttribute(attr) || "").trim(),
+ (el, attr, val) => el.setAttribute(attr, val),
+ );
+
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");