mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user