Add HyperFrames skills
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
This commit is contained in:
964 files changed
+142321
No files matched your search
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Thin wrapper over the shared packet builder in hyperframes — this file pins
|
||||
// this workflow's paths plus its one behavioral difference: design truth resolves
|
||||
// frame.md → design.md → DESIGN.md (general-video § 6 order). Everything else has
|
||||
// one owner: ../../hyperframes/scripts/lib/frame-packets-core.mjs
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as core from "./lib/frame-packets-core.mjs";
|
||||
|
||||
const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function designTruthLine(projectDir) {
|
||||
for (const name of ["frame.md", "design.md", "DESIGN.md"]) {
|
||||
const candidate = join(resolve(projectDir), name);
|
||||
if (existsSync(candidate)) return `- Design truth: ${candidate}`;
|
||||
}
|
||||
return `- Design truth: ${join(resolve(projectDir), "frame.md")}`;
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
animationDir: resolve(SKILL_DIR, "../hyperframes-animation"),
|
||||
corePath: resolve(SKILL_DIR, "../hyperframes/references/frame-worker-core.md"),
|
||||
deltaPath: resolve(SKILL_DIR, "sub-agents/frame-worker.md"),
|
||||
designTruthLine,
|
||||
};
|
||||
|
||||
export function buildRolePayload({ outDir }) {
|
||||
return core.buildRolePayload({ ...CONFIG, outDir });
|
||||
}
|
||||
|
||||
export function buildFramePackets(options) {
|
||||
return core.buildFramePackets({ ...CONFIG, ...options });
|
||||
}
|
||||
|
||||
if (core.isMainModule(import.meta.url)) core.runCli({ buildFramePackets, buildRolePayload });
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildFramePackets } from "./frame-packets.mjs";
|
||||
|
||||
function write(path, contents) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, contents);
|
||||
}
|
||||
|
||||
test("packets inline the blueprint body and the Scene-cited rule recipes", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "gv-packets-"));
|
||||
write(join(project, "frame.md"), "# tokens\n");
|
||||
write(
|
||||
join(project, "STORYBOARD.md"),
|
||||
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/01-hook.html\n- blueprint: dataviz-countup\n- scene: hero stat punches in\n\nScene 1 (0.0–1.5s): the stat enters via spring-pop-entrance, then counting-dynamic-scale runs the tally.\n\n## Frame 2 — Freeform\n\n- duration: 4s\n- src: compositions/02-freeform.html\n- blueprint: compose\n\nScene 1 (0.0–4.0s): a quiet hold, no named motion.\n`,
|
||||
);
|
||||
|
||||
const result = buildFramePackets({ projectDir: project });
|
||||
assert.equal(result.length, 2);
|
||||
|
||||
const hook = readFileSync(result[0].path, "utf8");
|
||||
assert.match(hook, /## Selected blueprint: dataviz-countup/);
|
||||
assert.match(hook, /## Selected motion rule: spring-pop-entrance/);
|
||||
assert.match(hook, /## Selected motion rule: counting-dynamic-scale/);
|
||||
assert.match(hook, /RULES_DIR: /);
|
||||
|
||||
const freeform = readFileSync(result[1].path, "utf8");
|
||||
assert.doesNotMatch(freeform, /## Selected blueprint/);
|
||||
assert.doesNotMatch(freeform, /## Selected motion rule/);
|
||||
});
|
||||
|
||||
test("design truth resolves frame.md → design.md → DESIGN.md", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "gv-design-"));
|
||||
write(join(project, "design.md"), "# design truth\n");
|
||||
write(
|
||||
join(project, "STORYBOARD.md"),
|
||||
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/01-hook.html\n`,
|
||||
);
|
||||
|
||||
const result = buildFramePackets({ projectDir: project });
|
||||
const packet = readFileSync(result[0].path, "utf8");
|
||||
assert.match(packet, /Design truth: .*design\.md/);
|
||||
assert.doesNotMatch(packet, /Design truth: .*frame\.md/);
|
||||
});
|
||||
|
||||
test("_role.md is the core contract + this workflow's delta, verbatim", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "gv-role-"));
|
||||
write(join(project, "frame.md"), "# tokens\n");
|
||||
write(
|
||||
join(project, "STORYBOARD.md"),
|
||||
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/01-hook.html\n`,
|
||||
);
|
||||
|
||||
buildFramePackets({ projectDir: project });
|
||||
const rolePath = join(project, ".hyperframes", "frame-packets", "_role.md");
|
||||
assert.ok(existsSync(rolePath));
|
||||
const role = readFileSync(rolePath, "utf8");
|
||||
assert.match(role, /# Frame worker — core contract/);
|
||||
assert.match(role, /# Frame worker — general-video delta/);
|
||||
});
|
||||
|
||||
test("packet validation is atomic and leaves no partial output on overflow", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "gv-atomic-"));
|
||||
const outDir = join(project, ".hyperframes", "frame-packets");
|
||||
write(join(project, "frame.md"), "# tokens\n");
|
||||
write(
|
||||
join(project, "STORYBOARD.md"),
|
||||
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Big\n\n- duration: 3s\n- src: compositions/01-big.html\n\n${"padding line\n".repeat(300)}`,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => buildFramePackets({ projectDir: project, outDir, maxPacketBytes: 2_000 }),
|
||||
/limit 2000/,
|
||||
);
|
||||
assert.equal(existsSync(outDir), false);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export { isMainModule } from "./main-module.mjs";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Node resolves symlinks in the main module's import.meta.url but argv[1] keeps the invoked
|
||||
// spelling (a symlinked skills dir, or /tmp vs /private/tmp on macOS), so compare real paths.
|
||||
export function isMainModule(importMetaUrl) {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user