mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): localize remote assets before validate so it matches render (#2001)
validate served the composition over a loopback origin and let headless
Chrome fetch remote <img crossorigin>/@font-face assets cross-origin, while
the render pipeline downloads them to disk first. Buckets whose CORS
allowlist omits the loopback origin then failed the CORS-mode request with a
false net::ERR_FAILED that never occurs in the real render, pushing authors
(and agent pipelines) to delete crossorigin — which disables WebGL
color-grading/shaders for that asset.
Reuse producer's localizeRemote{Media,Image,FontFace}Sources in validate,
downloading into a temp dir served as an extra static-server asset root
(project dir untouched, cleaned up after). validate now matches render.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
89e36da13d
commit
906c8d04f8
@@ -9,6 +9,25 @@ import {
|
||||
} from "./validate.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
|
||||
// validateInBrowser lazy-loads the producer localize helpers via loadProducer;
|
||||
// mock it so these unit tests never resolve @hyperframes/producer's built dist.
|
||||
vi.mock("../utils/producer.js", () => ({
|
||||
loadProducer: vi.fn(async () => ({
|
||||
localizeRemoteMediaSources: vi.fn(async (html: string) => ({
|
||||
html,
|
||||
remoteMediaAssets: new Map(),
|
||||
})),
|
||||
localizeRemoteImageSources: vi.fn(async (html: string) => ({
|
||||
html,
|
||||
remoteMediaAssets: new Map(),
|
||||
})),
|
||||
localizeRemoteFontFaces: vi.fn(async (html: string) => ({
|
||||
html,
|
||||
remoteMediaAssets: new Map(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Regression for the validate audio-duration-probe timeout: a slow-loading
|
||||
// media element's duration was snapshotted once, at a fixed point in time,
|
||||
// and any element still mid-load was permanently misreported as unreadable.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
@@ -334,6 +335,37 @@ export function extractCompositionErrorsFromLint(
|
||||
.map((f) => ({ level: "error" as const, text: f.message }));
|
||||
}
|
||||
|
||||
// Match the render pipeline: localize remote <img>/<video>/<audio>/@font-face
|
||||
// into a temp dir (served as an extra asset root) so validate resolves them
|
||||
// same-origin and doesn't false-fail on cross-origin (crossorigin/CORS) fetches
|
||||
// the real render never makes. Best-effort: no-op on any failure.
|
||||
async function localizeRemoteAssets(
|
||||
html: string,
|
||||
): Promise<{ html: string; assetRoots: string[]; cleanup: () => void }> {
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
const { loadProducer } = await import("../utils/producer.js");
|
||||
const { localizeRemoteMediaSources, localizeRemoteImageSources, localizeRemoteFontFaces } =
|
||||
await loadProducer();
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-validate-assets-"));
|
||||
const assetDir = dir;
|
||||
const media = await localizeRemoteMediaSources(html, assetDir);
|
||||
const images = await localizeRemoteImageSources(media.html, assetDir);
|
||||
const fonts = await localizeRemoteFontFaces(images.html, assetDir);
|
||||
const count =
|
||||
media.remoteMediaAssets.size + images.remoteMediaAssets.size + fonts.remoteMediaAssets.size;
|
||||
return {
|
||||
html: fonts.html,
|
||||
assetRoots: count > 0 ? [assetDir] : [],
|
||||
cleanup: () => rmSync(assetDir, { recursive: true, force: true }),
|
||||
};
|
||||
} catch {
|
||||
// Best-effort: drop any partial temp dir before falling back to remote URLs.
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
return { html, assetRoots: [], cleanup: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateInBrowser(
|
||||
project: ProjectDir,
|
||||
opts: { timeout?: number; contrast?: boolean },
|
||||
@@ -359,7 +391,17 @@ async function validateInBrowser(
|
||||
// runtime tag) is no longer needed — there's no `src` attribute to match.
|
||||
const html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
const server = await serveStaticProjectHtml(projectDir, html);
|
||||
const localized = await localizeRemoteAssets(html);
|
||||
const server = await serveStaticProjectHtml(
|
||||
projectDir,
|
||||
localized.html,
|
||||
undefined,
|
||||
localized.assetRoots,
|
||||
).catch((err) => {
|
||||
// Server never started — the finally below won't run, so clean up here.
|
||||
localized.cleanup();
|
||||
throw err;
|
||||
});
|
||||
|
||||
const errors: ConsoleEntry[] = [...compositionErrors];
|
||||
const warnings: ConsoleEntry[] = [];
|
||||
@@ -445,6 +487,7 @@ async function validateInBrowser(
|
||||
await chromeBrowser.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
localized.cleanup();
|
||||
}
|
||||
|
||||
return { errors, warnings, contrast };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js";
|
||||
@@ -66,3 +66,46 @@ describe("serveStaticProjectHtml range support", () => {
|
||||
expect(res.headers.get("content-range")).toBe(`bytes */${body.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serveStaticProjectHtml asset roots", () => {
|
||||
const extraDirs: string[] = [];
|
||||
const mk = (): string => {
|
||||
const d = mkdtempSync(join(tmpdir(), "hf-static-root-"));
|
||||
extraDirs.push(d);
|
||||
return d;
|
||||
};
|
||||
afterEach(() => {
|
||||
for (const d of extraDirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("serves files from an extra asset root when the project dir lacks them", async () => {
|
||||
// extra root (e.g. localized-assets temp dir) resolves same-origin
|
||||
const projectDir = mk();
|
||||
const assetDir = mk();
|
||||
mkdirSync(join(assetDir, "_remote_media"), { recursive: true });
|
||||
writeFileSync(join(assetDir, "_remote_media", "img.jpg"), "PIXELS");
|
||||
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [assetDir]);
|
||||
|
||||
const res = await fetch(`${server.url}_remote_media/img.jpg`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("PIXELS");
|
||||
});
|
||||
|
||||
it("prefers the project dir over an asset root for the same path", async () => {
|
||||
const projectDir = mk();
|
||||
const assetDir = mk();
|
||||
writeFileSync(join(projectDir, "a.txt"), "PROJECT");
|
||||
writeFileSync(join(assetDir, "a.txt"), "ASSET");
|
||||
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [assetDir]);
|
||||
|
||||
const res = await fetch(`${server.url}a.txt`);
|
||||
expect(await res.text()).toBe("PROJECT");
|
||||
});
|
||||
|
||||
it("404s a path present in no root", async () => {
|
||||
const projectDir = mk();
|
||||
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [mk()]);
|
||||
const res = await fetch(`${server.url}nope.txt`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,11 @@ export async function serveStaticProjectHtml(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
bindErrorMessage = "Failed to bind local HTTP server",
|
||||
// Extra dirs to resolve non-index requests against, after projectDir (e.g. a
|
||||
// temp dir of localized remote assets).
|
||||
assetRoots: readonly string[] = [],
|
||||
): Promise<StaticProjectServer> {
|
||||
const roots = [projectDir, ...assetRoots];
|
||||
// fallow-ignore-next-line complexity
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
@@ -81,16 +85,15 @@ export async function serveStaticProjectHtml(
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
|
||||
const rel = relative(projectDir, filePath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
serveFileWithRange(filePath, req.headers.range, res);
|
||||
return;
|
||||
const requestPath = decodeURIComponent(url).replace(/^\//, "");
|
||||
for (const root of roots) {
|
||||
const filePath = resolve(root, requestPath);
|
||||
const rel = relative(root, filePath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) continue; // traversal guard; try next root
|
||||
if (existsSync(filePath)) {
|
||||
serveFileWithRange(filePath, req.headers.range, res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
|
||||
@@ -27,6 +27,15 @@ export {
|
||||
type RenderObservationStatus,
|
||||
} from "./services/render/observability.js";
|
||||
|
||||
// ── HTML asset localization ─────────────────────────────────────────────────
|
||||
// Rewrite remote <img>/<video>/<audio>/@font-face to same-origin local paths
|
||||
// before capture. Shared by render and `validate` so both resolve assets alike.
|
||||
export {
|
||||
localizeRemoteMediaSources,
|
||||
localizeRemoteImageSources,
|
||||
localizeRemoteFontFaces,
|
||||
} from "./services/htmlCompiler.js";
|
||||
|
||||
// ── Frame capture (lower-level) ─────────────────────────────────────────────
|
||||
export {
|
||||
createCaptureSession,
|
||||
|
||||
Reference in New Issue
Block a user