mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix(skills): resolve the blueprint id from a qualified blueprint: field (#3337)
* fix(skills): resolve the blueprint id from a qualified `blueprint:` field visual-design.md documents `blueprint:` as the id plus a `(Reproduce)` / `(Adapt)` qualifier, and prints `dataviz-countup (Adapt)` as its worked example. The packet builder used that raw field as a filename, so a qualified blueprint looked for `<id> (Adapt).md`, found nothing, and inlined an empty string: `selectedFile()` returns "" for a missing path. Every packet shipped without the one document the frame was designed against, and the run still exited 0 with nothing on stderr. `compose (Adapt)` missed the `compose` check the same way. Parse the field into the id it names, once, so no caller resolves a raw field value against the blueprints directory. A blueprint that resolves to no file is now a named error rather than an empty section, matching how the builder already treats a missing `src` and an oversize packet. The existing tests only used bare ids, which is how the qualified form escaped; they now cover both, and the missing-file case. One owner: product-launch-video, faceless-explainer, pr-to-video and general-video all delegate to frame-packets-core.mjs. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> * fix(skills): degrade, not fail, when the blueprints library is absent Self-review catch on the previous commit. hyperframes-animation installs on demand, so its blueprints/ directory can legitimately be missing — that is a skill that isn't installed yet, not a frame naming a bad id. Throwing there turned a silent degrade into a hard failure for a valid setup. Distinguish the two: an absent blueprints/ warns and inlines nothing, exactly as an absent rules/ already does in knownRuleIds; a present library that has no file for this id still throws, because that is a typo or an unstripped qualifier. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> * fix(skills): point two dead blueprint references at real shapes CI surfaced these once an unresolvable blueprint stopped being silent. Both named ids that have never existed in hyperframes-animation/blueprints/: - faceless-explainer's frame template taught `messaging-multi-phase`, so an agent copying the template verbatim tagged a blueprint that resolves to nothing. dataviz-countup is what the same skill already uses in its own visual-design template and tests. - pr-to-video's diff-excerpt guardrail fixture used `number-lockup`. The test is about diff excerpting and the id was incidental; the frame's own `counting-dynamic-scale` rule makes dataviz-countup the natural real shape. A sweep of every `blueprint:` value across skills/ finds no others. Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com> --------- Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"files": 138
|
"files": 138
|
||||||
},
|
},
|
||||||
"faceless-explainer": {
|
"faceless-explainer": {
|
||||||
"hash": "7d587ff36c975d9a",
|
"hash": "81423e16a3e2806d",
|
||||||
"files": 24
|
"files": 24
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"files": 11
|
"files": 11
|
||||||
},
|
},
|
||||||
"hyperframes-core": {
|
"hyperframes-core": {
|
||||||
"hash": "f6516b74cb6e5d58",
|
"hash": "74b9841f63c8f405",
|
||||||
"files": 20
|
"files": 20
|
||||||
},
|
},
|
||||||
"hyperframes-creative": {
|
"hyperframes-creative": {
|
||||||
@@ -62,11 +62,11 @@
|
|||||||
"files": 132
|
"files": 132
|
||||||
},
|
},
|
||||||
"pr-to-video": {
|
"pr-to-video": {
|
||||||
"hash": "14250b018114d26d",
|
"hash": "bacdbbd868cd84a1",
|
||||||
"files": 30
|
"files": 30
|
||||||
},
|
},
|
||||||
"product-launch-video": {
|
"product-launch-video": {
|
||||||
"hash": "12c0895d4963aa90",
|
"hash": "ff7c30295b67fc2e",
|
||||||
"files": 29
|
"files": 29
|
||||||
},
|
},
|
||||||
"remotion-to-hyperframes": {
|
"remotion-to-hyperframes": {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ Use the exact fields required by the core storyboard format. This is the narrati
|
|||||||
- type: feature_showcase
|
- type: feature_showcase
|
||||||
- persuasion: Progressive disclosure
|
- persuasion: Progressive disclosure
|
||||||
- beat: comprehension
|
- 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.
|
narrativeRole: What this frame does in the viewer's understanding.
|
||||||
keyMessage: The one idea the viewer should remember.
|
keyMessage: The one idea the viewer should remember.
|
||||||
|
|||||||
@@ -83,14 +83,38 @@ export function citedRules(block, ruleIds) {
|
|||||||
return [...new Set([...explicit, ...mentioned])].filter((id) => ruleIds.includes(id));
|
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 = "";
|
let sections = "";
|
||||||
const blueprint = field(block, "blueprint");
|
const blueprint = blueprintId(block);
|
||||||
if (blueprint && blueprint.toLowerCase() !== "compose") {
|
if (blueprint) {
|
||||||
sections += selectedFile(
|
const blueprintsDir = join(animationDir, "blueprints");
|
||||||
join(animationDir, "blueprints", `${blueprint}.md`),
|
const path = join(blueprintsDir, `${blueprint}.md`);
|
||||||
`Selected blueprint: ${blueprint}`,
|
// 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)) {
|
for (const rule of citedRules(block, ruleIds)) {
|
||||||
sections += selectedFile(
|
sections += selectedFile(
|
||||||
@@ -135,7 +159,7 @@ export function buildFramePackets({
|
|||||||
const packets = frames.map((frame) => {
|
const packets = frames.map((frame) => {
|
||||||
const id = frameId(frame);
|
const id = frameId(frame);
|
||||||
if (validateFrame) validateFrame(frame, id);
|
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);
|
const bytes = Buffer.byteLength(packet);
|
||||||
if (bytes > maxPacketBytes) {
|
if (bytes > maxPacketBytes) {
|
||||||
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
|
throw new Error(`${id}: frame packet is ${bytes} bytes (limit ${maxPacketBytes})`);
|
||||||
|
|||||||
@@ -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, "frame.md"), "# compact frame tokens\n");
|
||||||
write(
|
write(
|
||||||
join(project, "STORYBOARD.md"),
|
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({
|
const result = buildFramePackets({
|
||||||
|
|||||||
@@ -64,3 +64,81 @@ test("packet validation is atomic and leaves no partial output on overflow", ()
|
|||||||
);
|
);
|
||||||
assert.equal(existsSync(outDir), false);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user