From 15ca6fd12954a4bfffdb5ef46624c4dbcbc8078c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 20:35:05 -0400 Subject: [PATCH] fix(skills): bundle modular capture helpers (#2456) --- skills-manifest.json | 4 +- .../scripts/animation-map.mjs | 53 ++++++++++++------- .../scripts/animation-map.test.mjs | 26 +++++++-- .../scripts/package-loader.mjs | 41 ++++++++++++-- .../scripts/contrast-report.mjs | 42 +++++++++------ .../scripts/package-loader.mjs | 41 ++++++++++++-- 6 files changed, 159 insertions(+), 48 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 982de740f..4f64e96a9 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "40a79102f708b4f5", + "hash": "70705a50c3adea48", "files": 102 }, "hyperframes-cli": { @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "a508ecd60aaa6dbf", + "hash": "ffe13c76b89ce3d1", "files": 78 }, "hyperframes-keyframes": { diff --git a/skills/hyperframes-animation/scripts/animation-map.mjs b/skills/hyperframes-animation/scripts/animation-map.mjs index d92cd2979..e59947759 100644 --- a/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.mjs @@ -17,14 +17,21 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve, join } from "node:path"; import { sampleTweenBboxes } from "./animation-map-sampling.mjs"; -import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs"; +import { + bundleCompositionForCapture, + hyperframesPackageSpec, + importPackagesOrBootstrap, +} from "./package-loader.mjs"; -const packages = await importPackagesOrBootstrap(["@hyperframes/producer", "@hyperframes/core"], { - npmPackages: [ - hyperframesPackageSpec("@hyperframes/producer"), - hyperframesPackageSpec("@hyperframes/core"), - ], -}); +const packages = await importPackagesOrBootstrap( + ["@hyperframes/producer", "@hyperframes/core", "@hyperframes/core/compiler"], + { + npmPackages: [ + hyperframesPackageSpec("@hyperframes/producer"), + hyperframesPackageSpec("@hyperframes/core"), + ], + }, +); const { createFileServer, createCaptureSession, @@ -53,16 +60,25 @@ await mkdir(OUT_DIR, { recursive: true }); // ─── Main ──────────────────────────────────────────────────────────────────── -const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); -const session = await createCaptureSession( - server.url, - OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, - null, -); -await initializeSession(session); - +// Raw modular hosts do not mount child compositions in the capture helper. +// Bundle first so duration/timeline discovery sees the same DOM as render/check. +const bundle = await bundleCompositionForCapture(packages["@hyperframes/core/compiler"], COMP_DIR); +let server; +let session; try { + server = await createFileServer({ + projectDir: COMP_DIR, + compiledDir: bundle.compiledDir, + port: 0, + }); + session = await createCaptureSession( + server.url, + OUT_DIR, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + null, + ); + await initializeSession(session); + const duration = await getCompositionDuration(session); const tweens = await enumerateTweens(session); const kept = tweens.filter((tw) => tw.end - tw.start >= MIN_DUR); @@ -126,8 +142,9 @@ try { printSummary(report); } finally { - await closeCaptureSession(session).catch(() => {}); - server.close(); + if (session) await closeCaptureSession(session).catch(() => {}); + server?.close(); + bundle.cleanup(); } // ─── Seek helper ──────────────────────────────────────────────────────────── diff --git a/skills/hyperframes-animation/scripts/animation-map.test.mjs b/skills/hyperframes-animation/scripts/animation-map.test.mjs index ae2cb3ad1..5f1790c56 100644 --- a/skills/hyperframes-animation/scripts/animation-map.test.mjs +++ b/skills/hyperframes-animation/scripts/animation-map.test.mjs @@ -14,7 +14,7 @@ const HELPERS = [ describe("HyperFrames skill helpers", () => { for (const helper of HELPERS) - it(`${helper.split("/").at(-1)} uses canonical rational frame-rate parsing`, () => { + it(`${helper.split("/").at(-1)} bundles modular input and uses rational fps`, () => { const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-")); const packageDir = join(root, "node_modules", "@hyperframes", "producer"); const corePackageDir = join(root, "node_modules", "@hyperframes", "core"); @@ -31,7 +31,15 @@ describe("HyperFrames skill helpers", () => { writeFileSync( join(packageDir, "index.mjs"), [ - 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + 'import { readFileSync } from "node:fs";', + 'import { join } from "node:path";', + "export async function createFileServer(options) {", + ' const bundled = readFileSync(join(options.compiledDir, "index.html"), "utf8");', + ' if (bundled !== "
bundled modular composition
") {', + " throw new Error(`UNEXPECTED_BUNDLE=${bundled}`);", + " }", + ' return { url: "http://test", close() {} };', + "}", "export async function createCaptureSession(_url, _out, options) {", " throw new Error(`CAPTURE_OPTIONS=${JSON.stringify(options)}`);", "}", @@ -42,7 +50,11 @@ describe("HyperFrames skill helpers", () => { ); writeFileSync( join(corePackageDir, "package.json"), - JSON.stringify({ name: "@hyperframes/core", type: "module", exports: "./index.mjs" }), + JSON.stringify({ + name: "@hyperframes/core", + type: "module", + exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" }, + }), ); writeFileSync( join(corePackageDir, "index.mjs"), @@ -54,6 +66,14 @@ describe("HyperFrames skill helpers", () => { "}", ].join("\n"), ); + writeFileSync( + join(corePackageDir, "compiler.mjs"), + [ + "export async function bundleToSingleHtml() {", + ' return "
bundled modular composition
";', + "}", + ].join("\n"), + ); writeFileSync( join(sharpPackageDir, "package.json"), JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }), diff --git a/skills/hyperframes-animation/scripts/package-loader.mjs b/skills/hyperframes-animation/scripts/package-loader.mjs index f3beb2de1..b0ec8840c 100644 --- a/skills/hyperframes-animation/scripts/package-loader.mjs +++ b/skills/hyperframes-animation/scripts/package-loader.mjs @@ -9,7 +9,7 @@ // The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error // text); they are never handed to a shell or executed. import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join, parse, resolve } from "node:path"; @@ -56,6 +56,23 @@ export async function importPackagesOrBootstrap(packageNames, options = {}) { return modules; } +export async function bundleCompositionForCapture(compiler, projectDir) { + const compiledDir = mkdtempSync(join(tmpdir(), "hyperframes-skill-bundle-")); + try { + const html = await compiler.bundleToSingleHtml(projectDir); + writeFileSync(join(compiledDir, "index.html"), html); + return { + compiledDir, + cleanup() { + rmSync(compiledDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + rmSync(compiledDir, { recursive: true, force: true }); + throw error; + } +} + export function hyperframesPackageSpec(packageName) { const override = process.env[VERSION_OVERRIDE_ENV]?.trim(); if (override) return `${packageName}@${override}`; @@ -79,6 +96,7 @@ export function hyperframesPackageSpec(packageName) { function resolvePackageEntry(packageName) { const bases = [process.cwd(), HERE, ...envNodeModulesDirs(), ...nodeModulesDirsFromPath()]; + const { rootName, subpath } = splitPackageSpecifier(packageName); const seen = new Set(); for (const base of bases) { @@ -91,8 +109,8 @@ function resolvePackageEntry(packageName) { packageName, ); } catch { - const packageDir = findPackageDir(normalized, packageName); - const packageEntry = packageDir ? readPackageEntry(packageDir) : null; + const packageDir = findPackageDir(normalized, rootName); + const packageEntry = packageDir ? readPackageEntry(packageDir, subpath) : null; if (packageEntry) return packageEntry; } } @@ -100,6 +118,15 @@ function resolvePackageEntry(packageName) { return null; } +function splitPackageSpecifier(packageName) { + const segments = packageName.split("/"); + const rootLength = packageName.startsWith("@") ? 2 : 1; + return { + rootName: segments.slice(0, rootLength).join("/"), + subpath: segments.slice(rootLength).join("/"), + }; +} + function readBundledHyperframesVersion() { for (const ancestor of ancestors(HERE)) { const directVersion = readPackageVersion(join(ancestor, "package.json")); @@ -152,10 +179,14 @@ function findPackageDir(base, packageName) { return null; } -function readPackageEntry(packageDir) { +function readPackageEntry(packageDir, subpath = "") { try { const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - const entry = exportEntry(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js"; + const requestedExport = subpath ? manifest.exports?.[`./${subpath}`] : manifest.exports; + const entry = + exportEntry(requestedExport) ?? + (!subpath ? (manifest.module ?? manifest.main ?? "index.js") : null); + if (!entry) return null; const entryPath = join(packageDir, entry); return existsSync(entryPath) ? entryPath : null; } catch { diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index a1b5b6a8a..35bd20386 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -45,12 +45,16 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs"; +import { + bundleCompositionForCapture, + hyperframesPackageSpec, + importPackagesOrBootstrap, +} from "./package-loader.mjs"; -// Use the producer's file server — it auto-injects the HyperFrames runtime -// and render-seek bridge, so raw authoring HTML works without a build step. +// Bundle first so mounted sub-compositions are inlined before the producer's +// file server injects the HyperFrames runtime and render-seek bridge. const packages = await importPackagesOrBootstrap( - ["@hyperframes/producer", "@hyperframes/core", "sharp"], + ["@hyperframes/producer", "@hyperframes/core", "@hyperframes/core/compiler", "sharp"], { npmPackages: [ hyperframesPackageSpec("@hyperframes/producer"), @@ -88,16 +92,23 @@ const COMP_DIR = resolve(args.composition); await mkdir(OUT_DIR, { recursive: true }); -const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); -const session = await createCaptureSession( - server.url, - OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, - null, -); -await initializeSession(session); - +const bundle = await bundleCompositionForCapture(packages["@hyperframes/core/compiler"], COMP_DIR); +let server; +let session; try { + server = await createFileServer({ + projectDir: COMP_DIR, + compiledDir: bundle.compiledDir, + port: 0, + }); + session = await createCaptureSession( + server.url, + OUT_DIR, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + null, + ); + await initializeSession(session); + const duration = await getCompositionDuration(session); const times = Array.from( { length: SAMPLES }, @@ -145,8 +156,9 @@ try { printSummary(report); process.exitCode = report.summary.failAA > 0 ? 1 : 0; } finally { - await closeCaptureSession(session).catch(() => {}); - server.close(); + if (session) await closeCaptureSession(session).catch(() => {}); + server?.close(); + bundle.cleanup(); } // ─── DOM probe + text-hide (runs in the page) ──────────────────────────────── diff --git a/skills/hyperframes-creative/scripts/package-loader.mjs b/skills/hyperframes-creative/scripts/package-loader.mjs index f3beb2de1..b0ec8840c 100644 --- a/skills/hyperframes-creative/scripts/package-loader.mjs +++ b/skills/hyperframes-creative/scripts/package-loader.mjs @@ -9,7 +9,7 @@ // The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error // text); they are never handed to a shell or executed. import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join, parse, resolve } from "node:path"; @@ -56,6 +56,23 @@ export async function importPackagesOrBootstrap(packageNames, options = {}) { return modules; } +export async function bundleCompositionForCapture(compiler, projectDir) { + const compiledDir = mkdtempSync(join(tmpdir(), "hyperframes-skill-bundle-")); + try { + const html = await compiler.bundleToSingleHtml(projectDir); + writeFileSync(join(compiledDir, "index.html"), html); + return { + compiledDir, + cleanup() { + rmSync(compiledDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + rmSync(compiledDir, { recursive: true, force: true }); + throw error; + } +} + export function hyperframesPackageSpec(packageName) { const override = process.env[VERSION_OVERRIDE_ENV]?.trim(); if (override) return `${packageName}@${override}`; @@ -79,6 +96,7 @@ export function hyperframesPackageSpec(packageName) { function resolvePackageEntry(packageName) { const bases = [process.cwd(), HERE, ...envNodeModulesDirs(), ...nodeModulesDirsFromPath()]; + const { rootName, subpath } = splitPackageSpecifier(packageName); const seen = new Set(); for (const base of bases) { @@ -91,8 +109,8 @@ function resolvePackageEntry(packageName) { packageName, ); } catch { - const packageDir = findPackageDir(normalized, packageName); - const packageEntry = packageDir ? readPackageEntry(packageDir) : null; + const packageDir = findPackageDir(normalized, rootName); + const packageEntry = packageDir ? readPackageEntry(packageDir, subpath) : null; if (packageEntry) return packageEntry; } } @@ -100,6 +118,15 @@ function resolvePackageEntry(packageName) { return null; } +function splitPackageSpecifier(packageName) { + const segments = packageName.split("/"); + const rootLength = packageName.startsWith("@") ? 2 : 1; + return { + rootName: segments.slice(0, rootLength).join("/"), + subpath: segments.slice(rootLength).join("/"), + }; +} + function readBundledHyperframesVersion() { for (const ancestor of ancestors(HERE)) { const directVersion = readPackageVersion(join(ancestor, "package.json")); @@ -152,10 +179,14 @@ function findPackageDir(base, packageName) { return null; } -function readPackageEntry(packageDir) { +function readPackageEntry(packageDir, subpath = "") { try { const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - const entry = exportEntry(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js"; + const requestedExport = subpath ? manifest.exports?.[`./${subpath}`] : manifest.exports; + const entry = + exportEntry(requestedExport) ?? + (!subpath ? (manifest.module ?? manifest.main ?? "index.js") : null); + if (!entry) return null; const entryPath = join(packageDir, entry); return existsSync(entryPath) ? entryPath : null; } catch {