Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 138
},
"faceless-explainer": {
"hash": "1eb3772e62dd71bb",
"hash": "881427da270043e3",
"files": 24
},
"figma": {
Expand Down Expand Up @@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "ec542db377d8b213",
"hash": "35eb12d7e0062a61",
"files": 20
},
"hyperframes-creative": {
Expand Down Expand Up @@ -62,11 +62,11 @@
"files": 132
},
"pr-to-video": {
"hash": "01f46da1e17577ea",
"hash": "264e82ff07a16900",
"files": 30
},
"product-launch-video": {
"hash": "d562efe00647c14b",
"hash": "b3c7ed11831f51d3",
"files": 28
},
"remotion-to-hyperframes": {
Expand Down
2 changes: 1 addition & 1 deletion skills/faceless-explainer/references/story-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Use the exact fields required by the core storyboard format. This is the narrati
- type: feature_showcase
- persuasion: Progressive disclosure
- beat: comprehension
- blueprint: messaging-multi-phase — candidate shape from the role→blueprint menu; omit when none fits
- blueprint: dataviz-countup — candidate shape from the role→blueprint menu; omit when none fits

narrativeRole: What this frame does in the viewer's understanding.
keyMessage: The one idea the viewer should remember.
Expand Down
40 changes: 32 additions & 8 deletions skills/hyperframes-core/scripts/lib/frame-packets-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,38 @@ export function citedRules(block, ruleIds) {
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
}

export function resourceSections(block, { animationDir, ruleIds }) {
// 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 = field(block, "blueprint");
if (blueprint && blueprint.toLowerCase() !== "compose") {
sections += selectedFile(
join(animationDir, "blueprints", `${blueprint}.md`),
`Selected blueprint: ${blueprint}`,
);
const blueprint = blueprintId(block);
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).
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(
Expand Down Expand Up @@ -135,7 +159,7 @@ export function buildFramePackets({
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 })}${extraSections ? extraSections(frame.block) : ""}`;
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})`);
Expand Down
2 changes: 1 addition & 1 deletion skills/pr-to-video/scripts/workflow-guardrails.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test("#1092 packets contain selected excerpts but never the full diff", () => {
write(join(project, "frame.md"), "# compact frame tokens\n");
write(
join(project, "STORYBOARD.md"),
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Diff\n\n- duration: 4s\n- src: compositions/frames/01-diff.html\n- focal: code-diff\n- blueprint: compose\n- rules: text-reveal\n\n### Source excerpt\n\n\`\`\`diff\n-oldCall()\n+newCall({ attested: true })\n\`\`\`\n\n## Frame 2 — Impact\n\n- duration: 3s\n- src: compositions/frames/02-impact.html\n- blueprint: number-lockup\n- rules: counting-dynamic-scale\n`,
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Diff\n\n- duration: 4s\n- src: compositions/frames/01-diff.html\n- focal: code-diff\n- blueprint: compose\n- rules: text-reveal\n\n### Source excerpt\n\n\`\`\`diff\n-oldCall()\n+newCall({ attested: true })\n\`\`\`\n\n## Frame 2 — Impact\n\n- duration: 3s\n- src: compositions/frames/02-impact.html\n- blueprint: dataviz-countup\n- rules: counting-dynamic-scale\n`,
);

const result = buildFramePackets({
Expand Down
78 changes: 78 additions & 0 deletions skills/product-launch-video/scripts/frame-packets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,81 @@ test("packet validation is atomic and leaves no partial output on overflow", ()
);
assert.equal(existsSync(outDir), false);
});

// ── the blueprint qualifier ──────────────────────────────────────────────────
// Regression: visual-design.md documents `blueprint:` as the id plus a
// `(Reproduce)` / `(Adapt)` qualifier, and prints `dataviz-countup (Adapt)` as
// its worked example. The resolver used the raw field as the filename, so every
// qualified blueprint looked for a file that cannot exist and inlined "" —
// packets shipped without the document the frame was designed against, and the
// run still reported success. The cases above only ever used bare ids.

test("a qualified blueprint resolves to the same body as the bare id", () => {
const project = mkdtempSync(join(tmpdir(), "plv-blueprint-qualified-"));
write(join(project, "frame.md"), "# tokens\n");
write(
join(project, "STORYBOARD.md"),
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Adapted\n\n- duration: 3s\n- src: compositions/frames/01-adapted.html\n- blueprint: device-surface-showcase (Adapt)\n\n## Frame 2 — Reproduced\n\n- duration: 3s\n- src: compositions/frames/02-reproduced.html\n- blueprint: device-surface-showcase (Reproduce)\n\n## Frame 3 — Bare\n\n- duration: 3s\n- src: compositions/frames/03-bare.html\n- blueprint: device-surface-showcase\n`,
);

const packets = buildFramePackets({ projectDir: project });
const blueprintSections = packets.map((packet) => {
const body = readFileSync(packet.path, "utf8");
const start = body.indexOf("## Selected blueprint:");
assert.notEqual(start, -1, `${packet.frameId} inlined no blueprint`);
return body.slice(start);
});

assert.match(blueprintSections[0], /## Selected blueprint: device-surface-showcase\n/);
// The qualifier is direction for the worker, not a different document: all
// three frames must inline byte-identical blueprint bodies.
assert.equal(new Set(blueprintSections).size, 1);
});

test("a qualified `compose` still selects no blueprint", () => {
const project = mkdtempSync(join(tmpdir(), "plv-blueprint-compose-"));
write(join(project, "frame.md"), "# tokens\n");
write(
join(project, "STORYBOARD.md"),
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Freeform\n\n- duration: 3s\n- src: compositions/frames/01-freeform.html\n- blueprint: compose (Adapt)\n`,
);

const [packet] = buildFramePackets({ projectDir: project });

assert.doesNotMatch(readFileSync(packet.path, "utf8"), /## Selected blueprint/);
});

test("a blueprint with no file fails the run instead of shipping an empty section", () => {
const project = mkdtempSync(join(tmpdir(), "plv-blueprint-missing-"));
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 — Typo\n\n- duration: 3s\n- src: compositions/frames/01-typo.html\n- blueprint: device-surface-showcses\n`,
);

assert.throws(
() => buildFramePackets({ projectDir: project, outDir }),
/01-typo: blueprint "device-surface-showcses" has no file/,
);
assert.equal(existsSync(outDir), false);
});

test("an uninstalled animation skill degrades with a warning, it does not fail the run", () => {
// hyperframes-animation installs on demand, so an absent blueprints/ means the
// library isn't there yet — not that the frame named a bad id. Matches how an
// absent rules/ already behaves.
const project = mkdtempSync(join(tmpdir(), "plv-blueprint-uninstalled-"));
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/frames/01-hook.html\n- blueprint: dataviz-countup (Adapt)\n`,
);

const [packet] = buildFramePackets({
projectDir: project,
animationDir: join(project, "absent-animation-skill"),
});

assert.doesNotMatch(readFileSync(packet.path, "utf8"), /## Selected blueprint/);
});
Loading