fix(skills): keep script imports inside installed skill directories (#4356)

* fix(skills): keep script imports inside installed skill directories

* refactor(skills): simplify standalone module checks

* fix(skills): carry the brand-logo lookup into the media-use copy of media-fetch
This commit is contained in:
Miguel Ángel
2026-09-23 19:09:09 -04:00
committed by GitHub
parent b021cb77be
commit 29fc95395d
25 changed files with 1097 additions and 64 deletions
+14 -4
View File
@@ -120,6 +120,8 @@ jobs:
- "scripts/generate-captured-font-copies.mjs"
- "packages/cli/src/media-use/**"
- "scripts/check-media-use-copy-parity.test.mjs"
- "scripts/check-skill-import-boundaries.test.mjs"
- "scripts/generate-skill-module-copies.mjs"
- "skills/**"
- ".claude/skills/**"
- ".agents/skills/**"
@@ -447,24 +449,29 @@ jobs:
test:
name: Test
needs: [changes, producer-source-tests, test-studio, test-runtime, test-support]
needs: [changes, producer-source-tests, test-studio, test-runtime, test-support, test-skills]
# Keep the existing required `Test` context authoritative for producer
# failures too. It also fails closed if any package shard fails or is cancelled.
if: always() && needs.changes.outputs.code == 'true'
if: always() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.skills == 'true')
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Require producer source tests
if: needs.producer-source-tests.result != 'success'
if: needs.changes.outputs.code == 'true' && needs.producer-source-tests.result != 'success'
run: |
echo "::error::Producer unit/integration tests did not succeed."
exit 1
- name: Require package test shards
if: needs.test-studio.result != 'success' || needs.test-runtime.result != 'success' || needs.test-support.result != 'success'
if: needs.changes.outputs.code == 'true' && (needs.test-studio.result != 'success' || needs.test-runtime.result != 'success' || needs.test-support.result != 'success')
run: |
echo "::error::A package test shard did not succeed."
echo "studio=${{ needs.test-studio.result }} runtime=${{ needs.test-runtime.result }} support=${{ needs.test-support.result }}"
exit 1
- name: Require skills tests
if: needs.changes.outputs.skills == 'true' && needs.test-skills.result != 'success'
run: |
echo "::error::Skills tests did not succeed."
exit 1
producer-source-tests:
name: "Producer: ${{ matrix.lane }} tests"
@@ -514,6 +521,9 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/install-ffmpeg-linux
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Verify standalone skill imports
run: bun test scripts/check-skill-import-boundaries.test.mjs
- name: Discover and run skills tests
# We expand the test list via bash so the job fails loudly when the
# matcher comes back empty, rather than silently no-op'ing (which
+5 -4
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { describe, it } from "node:test";
import { generateSkillModuleCopies } from "./generate-skill-module-copies.mjs";
const skillLibDir = resolve("skills/media-use/scripts/lib");
const cliLibDir = resolve("packages/cli/src/media-use/lib");
@@ -24,10 +25,6 @@ export const MEDIA_USE_COPY_NAMES = [
];
export const INTENTIONAL_MEDIA_USE_DIVERGENCES = new Map([
[
"media-fetch.mjs",
"the standalone skill uses a shim because it cannot import the CLI package tree",
],
[
"npx-sync.mjs",
"the standalone skill stays self-contained while the CLI copy uses the shared audio helper",
@@ -76,3 +73,7 @@ describe("media-use source parity", () => {
}
});
});
it("keeps generated standalone modules equal to their owners", () => {
assert.deepEqual(generateSkillModuleCopies({ check: true }), []);
});
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import { it } from "node:test";
const parser = new Bun.Transpiler({ loader: "tsx" });
function moduleSpecifiers(source) {
return parser.scanImports(source.replace(/^#![^\n]*/, "")).map((entry) => entry.path);
}
function scriptFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return scriptFiles(path);
return /\.(?:mjs|cjs|js|ts)$/.test(entry.name) && !/\.(?:test|spec)\./.test(entry.name)
? [path]
: [];
});
}
function escapingImports(root, file) {
return moduleSpecifiers(readFileSync(file, "utf8"))
.filter((specifier) => {
if (!specifier.startsWith(".")) return false;
const target = resolve(dirname(file), specifier);
const local = relative(root, target);
return local === ".." || local.startsWith(".." + sep);
})
.map((specifier) => relative(resolve("."), file) + ": " + specifier);
}
it("shipped scripts keep literal relative imports inside their own skill", () => {
const { skills } = JSON.parse(readFileSync("skills-manifest.json", "utf8"));
const issues = [];
let checked = 0;
for (const name of Object.keys(skills)) {
const root = resolve("skills", name);
const files = scriptFiles(root);
checked += files.length;
issues.push(...files.flatMap((file) => escapingImports(root, file)));
}
assert.ok(checked > 0, "must inspect shipped scripts");
assert.deepEqual(issues, []);
});
it("inspects static imports, re-exports, dynamic imports and require without reading comments", () => {
assert.deepEqual(
moduleSpecifiers(`
// import "ignored";
import "./side.mjs";
import { x } from "./named.mjs";
export { y } from "./export.mjs";
export * from "./star.mjs";
import("./dynamic.mjs");
require("./common.cjs");
`),
["./side.mjs", "./named.mjs", "./export.mjs", "./star.mjs", "./dynamic.mjs", "./common.cjs"],
);
});
+43
View File
@@ -0,0 +1,43 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workflows = ["faceless-explainer", "pr-to-video", "product-launch-video"];
export const skillModuleCopies = [
[
"packages/cli/src/media-use/lib/media-fetch.mjs",
["skills/media-use/scripts/lib/media-fetch.mjs"],
],
[
"skills/media-use/audio/scripts/lib/bgm-volume.mjs",
workflows.map((skill) => `skills/${skill}/scripts/lib/bgm-volume.mjs`),
],
[
"skills/hyperframes/scripts/lib/frame-packets-core.mjs",
[...workflows, "general-video"].map(
(skill) => `skills/${skill}/scripts/lib/frame-packets-core.mjs`,
),
],
];
function synchronizeCopy(content, target, check) {
const path = resolve(root, target);
if (check) return readFileSync(path, "utf8") !== content;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
return false;
}
export function generateSkillModuleCopies({ check = false } = {}) {
return skillModuleCopies.flatMap(([source, targets]) => {
const content = readFileSync(resolve(root, source), "utf8");
return targets.filter((target) => synchronizeCopy(content, target, check));
});
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const drifted = generateSkillModuleCopies({ check: process.argv.includes("--check") });
if (drifted.length) throw new Error("Regenerate skill module copies: " + drifted.join(", "));
console.log("Skill module copies are in sync");
}
+6 -2
View File
@@ -2,7 +2,10 @@
"guards": {
"scripts/check-media-use-copy-parity.test.mjs": [
"skills/media-use/scripts/lib/**",
"packages/cli/src/media-use/lib/**"
"packages/cli/src/media-use/lib/**",
"scripts/generate-skill-module-copies.mjs",
"skills/hyperframes/scripts/lib/frame-packets-core.mjs",
"skills/media-use/audio/scripts/lib/bgm-volume.mjs"
],
"scripts/check-test-reachability.test.mjs": [
".github/workflows/ci.yml",
@@ -32,7 +35,8 @@
"skills/pr-to-video/scripts/build-frame.mjs",
"skills/pr-to-video/scripts/captions.mjs",
"skills/pr-to-video/scripts/lib/captured-fonts.mjs"
]
],
"scripts/check-skill-import-boundaries.test.mjs": ["skills/**"]
},
"runners": [
{
+11 -11
View File
@@ -6,19 +6,19 @@
"files": 142
},
"faceless-explainer": {
"hash": "3dfdfe562df85799",
"files": 25
"hash": "d3e32414b9e86678",
"files": 27
},
"figma": {
"hash": "642cce1b5e211182",
"files": 2
},
"general-video": {
"hash": "079d3479295e27b4",
"files": 4
"hash": "36b4d47cf01c451d",
"files": 5
},
"hyperframes": {
"hash": "ca51ed2be534305c",
"hash": "e4788caea448bc93",
"files": 26
},
"hyperframes-animation": {
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "d64b6d419acfee84",
"files": 90
"hash": "d15857ed6f884a1f",
"files": 91
},
"motion-graphics": {
"hash": "ab9d2e9c205ce1b1",
@@ -66,12 +66,12 @@
"files": 169
},
"pr-to-video": {
"hash": "56f66423f93dafc5",
"files": 31
"hash": "148de3a4692bc44d",
"files": 33
},
"product-launch-video": {
"hash": "8a1406f7c1afa598",
"files": 32
"hash": "104014221c41ae13",
"files": 34
},
"remotion-to-hyperframes": {
"hash": "1da9494cf3a6b1f3",
@@ -57,7 +57,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.mjs";
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
import { bgmDefaultVolume } from "./lib/bgm-volume.mjs";
// ---------- argv ----------
const argv = process.argv.slice(2);
@@ -7,7 +7,7 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as core from "../../hyperframes/scripts/lib/frame-packets-core.mjs";
import * as core from "./lib/frame-packets-core.mjs";
const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CONFIG = {
@@ -0,0 +1,4 @@
// Music sits under narration; a silent film brings music forward.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
@@ -0,0 +1,194 @@
// Frame packets inline the storyboard frame, blueprint and cited recipes.
// Workflow wrappers supply resource paths and workflow-specific sections.
// The role combines the shared worker contract with the workflow delta.
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export function field(block, name) {
const match = block.match(new RegExp(`^-\\s+${name}:\\s*(.+)$`, "im"));
return match?.[1]?.trim() ?? null;
}
export function splitFrames(storyboard) {
const matches = [...storyboard.matchAll(/^## Frame\s+([^\n]+)$/gm)];
return matches.map((match, index) => {
const start = match.index;
const end = matches[index + 1]?.index ?? storyboard.length;
return {
heading: match[1].trim(),
block: storyboard.slice(start, end).trim(),
};
});
}
export function frameId(frame) {
const src = field(frame.block, "src");
if (!src) throw new Error(`${frame.heading}: missing src`);
return basename(src).replace(/\.html?$/i, "");
}
export function selectedFile(path, heading) {
if (!path || !existsSync(path)) return "";
return `\n## ${heading}\n\n${readFileSync(path, "utf8").trim()}\n`;
}
function escapeRegExp(id) {
return id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function knownRuleIds(animationDir) {
const rulesDir = join(animationDir, "rules");
if (!existsSync(rulesDir)) {
console.warn(
`frame-packets: no rules dir at ${rulesDir} — packets will inline no motion recipes`,
);
return [];
}
return readdirSync(rulesDir)
.filter((name) => name.endsWith(".md"))
.map((name) => name.replace(/\.md$/, ""));
}
export function citedRules(block, ruleIds) {
const explicit = (field(block, "rules") ?? "")
.split(/[,\s]+/)
.map((rule) => rule.trim())
.filter(Boolean);
const mentioned = ruleIds.filter((id) =>
new RegExp(`(?<![\\w-])${escapeRegExp(id)}(?![\\w-])`, "i").test(block),
);
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
}
// visual-design.md tells the author to write the blueprint as `<id> (Reproduce)`
// or `<id> (Adapt)` — the qualifier is direction for the frame worker, not part of
// the filename. Parse the field into the id it names (or null for `compose`), so
// no caller ever resolves a raw field value against the blueprints directory.
export function blueprintId(block) {
const raw = field(block, "blueprint");
if (!raw) return null;
const id = raw.replace(/\s*\([^)]*\)\s*$/, "").trim();
return id && id.toLowerCase() !== "compose" ? id : null;
}
export function resourceSections(block, { animationDir, ruleIds, frameId }) {
let sections = "";
const blueprint = blueprintId(block);
if (blueprint) {
const blueprintsDir = join(animationDir, "blueprints");
const path = join(blueprintsDir, `${blueprint}.md`);
// An installed library must contain the named blueprint.
// An absent on-demand library warns, like missing motion rules.
if (!existsSync(blueprintsDir)) {
console.warn(
`frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
);
} else if (!existsSync(path)) {
throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`);
} else {
sections += selectedFile(path, `Selected blueprint: ${blueprint}`);
}
}
for (const rule of citedRules(block, ruleIds)) {
sections += selectedFile(
join(animationDir, "rules", `${rule}.md`),
`Selected motion rule: ${rule}`,
);
}
return sections;
}
export function buildRolePayload({ corePath, deltaPath, outDir }) {
const core = readFileSync(corePath, "utf8").trim();
const delta = readFileSync(deltaPath, "utf8").trim();
const role = `${core}\n\n---\n\n${delta}\n`;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, "_role.md");
writeFileSync(path, role);
return { path, bytes: Buffer.byteLength(role) };
}
export function buildFramePackets({
projectDir,
storyboardPath = join(projectDir, "STORYBOARD.md"),
outDir = join(projectDir, ".hyperframes", "frame-packets"),
maxPacketBytes = 48_000,
animationDir,
corePath,
deltaPath,
// Per-workflow hooks (all optional):
// designTruthLine(projectDir) -> the packet's design-truth input line
// validateFrame(frame, id) -> throw to reject a frame before packing
// extraSections(block) -> extra packet sections appended after the rule recipes
designTruthLine = (dir) => `- Design tokens: ${join(resolve(dir), "frame.md")}`,
validateFrame,
extraSections,
}) {
const storyboard = readFileSync(storyboardPath, "utf8");
const frames = splitFrames(storyboard);
if (frames.length === 0) throw new Error("STORYBOARD.md has no frame blocks");
const ruleIds = knownRuleIds(animationDir);
const packets = frames.map((frame) => {
const id = frameId(frame);
if (validateFrame) validateFrame(frame, id);
const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds, frameId: id })}${extraSections ? extraSections(frame.block) : ""}`;
const bytes = Buffer.byteLength(packet);
if (bytes > maxPacketBytes) {
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
}
return { frameId: id, path: join(outDir, `${id}.md`), bytes, packet };
});
mkdirSync(outDir, { recursive: true });
for (const { path, packet } of packets) writeFileSync(path, packet);
buildRolePayload({ corePath, deltaPath, outDir });
return packets.map(({ packet: _packet, ...result }) => result);
}
export function flag(argv, name, fallback) {
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
}
// realpath both sides: on macOS /tmp → /private/tmp, and node resolves the main
// module's symlinks in import.meta.url while argv[1] keeps the invoked spelling —
// a raw compare silently skips main() when invoked through any symlinked path.
export function isMainModule(importMetaUrl) {
if (!process.argv[1]) return false;
try {
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
} catch {
return false;
}
}
export function runCli({ buildFramePackets: build, buildRolePayload: buildRole }) {
const argv = process.argv.slice(2);
const projectDir = resolve(flag(argv, "project", "."));
const outDir = resolve(flag(argv, "out-dir", join(projectDir, ".hyperframes", "frame-packets")));
try {
const packets = build({
projectDir,
storyboardPath: resolve(flag(argv, "storyboard", join(projectDir, "STORYBOARD.md"))),
outDir,
});
const role = buildRole({ outDir });
console.log(`✓ frame packets: ${packets.length} bounded packet(s)`);
for (const packet of packets)
console.log(` ${packet.frameId}: ${packet.bytes} bytes → ${packet.path}`);
console.log(` worker role: ${role.bytes} bytes → ${role.path}`);
} catch (error) {
console.error(`✗ frame packets: ${error.message}`);
process.exit(1);
}
}
@@ -8,7 +8,7 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as core from "../../hyperframes/scripts/lib/frame-packets-core.mjs";
import * as core from "./lib/frame-packets-core.mjs";
const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -0,0 +1,194 @@
// Frame packets inline the storyboard frame, blueprint and cited recipes.
// Workflow wrappers supply resource paths and workflow-specific sections.
// The role combines the shared worker contract with the workflow delta.
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export function field(block, name) {
const match = block.match(new RegExp(`^-\\s+${name}:\\s*(.+)$`, "im"));
return match?.[1]?.trim() ?? null;
}
export function splitFrames(storyboard) {
const matches = [...storyboard.matchAll(/^## Frame\s+([^\n]+)$/gm)];
return matches.map((match, index) => {
const start = match.index;
const end = matches[index + 1]?.index ?? storyboard.length;
return {
heading: match[1].trim(),
block: storyboard.slice(start, end).trim(),
};
});
}
export function frameId(frame) {
const src = field(frame.block, "src");
if (!src) throw new Error(`${frame.heading}: missing src`);
return basename(src).replace(/\.html?$/i, "");
}
export function selectedFile(path, heading) {
if (!path || !existsSync(path)) return "";
return `\n## ${heading}\n\n${readFileSync(path, "utf8").trim()}\n`;
}
function escapeRegExp(id) {
return id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function knownRuleIds(animationDir) {
const rulesDir = join(animationDir, "rules");
if (!existsSync(rulesDir)) {
console.warn(
`frame-packets: no rules dir at ${rulesDir} — packets will inline no motion recipes`,
);
return [];
}
return readdirSync(rulesDir)
.filter((name) => name.endsWith(".md"))
.map((name) => name.replace(/\.md$/, ""));
}
export function citedRules(block, ruleIds) {
const explicit = (field(block, "rules") ?? "")
.split(/[,\s]+/)
.map((rule) => rule.trim())
.filter(Boolean);
const mentioned = ruleIds.filter((id) =>
new RegExp(`(?<![\\w-])${escapeRegExp(id)}(?![\\w-])`, "i").test(block),
);
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
}
// visual-design.md tells the author to write the blueprint as `<id> (Reproduce)`
// or `<id> (Adapt)` — the qualifier is direction for the frame worker, not part of
// the filename. Parse the field into the id it names (or null for `compose`), so
// no caller ever resolves a raw field value against the blueprints directory.
export function blueprintId(block) {
const raw = field(block, "blueprint");
if (!raw) return null;
const id = raw.replace(/\s*\([^)]*\)\s*$/, "").trim();
return id && id.toLowerCase() !== "compose" ? id : null;
}
export function resourceSections(block, { animationDir, ruleIds, frameId }) {
let sections = "";
const blueprint = blueprintId(block);
if (blueprint) {
const blueprintsDir = join(animationDir, "blueprints");
const path = join(blueprintsDir, `${blueprint}.md`);
// An installed library must contain the named blueprint.
// An absent on-demand library warns, like missing motion rules.
if (!existsSync(blueprintsDir)) {
console.warn(
`frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
);
} else if (!existsSync(path)) {
throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`);
} else {
sections += selectedFile(path, `Selected blueprint: ${blueprint}`);
}
}
for (const rule of citedRules(block, ruleIds)) {
sections += selectedFile(
join(animationDir, "rules", `${rule}.md`),
`Selected motion rule: ${rule}`,
);
}
return sections;
}
export function buildRolePayload({ corePath, deltaPath, outDir }) {
const core = readFileSync(corePath, "utf8").trim();
const delta = readFileSync(deltaPath, "utf8").trim();
const role = `${core}\n\n---\n\n${delta}\n`;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, "_role.md");
writeFileSync(path, role);
return { path, bytes: Buffer.byteLength(role) };
}
export function buildFramePackets({
projectDir,
storyboardPath = join(projectDir, "STORYBOARD.md"),
outDir = join(projectDir, ".hyperframes", "frame-packets"),
maxPacketBytes = 48_000,
animationDir,
corePath,
deltaPath,
// Per-workflow hooks (all optional):
// designTruthLine(projectDir) -> the packet's design-truth input line
// validateFrame(frame, id) -> throw to reject a frame before packing
// extraSections(block) -> extra packet sections appended after the rule recipes
designTruthLine = (dir) => `- Design tokens: ${join(resolve(dir), "frame.md")}`,
validateFrame,
extraSections,
}) {
const storyboard = readFileSync(storyboardPath, "utf8");
const frames = splitFrames(storyboard);
if (frames.length === 0) throw new Error("STORYBOARD.md has no frame blocks");
const ruleIds = knownRuleIds(animationDir);
const packets = frames.map((frame) => {
const id = frameId(frame);
if (validateFrame) validateFrame(frame, id);
const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds, frameId: id })}${extraSections ? extraSections(frame.block) : ""}`;
const bytes = Buffer.byteLength(packet);
if (bytes > maxPacketBytes) {
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
}
return { frameId: id, path: join(outDir, `${id}.md`), bytes, packet };
});
mkdirSync(outDir, { recursive: true });
for (const { path, packet } of packets) writeFileSync(path, packet);
buildRolePayload({ corePath, deltaPath, outDir });
return packets.map(({ packet: _packet, ...result }) => result);
}
export function flag(argv, name, fallback) {
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
}
// realpath both sides: on macOS /tmp → /private/tmp, and node resolves the main
// module's symlinks in import.meta.url while argv[1] keeps the invoked spelling —
// a raw compare silently skips main() when invoked through any symlinked path.
export function isMainModule(importMetaUrl) {
if (!process.argv[1]) return false;
try {
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
} catch {
return false;
}
}
export function runCli({ buildFramePackets: build, buildRolePayload: buildRole }) {
const argv = process.argv.slice(2);
const projectDir = resolve(flag(argv, "project", "."));
const outDir = resolve(flag(argv, "out-dir", join(projectDir, ".hyperframes", "frame-packets")));
try {
const packets = build({
projectDir,
storyboardPath: resolve(flag(argv, "storyboard", join(projectDir, "STORYBOARD.md"))),
outDir,
});
const role = buildRole({ outDir });
console.log(`✓ frame packets: ${packets.length} bounded packet(s)`);
for (const packet of packets)
console.log(` ${packet.frameId}: ${packet.bytes} bytes → ${packet.path}`);
console.log(` worker role: ${role.bytes} bytes → ${role.path}`);
} catch (error) {
console.error(`✗ frame packets: ${error.message}`);
process.exit(1);
}
}
@@ -1,20 +1,6 @@
// Shared frame-packet builder — the script half of the frame-worker core/delta split.
//
// Each narrative workflow ships a thin `scripts/frame-packets.mjs` wrapper that pins
// its own paths (animation skill, role delta, design-truth resolution, extra packet
// sections) and delegates everything else here, exactly as the markdown half already
// does with `references/frame-worker-core.md` + each workflow's delta. One owner for
// the packet-building logic; the wrappers own only what genuinely differs per workflow.
//
// Packet (<frame_id>.md) = project inputs + the frame's exact `## Frame N` block
// + the blueprint body + every cited rule recipe, inlined — so a worker never opens
// the shared STORYBOARD.md or any skill document. Cited motions are found
// mechanically: the explicit `- rules:` field when present, plus every valid rule id
// (a filename under the animation skill's rules/) mentioned in the block.
//
// _role.md = frame-worker-core.md + the workflow's sub-agents/frame-worker.md,
// concatenated verbatim — the complete worker role, assembled from the two source
// documents so nothing is hand-maintained twice.
// Frame packets inline the storyboard frame, blueprint and cited recipes.
// Workflow wrappers supply resource paths and workflow-specific sections.
// The role combines the shared worker contract with the workflow delta.
import {
existsSync,
@@ -100,12 +86,8 @@ export function resourceSections(block, { animationDir, ruleIds, frameId }) {
if (blueprint) {
const blueprintsDir = join(animationDir, "blueprints");
const path = join(blueprintsDir, `${blueprint}.md`);
// A blueprint that resolved to nothing used to inline an empty string, so the
// packet shipped without the one document the frame was designed against and
// the run still reported success. Name it instead — but only when the library
// is actually there to be named against. The animation skill installs on
// demand, so an absent blueprints/ is a missing install, not a bad id, and it
// degrades with a warning exactly like an absent rules/ (see knownRuleIds).
// An installed library must contain the named blueprint.
// An absent on-demand library warns, like missing motion rules.
if (!existsSync(blueprintsDir)) {
console.warn(
`frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
@@ -0,0 +1,4 @@
// Music sits under narration; a silent film brings music forward.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
+2 -6
View File
@@ -20,12 +20,8 @@ import { pythonInvocation } from "./python.mjs";
const r3 = (x) => Number(x.toFixed(3));
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
// Default BGM level. Under narration music is a bed that must stay under the
// voice — 0.12 linear ≈ -18 dB. A silent film (no voice) has no voice to duck
// beneath, so BGM sits forward at 0.9. Callers may override per composition.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
import { bgmDefaultVolume } from "./bgm-volume.mjs";
export { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm-volume.mjs";
const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
const BGM_PY_PROBE =
@@ -1,8 +1,10 @@
import { cpSync, mkdtempSync, rmSync } from "node:fs";
import assert from "node:assert/strict";
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import test from "node:test";
import { pathToFileURL } from "node:url";
function run(scriptsDir, script, args) {
return execFileSync(process.execPath, [join(scriptsDir, script), ...args], {
@@ -25,3 +27,68 @@ test("published memory scripts run without reaching outside the skill", () => {
rmSync(scratchDir, { recursive: true, force: true });
}
});
test("published audio helpers import without the CLI package tree", () => {
const root = mkdtempSync(join(tmpdir(), "media-use-audio-"));
const skill = join(root, "media-use");
try {
cpSync(new URL("..", import.meta.url), skill, { recursive: true });
for (const helper of ["heygen.mjs", "tts.mjs"])
run(join(skill, "audio/scripts/lib"), helper, []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
for (const name of ["faceless-explainer", "pr-to-video", "product-launch-video"]) {
test(name + " assembles from its installed directory alone", () => {
const root = mkdtempSync(join(tmpdir(), "installed-workflow-"));
try {
const skill = join(root, name);
cpSync(new URL("../../" + name, import.meta.url), skill, { recursive: true });
const project = join(root, "project");
mkdirSync(join(project, "compositions/frames"), { recursive: true });
writeFileSync(
join(project, "STORYBOARD.md"),
"---\nformat: 1920x1080\nmessage: Test\n---\n\n## Frame 1\n- duration: 3s\n- src: compositions/frames/01-a.html\n",
);
writeFileSync(
join(project, "compositions/frames/01-a.html"),
'<template><div data-composition-id="01-a" data-duration="3" data-width="1920" data-height="1080"><section class="clip" data-start="0" data-duration="3"></section></div></template>',
);
run(join(skill, "scripts"), "assemble-index.mjs", [
"--hyperframes",
project,
"--storyboard",
join(project, "STORYBOARD.md"),
]);
assert.match(readFileSync(join(project, "index.html"), "utf8"), /01-a/);
importPacketBuilder(join(skill, "scripts/frame-packets.mjs"));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
test("general-video packet builder starts from its installed directory alone", () => {
const root = mkdtempSync(join(tmpdir(), "installed-packets-"));
try {
cpSync(new URL("../../general-video", import.meta.url), root, { recursive: true });
importPacketBuilder(join(root, "scripts/frame-packets.mjs"));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
function importPacketBuilder(path) {
execFileSync(
process.execPath,
[
"--input-type=module",
"-e",
'const m = await import(process.argv[1]); if (typeof m.buildFramePackets !== "function") throw new Error("missing builder");',
pathToFileURL(path).href,
],
{ stdio: "pipe" },
);
}
+85 -6
View File
@@ -1,6 +1,85 @@
// Compatibility copy for the audio helpers that still import the historical
// skill-relative path. The CLI-owned engine is the source of truth.
export {
fetchMedia,
isPublicMediaUrl,
} from "../../../../packages/cli/src/media-use/lib/media-fetch.mjs";
// Media downloads use public HTTP(S) URLs. Validate every redirect target;
// a provider result must meet the same host policy as a direct ingest URL.
// Public HTTPS-to-HTTP redirects are allowed, matching direct HTTP support.
// This is a literal-host policy, not DNS pinning: DNS resolution remains trusted.
import { BlockList, isIP } from "node:net";
const blocked = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
])
blocked.addSubnet(network, prefix, "ipv4");
for (const [network, prefix] of [
["::", 128],
["::1", 128],
["fc00::", 7],
["fe80::", 10],
["fec0::", 10],
["ff00::", 8],
["2001:db8::", 32],
])
blocked.addSubnet(network, prefix, "ipv6");
export function isPublicMediaUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
const host = url.hostname.replace(/\.$/, "");
if (
host === "localhost" ||
host.endsWith(".localhost") ||
host.endsWith(".local") ||
host.endsWith(".internal")
)
return false;
const address = host.replace(/^\[|\]$/g, "");
const family = isIP(address);
return family === 0 || !blocked.check(address, family === 4 ? "ipv4" : "ipv6");
} catch {
return false;
}
}
export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetch } = {}) {
let current = String(url);
for (let hop = 0; hop <= 5; hop++) {
if (!isPublicMediaUrl(current))
throw new Error("Media download blocked: URL is not public HTTP(S)");
const response = await fetchImpl(current, { method, signal, redirect: "manual" });
if (!(response.status >= 300 && response.status < 400)) return response;
const location = response.headers.get("location");
if (!location) return response;
await response.body?.cancel();
current = new URL(location, current).href;
}
throw new Error("Media download exceeded redirect limit");
}
// Stream and abort once the cap is crossed, so a lying or chunked body can't buffer past it.
export async function readCappedBody(res, maxBytes, label) {
const declared = Number(res.headers.get("content-length"));
if (declared > maxBytes) throw new Error(`${label}: ${declared} bytes exceeds ${maxBytes} cap`);
const chunks = [];
let total = 0;
for await (const chunk of res.body) {
total += chunk.length;
if (total > maxBytes) throw new Error(`${label}: stream exceeds ${maxBytes} cap`);
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
@@ -58,7 +58,7 @@ import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.mjs";
import { validateFrameHtml } from "./lib/frame-contract.mjs";
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
import { bgmDefaultVolume } from "./lib/bgm-volume.mjs";
// ---------- argv ----------
const argv = process.argv.slice(2);
+1 -1
View File
@@ -9,7 +9,7 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as core from "../../hyperframes/scripts/lib/frame-packets-core.mjs";
import * as core from "./lib/frame-packets-core.mjs";
const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -0,0 +1,4 @@
// Music sits under narration; a silent film brings music forward.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
@@ -0,0 +1,194 @@
// Frame packets inline the storyboard frame, blueprint and cited recipes.
// Workflow wrappers supply resource paths and workflow-specific sections.
// The role combines the shared worker contract with the workflow delta.
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export function field(block, name) {
const match = block.match(new RegExp(`^-\\s+${name}:\\s*(.+)$`, "im"));
return match?.[1]?.trim() ?? null;
}
export function splitFrames(storyboard) {
const matches = [...storyboard.matchAll(/^## Frame\s+([^\n]+)$/gm)];
return matches.map((match, index) => {
const start = match.index;
const end = matches[index + 1]?.index ?? storyboard.length;
return {
heading: match[1].trim(),
block: storyboard.slice(start, end).trim(),
};
});
}
export function frameId(frame) {
const src = field(frame.block, "src");
if (!src) throw new Error(`${frame.heading}: missing src`);
return basename(src).replace(/\.html?$/i, "");
}
export function selectedFile(path, heading) {
if (!path || !existsSync(path)) return "";
return `\n## ${heading}\n\n${readFileSync(path, "utf8").trim()}\n`;
}
function escapeRegExp(id) {
return id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function knownRuleIds(animationDir) {
const rulesDir = join(animationDir, "rules");
if (!existsSync(rulesDir)) {
console.warn(
`frame-packets: no rules dir at ${rulesDir} — packets will inline no motion recipes`,
);
return [];
}
return readdirSync(rulesDir)
.filter((name) => name.endsWith(".md"))
.map((name) => name.replace(/\.md$/, ""));
}
export function citedRules(block, ruleIds) {
const explicit = (field(block, "rules") ?? "")
.split(/[,\s]+/)
.map((rule) => rule.trim())
.filter(Boolean);
const mentioned = ruleIds.filter((id) =>
new RegExp(`(?<![\\w-])${escapeRegExp(id)}(?![\\w-])`, "i").test(block),
);
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
}
// visual-design.md tells the author to write the blueprint as `<id> (Reproduce)`
// or `<id> (Adapt)` — the qualifier is direction for the frame worker, not part of
// the filename. Parse the field into the id it names (or null for `compose`), so
// no caller ever resolves a raw field value against the blueprints directory.
export function blueprintId(block) {
const raw = field(block, "blueprint");
if (!raw) return null;
const id = raw.replace(/\s*\([^)]*\)\s*$/, "").trim();
return id && id.toLowerCase() !== "compose" ? id : null;
}
export function resourceSections(block, { animationDir, ruleIds, frameId }) {
let sections = "";
const blueprint = blueprintId(block);
if (blueprint) {
const blueprintsDir = join(animationDir, "blueprints");
const path = join(blueprintsDir, `${blueprint}.md`);
// An installed library must contain the named blueprint.
// An absent on-demand library warns, like missing motion rules.
if (!existsSync(blueprintsDir)) {
console.warn(
`frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
);
} else if (!existsSync(path)) {
throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`);
} else {
sections += selectedFile(path, `Selected blueprint: ${blueprint}`);
}
}
for (const rule of citedRules(block, ruleIds)) {
sections += selectedFile(
join(animationDir, "rules", `${rule}.md`),
`Selected motion rule: ${rule}`,
);
}
return sections;
}
export function buildRolePayload({ corePath, deltaPath, outDir }) {
const core = readFileSync(corePath, "utf8").trim();
const delta = readFileSync(deltaPath, "utf8").trim();
const role = `${core}\n\n---\n\n${delta}\n`;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, "_role.md");
writeFileSync(path, role);
return { path, bytes: Buffer.byteLength(role) };
}
export function buildFramePackets({
projectDir,
storyboardPath = join(projectDir, "STORYBOARD.md"),
outDir = join(projectDir, ".hyperframes", "frame-packets"),
maxPacketBytes = 48_000,
animationDir,
corePath,
deltaPath,
// Per-workflow hooks (all optional):
// designTruthLine(projectDir) -> the packet's design-truth input line
// validateFrame(frame, id) -> throw to reject a frame before packing
// extraSections(block) -> extra packet sections appended after the rule recipes
designTruthLine = (dir) => `- Design tokens: ${join(resolve(dir), "frame.md")}`,
validateFrame,
extraSections,
}) {
const storyboard = readFileSync(storyboardPath, "utf8");
const frames = splitFrames(storyboard);
if (frames.length === 0) throw new Error("STORYBOARD.md has no frame blocks");
const ruleIds = knownRuleIds(animationDir);
const packets = frames.map((frame) => {
const id = frameId(frame);
if (validateFrame) validateFrame(frame, id);
const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds, frameId: id })}${extraSections ? extraSections(frame.block) : ""}`;
const bytes = Buffer.byteLength(packet);
if (bytes > maxPacketBytes) {
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
}
return { frameId: id, path: join(outDir, `${id}.md`), bytes, packet };
});
mkdirSync(outDir, { recursive: true });
for (const { path, packet } of packets) writeFileSync(path, packet);
buildRolePayload({ corePath, deltaPath, outDir });
return packets.map(({ packet: _packet, ...result }) => result);
}
export function flag(argv, name, fallback) {
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
}
// realpath both sides: on macOS /tmp → /private/tmp, and node resolves the main
// module's symlinks in import.meta.url while argv[1] keeps the invoked spelling —
// a raw compare silently skips main() when invoked through any symlinked path.
export function isMainModule(importMetaUrl) {
if (!process.argv[1]) return false;
try {
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
} catch {
return false;
}
}
export function runCli({ buildFramePackets: build, buildRolePayload: buildRole }) {
const argv = process.argv.slice(2);
const projectDir = resolve(flag(argv, "project", "."));
const outDir = resolve(flag(argv, "out-dir", join(projectDir, ".hyperframes", "frame-packets")));
try {
const packets = build({
projectDir,
storyboardPath: resolve(flag(argv, "storyboard", join(projectDir, "STORYBOARD.md"))),
outDir,
});
const role = buildRole({ outDir });
console.log(`✓ frame packets: ${packets.length} bounded packet(s)`);
for (const packet of packets)
console.log(` ${packet.frameId}: ${packet.bytes} bytes → ${packet.path}`);
console.log(` worker role: ${role.bytes} bytes → ${role.path}`);
} catch (error) {
console.error(`✗ frame packets: ${error.message}`);
process.exit(1);
}
}
@@ -57,7 +57,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.mjs";
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
import { bgmDefaultVolume } from "./lib/bgm-volume.mjs";
// ---------- argv ----------
const argv = process.argv.slice(2);
@@ -7,7 +7,7 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as core from "../../hyperframes/scripts/lib/frame-packets-core.mjs";
import * as core from "./lib/frame-packets-core.mjs";
const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CONFIG = {
@@ -0,0 +1,4 @@
// Music sits under narration; a silent film brings music forward.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
@@ -0,0 +1,194 @@
// Frame packets inline the storyboard frame, blueprint and cited recipes.
// Workflow wrappers supply resource paths and workflow-specific sections.
// The role combines the shared worker contract with the workflow delta.
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export function field(block, name) {
const match = block.match(new RegExp(`^-\\s+${name}:\\s*(.+)$`, "im"));
return match?.[1]?.trim() ?? null;
}
export function splitFrames(storyboard) {
const matches = [...storyboard.matchAll(/^## Frame\s+([^\n]+)$/gm)];
return matches.map((match, index) => {
const start = match.index;
const end = matches[index + 1]?.index ?? storyboard.length;
return {
heading: match[1].trim(),
block: storyboard.slice(start, end).trim(),
};
});
}
export function frameId(frame) {
const src = field(frame.block, "src");
if (!src) throw new Error(`${frame.heading}: missing src`);
return basename(src).replace(/\.html?$/i, "");
}
export function selectedFile(path, heading) {
if (!path || !existsSync(path)) return "";
return `\n## ${heading}\n\n${readFileSync(path, "utf8").trim()}\n`;
}
function escapeRegExp(id) {
return id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function knownRuleIds(animationDir) {
const rulesDir = join(animationDir, "rules");
if (!existsSync(rulesDir)) {
console.warn(
`frame-packets: no rules dir at ${rulesDir} — packets will inline no motion recipes`,
);
return [];
}
return readdirSync(rulesDir)
.filter((name) => name.endsWith(".md"))
.map((name) => name.replace(/\.md$/, ""));
}
export function citedRules(block, ruleIds) {
const explicit = (field(block, "rules") ?? "")
.split(/[,\s]+/)
.map((rule) => rule.trim())
.filter(Boolean);
const mentioned = ruleIds.filter((id) =>
new RegExp(`(?<![\\w-])${escapeRegExp(id)}(?![\\w-])`, "i").test(block),
);
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
}
// visual-design.md tells the author to write the blueprint as `<id> (Reproduce)`
// or `<id> (Adapt)` — the qualifier is direction for the frame worker, not part of
// the filename. Parse the field into the id it names (or null for `compose`), so
// no caller ever resolves a raw field value against the blueprints directory.
export function blueprintId(block) {
const raw = field(block, "blueprint");
if (!raw) return null;
const id = raw.replace(/\s*\([^)]*\)\s*$/, "").trim();
return id && id.toLowerCase() !== "compose" ? id : null;
}
export function resourceSections(block, { animationDir, ruleIds, frameId }) {
let sections = "";
const blueprint = blueprintId(block);
if (blueprint) {
const blueprintsDir = join(animationDir, "blueprints");
const path = join(blueprintsDir, `${blueprint}.md`);
// An installed library must contain the named blueprint.
// An absent on-demand library warns, like missing motion rules.
if (!existsSync(blueprintsDir)) {
console.warn(
`frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
);
} else if (!existsSync(path)) {
throw new Error(`${frameId ?? "frame"}: blueprint "${blueprint}" has no file at ${path}`);
} else {
sections += selectedFile(path, `Selected blueprint: ${blueprint}`);
}
}
for (const rule of citedRules(block, ruleIds)) {
sections += selectedFile(
join(animationDir, "rules", `${rule}.md`),
`Selected motion rule: ${rule}`,
);
}
return sections;
}
export function buildRolePayload({ corePath, deltaPath, outDir }) {
const core = readFileSync(corePath, "utf8").trim();
const delta = readFileSync(deltaPath, "utf8").trim();
const role = `${core}\n\n---\n\n${delta}\n`;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, "_role.md");
writeFileSync(path, role);
return { path, bytes: Buffer.byteLength(role) };
}
export function buildFramePackets({
projectDir,
storyboardPath = join(projectDir, "STORYBOARD.md"),
outDir = join(projectDir, ".hyperframes", "frame-packets"),
maxPacketBytes = 48_000,
animationDir,
corePath,
deltaPath,
// Per-workflow hooks (all optional):
// designTruthLine(projectDir) -> the packet's design-truth input line
// validateFrame(frame, id) -> throw to reject a frame before packing
// extraSections(block) -> extra packet sections appended after the rule recipes
designTruthLine = (dir) => `- Design tokens: ${join(resolve(dir), "frame.md")}`,
validateFrame,
extraSections,
}) {
const storyboard = readFileSync(storyboardPath, "utf8");
const frames = splitFrames(storyboard);
if (frames.length === 0) throw new Error("STORYBOARD.md has no frame blocks");
const ruleIds = knownRuleIds(animationDir);
const packets = frames.map((frame) => {
const id = frameId(frame);
if (validateFrame) validateFrame(frame, id);
const packet = `# Frame packet: ${id}\n\n## Project inputs\n\n- Project: ${resolve(projectDir)}\n${designTruthLine(projectDir)}\n- RULES_DIR: ${join(animationDir, "rules")}\n\n## Assigned storyboard block\n\n${frame.block}\n${resourceSections(frame.block, { animationDir, ruleIds, frameId: id })}${extraSections ? extraSections(frame.block) : ""}`;
const bytes = Buffer.byteLength(packet);
if (bytes > maxPacketBytes) {
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
}
return { frameId: id, path: join(outDir, `${id}.md`), bytes, packet };
});
mkdirSync(outDir, { recursive: true });
for (const { path, packet } of packets) writeFileSync(path, packet);
buildRolePayload({ corePath, deltaPath, outDir });
return packets.map(({ packet: _packet, ...result }) => result);
}
export function flag(argv, name, fallback) {
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
}
// realpath both sides: on macOS /tmp → /private/tmp, and node resolves the main
// module's symlinks in import.meta.url while argv[1] keeps the invoked spelling —
// a raw compare silently skips main() when invoked through any symlinked path.
export function isMainModule(importMetaUrl) {
if (!process.argv[1]) return false;
try {
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
} catch {
return false;
}
}
export function runCli({ buildFramePackets: build, buildRolePayload: buildRole }) {
const argv = process.argv.slice(2);
const projectDir = resolve(flag(argv, "project", "."));
const outDir = resolve(flag(argv, "out-dir", join(projectDir, ".hyperframes", "frame-packets")));
try {
const packets = build({
projectDir,
storyboardPath: resolve(flag(argv, "storyboard", join(projectDir, "STORYBOARD.md"))),
outDir,
});
const role = buildRole({ outDir });
console.log(`✓ frame packets: ${packets.length} bounded packet(s)`);
for (const packet of packets)
console.log(` ${packet.frameId}: ${packet.bytes} bytes → ${packet.path}`);
console.log(` worker role: ${role.bytes} bytes → ${role.path}`);
} catch (error) {
console.error(`✗ frame packets: ${error.message}`);
process.exit(1);
}
}