Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ describe("bundleToSingleHtml", () => {
expect(bundled).not.toContain("./bg.svg");
});

it("adds the SVG namespace before inlining an external image", async () => {
const svg = '<svg width="320" height="180"><circle cx="160" cy="90" r="56"/></svg>';
const dir = makeTempProject({
"index.html": `<!doctype html><html><body>
<div data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="1">
<img id="external-svg" src="assets/icon.svg">
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.main = {}</script>
</body></html>`,
"assets/icon.svg": svg,
});

const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const src = document.getElementById("external-svg")?.getAttribute("src") ?? "";
const encodedSvg = src.replace("data:image/svg+xml;base64,", "");

expect(src).toMatch(/^data:image\/svg\+xml;base64,/);
expect(Buffer.from(encodedSvg, "base64").toString("utf8")).toBe(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><circle cx="160" cy="90" r="56"/></svg>',
);
});

it("preserves external SVG fragment references used by <use>", async () => {
const spriteSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="patch-head" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" /></symbol>
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { inlineSubCompositions } from "./inlineSubCompositions";
import { queryByAttr } from "../utils/cssSelector";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
import { ensureSvgNamespace } from "./svgNamespace";

const DEFAULT_RUNTIME_SCRIPT_URL = "";

Expand Down Expand Up @@ -306,7 +307,9 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
if (!mimeType) return null;
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
const inlinedContent =
ext === ".svg" ? Buffer.from(ensureSvgNamespace(content.toString("utf8")), "utf8") : content;
const dataUrl = `data:${mimeType};base64,${inlinedContent.toString("base64")}`;
return appendSuffixToUrl(dataUrl, suffix);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ export {

// Asset-path primitives (shared across core, producer, CLI)
export { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl, isPathInside } from "./assetPaths";
export { ensureSvgNamespace } from "./svgNamespace";
8 changes: 8 additions & 0 deletions packages/core/src/compiler/svgNamespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const SVG_XMLNS = 'xmlns="http://www.w3.org/2000/svg"';

/** Ensure a standalone SVG document has the namespace browsers require in image contexts. */
export function ensureSvgNamespace(source: string): string {
const root = source.match(/<svg\b[^>]*>/i)?.[0];
if (!root || /\sxmlns\s*=/.test(root)) return source;
return source.replace(/<svg\b/i, `<svg ${SVG_XMLNS}`);
}
22 changes: 22 additions & 0 deletions packages/producer/src/services/fileServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,28 @@ describe("parseRangeHeader", () => {
});

describe("createFileServer", () => {
it("adds the SVG namespace before serving an external image", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-svg-xmlns-"));
try {
writeEmptyIndex(projectDir);
writeFileSync(
join(projectDir, "icon.svg"),
'<svg width="10" height="10"><circle cx="5" cy="5" r="4"/></svg>',
);

await withFileServer(projectDir, async (server) => {
const response = await fetch(`${server.url}/icon.svg`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/svg+xml");
expect(await response.text()).toBe(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4"/></svg>',
);
});
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});

it("serves ES modules with a JavaScript MIME type", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-mjs-"));
try {
Expand Down
17 changes: 16 additions & 1 deletion packages/producer/src/services/fileServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { join, extname, resolve, sep } from "node:path";
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import {
ensureSvgNamespace,
injectScriptsAtHeadStart,
injectScriptsIntoHtml,
} from "@hyperframes/core/compiler";
import { fpsToNumber, type Fps } from "@hyperframes/core";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js";
Expand Down Expand Up @@ -790,6 +794,17 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
return c.text(html, 200, { "Content-Type": contentType });
}

if (ext === ".svg") {
const svg = ensureSvgNamespace(await readFile(filePath, "utf8"));
return new Response(svg, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(Buffer.byteLength(svg)),
},
});
}

// Stream binary file content rather than buffering it with readFileSync.
// On video-heavy compositions Chrome requests several 32MB video files
// back-to-back through this server; each readFileSync(32MB) blocked the
Expand Down
Loading