fix(pr-to-video): enforce offline frame contract (#2383)

* fix(pr-to-video): enforce offline frame contract

* fix(pr-to-video): tighten frame attribute contract
This commit is contained in:
Miguel Ángel
2026-07-13 22:39:32 -04:00
committed by GitHub
parent fba5cb9c93
commit 852d1891d3
16 changed files with 588 additions and 8 deletions
+4 -4
View File
@@ -34,8 +34,8 @@
"files": 14 "files": 14
}, },
"hyperframes-creative": { "hyperframes-creative": {
"hash": "3e5bcbf46dc14427", "hash": "e0750456fba2cf22",
"files": 69 "files": 78
}, },
"hyperframes-keyframes": { "hyperframes-keyframes": {
"hash": "040453e302a0e15b", "hash": "040453e302a0e15b",
@@ -58,8 +58,8 @@
"files": 132 "files": 132
}, },
"pr-to-video": { "pr-to-video": {
"hash": "9fe5b5fecfc78f38", "hash": "7ea2d086eb5a5856",
"files": 26 "files": 28
}, },
"product-launch-video": { "product-launch-video": {
"hash": "b1b41c74a52e28f1", "hash": "b1b41c74a52e28f1",
@@ -271,7 +271,7 @@ to the diff; commit/issue numbers are chrome.
## Known Gaps ## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only. Claude's motion register — short cross-dissolves, no overshoot/bounce/elastic, coral the only "draw-on", numbers count up, code types on line by line — lives in the workflow's `motion-language.md` + `hyperframes-animation`, not here. - **Motion intentionally out of scope.** frame.md specifies composition only. Claude's motion register — short cross-dissolves, no overshoot/bounce/elastic, coral the only "draw-on", numbers count up, code types on line by line — lives in the workflow's `motion-language.md` + `hyperframes-animation`, not here.
- **EB Garamond + Inter + JetBrains Mono are all bundled in the HyperFrames renderer (`@fontsource` embedded data) — they resolve offline by name, with no Google Fonts dependency.** This is deliberate: every face here renders deterministically on a clean machine or AWS Lambda, so a frame needs no captured `.woff2` or `@font-face` for these three. The embedded set ships **weights 400 + 700 only and no true italic** — so author display at **weight 400** (700 reads as a heavy bold, off-register), and treat italic as the browser-synthesized slant (acceptable for the pull-quote register; ship a real EB Garamond italic `.woff2` + `@font-face` only if a project leans hard on it). EB Garamond is a warm old-style serif (low contrast, humanist); if it ever fails, fall to Georgia or another old-style serif — never to a sans. CJK: Noto Serif SC (display) / Noto Sans SC (body) / Noto Sans Mono CJK (code); the sentence-case warmth carries when the serif drops. - **EB Garamond + Inter + JetBrains Mono ship as licensed local WOFF2 assets with this preset.** `build-frame.mjs` stages weights 400 + 700 into `assets/fonts/` and appends the exact `@font-face` block to the generated `frame.md`, so Studio, snapshots, and renders resolve them offline without a first-run Google Fonts fetch. Author display at **weight 400** (700 reads as a heavy bold, off-register), and treat italic as the browser-synthesized slant (acceptable for the pull-quote register; add a real italic face only if a project leans hard on it). EB Garamond is a warm old-style serif (low contrast, humanist); if it ever fails, fall to Georgia or another old-style serif — never to a sans. CJK: Noto Serif SC (display) / Noto Sans SC (body) / Noto Sans Mono CJK (code); the sentence-case warmth carries when the serif drops.
- **Syntax colors (teal `#5DB8A6` / amber `#E8A55A` / status) are fixed decoration**, declared in §Colors — they are NOT in the remixable `colors:` block, so a brand remix never repaints them. - **Syntax colors (teal `#5DB8A6` / amber `#E8A55A` / status) are fixed decoration**, declared in §Colors — they are NOT in the remixable `colors:` block, so a brand remix never repaints them.
- **The code itself is the `code-*` registry blocks**, not this preset — this preset owns only the surrounding warm-navy surface + mono chrome. - **The code itself is the `code-*` registry blocks**, not this preset — this preset owns only the surrounding warm-navy surface + mono chrome.
- **9:16 / 1:1 are guidance**; verify the legibility floor and that the cream/tile warmth + one-coral discipline hold per ratio. - **9:16 / 1:1 are guidance**; verify the legibility floor and that the cream/tile warmth + one-coral discipline hold per ratio.
@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) EBGaramond-Italic[wght].ttf: Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs"; import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs"; import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.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 "../../media-use/audio/scripts/lib/bgm.mjs";
// ---------- argv ---------- // ---------- argv ----------
@@ -285,6 +286,11 @@ for (const f of manifest.frames) {
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`, `${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
); );
} }
try {
validateFrameHtml(html, { expectedId: compId, expectedDuration: f.durationSeconds });
} catch (error) {
die(`${label}: ${error.message}`);
}
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations. // pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
const guard = guardFrame(html, label); const guard = guardFrame(html, label);
if (guard.repairedHtml) { if (guard.repairedHtml) {
@@ -359,6 +359,41 @@ if (brandFonts.length) {
summary.push("fonts: no brand fonts — preset fonts kept"); summary.push("fonts: no brand fonts — preset fonts kept");
} }
// ── stage preset-owned offline font faces ────────────────────────────────────
// PR ingestion has no captured brand fonts. Presets that own a type system must
// therefore carry their own licensed files instead of depending on a first-run
// Google Fonts fetch or a renderer-only embedding path that Studio workers cannot see.
const presetFontsDir = join(presetDir, presetName, "fonts");
if (existsSync(presetFontsDir)) {
const fontSpecs = [
["EB Garamond", "EBGaramond", 400],
["EB Garamond", "EBGaramond", 700],
["Inter", "Inter", 400],
["Inter", "Inter", 700],
["JetBrains Mono", "JetBrainsMono", 400],
["JetBrains Mono", "JetBrainsMono", 700],
];
const outDir = join(hyperframesDir, "assets/fonts");
const faces = [];
for (const [family, stem, weight] of fontSpecs) {
const file = `${stem}-${weight}.woff2`;
const source = join(presetFontsDir, file);
if (!existsSync(source)) die(`preset font is missing: ${source}`);
mkdirSync(outDir, { recursive: true });
copyFileSync(source, join(outDir, file));
faces.push(
`@font-face{font-family:"${family}";font-weight:${weight};font-style:normal;font-display:block;src:url("assets/fonts/${file}") format("woff2");}`,
);
}
md +=
`\n\n## Font loading (preset-owned, offline)\n\n` +
`These licensed faces are staged in \`assets/fonts/\`. Paste this block inside every frame template; do not link Google Fonts:\n\n` +
"```html\n<style>\n" +
faces.join("\n") +
"\n</style>\n```\n";
summary.push(`fonts: staged ${fontSpecs.length} preset face(s) for offline preview/render`);
}
// ── cap type weights to the brand font's available faces ────────────────────── // ── cap type weights to the brand font's available faces ──────────────────────
// The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships // The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
// only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight // only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { validateFrameHtml } from "./lib/frame-contract.mjs";
const scriptDir = dirname(new URL(import.meta.url).pathname);
const buildFrameScript = join(scriptDir, "build-frame.mjs");
const assembleScript = join(scriptDir, "assemble-index.mjs");
const transitionsScript = join(scriptDir, "transitions.mjs");
function write(path, contents) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents);
}
function validFrame(id = "01-valid", duration = 3) {
return `<template id="${id}-template">
<div data-composition-id="${id}" data-width="1920" data-height="1080" data-duration="${duration}">
<div class="clip" data-start="0" data-duration="${duration}" data-track-index="0"></div>
<style>#root { color: black; }</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["${id}"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
}
test("valid bare template fragment passes the shared frame contract", () => {
assert.doesNotThrow(() =>
validateFrameHtml(validFrame(), { expectedId: "01-valid", expectedDuration: 3 }),
);
});
test("HTML5 unquoted root attributes are accepted", () => {
assert.deepEqual(
validateFrameHtml(
`<template><div data-composition-id=01-valid data-duration=3></div></template>`,
{ expectedId: "01-valid", expectedDuration: 3 },
),
{ compositionId: "01-valid", duration: 3 },
);
});
test("duration accepts decimal seconds but rejects alternate numeric syntaxes", () => {
assert.doesNotThrow(() =>
validateFrameHtml(
`<template><div data-composition-id="01-valid" data-duration="3.25"></div></template>`,
),
);
for (const duration of ["0x10", "3e2", "Infinity"]) {
assert.throws(
() =>
validateFrameHtml(
`<template><div data-composition-id="01-valid" data-duration="${duration}"></div></template>`,
),
/positive.*duration/i,
);
}
});
test("full HTML is rejected even when it contains a valid inner template", () => {
const html = `<!doctype html><html><head></head><body>${validFrame()}</body></html>`;
assert.throws(
() => validateFrameHtml(html, { expectedId: "01-valid", expectedDuration: 3 }),
/bare <template>|full HTML document/i,
);
});
test("missing or mismatched composition id and duration fail explicitly", () => {
assert.throws(
() =>
validateFrameHtml(`<template><div data-duration="3"></div></template>`, {
expectedId: "01-valid",
expectedDuration: 3,
}),
/composition id/i,
);
assert.throws(
() =>
validateFrameHtml(
`<template><div data-composition-id="wrong" data-duration="3"></div></template>`,
{ expectedId: "01-valid", expectedDuration: 3 },
),
/expected.*01-valid/i,
);
assert.throws(
() =>
validateFrameHtml(
`<template><div data-composition-id="01-valid" data-duration="0"></div></template>`,
{ expectedId: "01-valid", expectedDuration: 3 },
),
/positive.*duration/i,
);
});
test("assembler rejects malformed worker output before writing index.html", () => {
const project = mkdtempSync(join(tmpdir(), "p2v-frame-contract-"));
write(
join(project, "STORYBOARD.md"),
`---\nformat: 1920x1080\n---\n\n## Frame 1 — Broken\n\n- duration: 3s\n- status: animated\n- src: compositions/frames/01-broken.html\n`,
);
write(
join(project, "compositions", "frames", "01-broken.html"),
`<!doctype html><html><body><div data-composition-id="01-broken" data-duration="3"></div></body></html>`,
);
assert.throws(
() =>
execFileSync(
process.execPath,
[assembleScript, "--storyboard", join(project, "STORYBOARD.md"), "--hyperframes", project],
{ encoding: "utf8", stdio: "pipe" },
),
/bare <template>|full HTML document/i,
);
assert.equal(existsSync(join(project, "index.html")), false);
});
test("validated bare frames survive assembly and transition injection", () => {
const project = mkdtempSync(join(tmpdir(), "p2v-frame-transition-"));
write(
join(project, "STORYBOARD.md"),
`---\nformat: 1920x1080\n---\n\n## Frame 1 — First\n\n- duration: 3s\n- transition_in: cut\n- status: animated\n- src: compositions/frames/01-first.html\n\n## Frame 2 — Second\n\n- duration: 3s\n- transition_in: crossfade 0.4s\n- status: animated\n- src: compositions/frames/02-second.html\n`,
);
write(join(project, "compositions", "frames", "01-first.html"), validFrame("01-first", 3));
write(join(project, "compositions", "frames", "02-second.html"), validFrame("02-second", 3));
execFileSync(
process.execPath,
[assembleScript, "--storyboard", join(project, "STORYBOARD.md"), "--hyperframes", project],
{ encoding: "utf8" },
);
execFileSync(
process.execPath,
[
transitionsScript,
"inject",
"--storyboard",
join(project, "STORYBOARD.md"),
"--hyperframes",
project,
],
{ encoding: "utf8" },
);
const verified = execFileSync(
process.execPath,
[
transitionsScript,
"verify",
"--storyboard",
join(project, "STORYBOARD.md"),
"--index",
join(project, "index.html"),
],
{ encoding: "utf8" },
);
assert.match(verified, /1 transition\(s\) verified/);
assert.doesNotThrow(() =>
execFileSync(
process.execPath,
[assembleScript, "--storyboard", join(project, "STORYBOARD.md"), "--hyperframes", project],
{ encoding: "utf8" },
),
);
});
test("Claude preset stages renderer-parity fonts for an empty PR token set", () => {
const project = mkdtempSync(join(tmpdir(), "p2v-claude-fonts-"));
write(join(project, "capture", "extracted", "tokens.json"), '{"colors":[],"fonts":[]}');
execFileSync(
process.execPath,
[buildFrameScript, "--preset", "claude", "--hyperframes", project],
{ encoding: "utf8" },
);
const expected = [
"EBGaramond-400.woff2",
"EBGaramond-700.woff2",
"Inter-400.woff2",
"Inter-700.woff2",
"JetBrainsMono-400.woff2",
"JetBrainsMono-700.woff2",
];
for (const name of expected) {
const path = join(project, "assets", "fonts", name);
assert.equal(existsSync(path), true, `${name} should be staged`);
const magic = readFileSync(path).subarray(0, 4).toString("ascii");
assert.equal(magic, "wOF2", `${name} should be a WOFF2 file`);
}
const frameMd = readFileSync(join(project, "frame.md"), "utf8");
assert.match(frameMd, /@font-face\{font-family:"EB Garamond";font-weight:400/);
assert.match(frameMd, /@font-face\{font-family:"Inter";font-weight:700/);
assert.match(frameMd, /@font-face\{font-family:"JetBrains Mono";font-weight:400/);
assert.doesNotMatch(frameMd, /fonts\.googleapis\.com/);
});
test("bundled Claude font licenses are shipped beside the assets", () => {
const fontDir = resolve(scriptDir, "../../hyperframes-creative/frame-presets/claude/fonts");
for (const family of ["eb-garamond", "inter", "jetbrains-mono"]) {
assert.equal(existsSync(join(fontDir, `OFL-${family}.txt`)), true);
}
});
@@ -0,0 +1,54 @@
function fail(message) {
throw new Error(`frame contract: ${message}`);
}
function attrValue(attrs, name) {
const match = attrs.match(
new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i"),
);
return match ? (match[1] ?? match[2] ?? match[3]) : null;
}
export function validateFrameHtml(html, { expectedId, expectedDuration } = {}) {
const source = String(html ?? "");
const trimmed = source.trim();
if (/<!doctype|<\/?(?:html|head|body)\b/i.test(trimmed)) {
fail("worker output must be one bare <template> fragment, not a full HTML document");
}
const opens = trimmed.match(/<template\b/gi) ?? [];
const closes = trimmed.match(/<\/template\s*>/gi) ?? [];
if (opens.length !== 1 || closes.length !== 1 || !/^<template\b/i.test(trimmed)) {
fail("worker output must contain exactly one bare <template> fragment");
}
if (!/<\/template\s*>$/i.test(trimmed)) {
fail("markup outside the closing </template> is not allowed");
}
const templateOpen = trimmed.match(/^<template\b[^>]*>/i)?.[0];
if (!templateOpen) fail("template opening tag is malformed");
const inner = trimmed.slice(
templateOpen.length,
trimmed.toLowerCase().lastIndexOf("</template>"),
);
const root = inner.match(/^\s*<([A-Za-z][\w:-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/);
if (!root) fail("template must begin with one composition root element");
const attrs = root[2];
const compositionId = attrValue(attrs, "data-composition-id");
if (!compositionId) fail("composition id is missing on the template root");
if (expectedId && compositionId !== expectedId) {
fail(`composition id ${JSON.stringify(compositionId)} does not match expected ${expectedId}`);
}
const durationRaw = attrValue(attrs, "data-duration");
const decimalDuration = /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(durationRaw ?? "");
const duration = Number(durationRaw);
if (!decimalDuration || !Number.isFinite(duration) || duration <= 0) {
fail("root must declare a positive data-duration");
}
// Transition injection may extend an outgoing frame beyond its storyboard duration.
// A shorter root is always invalid; a longer root remains safe to reassemble.
if (Number.isFinite(expectedDuration) && duration < Number(expectedDuration) - 0.001) {
fail(`root duration ${duration}s is shorter than expected ${expectedDuration}s`);
}
return { compositionId, duration };
}
@@ -26,7 +26,7 @@ Each packet provides:
**Retry** — if your context carries lint / validate feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint. **Retry** — if your context carries lint / validate feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint.
**OUTPUT** — one `compositions/frames/<frame_id>.html` per assigned packet, each a self-contained sub-composition. After the last assigned file passes the self-check, stop — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the files and marks their `status`. **OUTPUT** — one `compositions/frames/<frame_id>.html` per assigned packet, each a bare template fragment. The first non-whitespace bytes are `<template`; the last are `</template>`. Never emit `<!doctype>`, `<html>`, `<head>`, `<body>`, or any markup outside that single template. After the last assigned file passes the self-check, stop — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator validates the files before assembly and marks their `status`.
## Mostly invented — you build the visual (except code blocks + the credits avatars) ## Mostly invented — you build the visual (except code blocks + the credits avatars)
@@ -80,7 +80,7 @@ Generic seek-safety + structure live in `hyperframes-core` (read it; not restate
You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them at **Step 6, after assembly** (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against this checklist before finishing — the codes in parens are `hyperframes lint`'s and what the orchestrator may cite back (the rules behind them live in `hyperframes-core`): You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them at **Step 6, after assembly** (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against this checklist before finishing — the codes in parens are `hyperframes lint`'s and what the orchestrator may cite back (the rules behind them live in `hyperframes-core`):
- `missing_template_wrapper` / `missing_composition_id`root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`. - `missing_template_wrapper` / `missing_composition_id`the entire file is exactly one bare `<template>…</template>` fragment (no DOCTYPE/full document); its first element carries `data-composition-id="<frame_id>"` and a positive `data-duration` matching the packet.
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`. - **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
- `subcomposition_root_styled_by_class`**style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors. - `subcomposition_root_styled_by_class`**style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
- **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it. - **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it.
@@ -93,5 +93,5 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here:
- **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%. - **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%.
- **Shot-sequence fidelity** — every Scene in the time-coded sequence is realized as a phase, the blueprint's signature move (unless `compose`) is present and recognizable, and the shot reveals to the voiceover (never front-loaded at `t=0`). - **Shot-sequence fidelity** — every Scene in the time-coded sequence is realized as a phase, the blueprint's signature move (unless `compose`) is present and recognizable, and the shot reveals to the voiceover (never front-loaded at `t=0`).
- **Code-block cadence fits `data-duration`** — for a code beat, the `code-*` block's internal cadence is set so the full block completes within the frame's `data-duration` (a long snippet at the default per-character speed overruns — the code never finishes and the chrome beats never play; see `code-vocabulary.md`). - **Code-block cadence fits `data-duration`** — for a code beat, the `code-*` block's internal cadence is set so the full block completes within the frame's `data-duration` (a long snippet at the default per-character speed overruns — the code never finishes and the chrome beats never play; see `code-vocabulary.md`).
- `font_family_without_font_face` — every font you name has a matching `@font-face` (or `@import`) **inside this file**. **Only use fonts that ship as files** with the project: the families declared in `frame.md` (their `.woff2` live in `assets/fonts/` or `capture/assets/fonts/` — point the `@font-face` `src` at the real file you find there). **Never name a font that has no file**, including system CJK / Japanese / Devanagari families (`Hiragino Sans`, `Yu Gothic`, `Noto Sans CJK`, `Noto Sans Devanagari`, …): the render machine is a clean headless Chrome with none of them installed, so the text silently falls back to a generic font and the typography is wrong in the MP4. For non-Latin or multilingual visible text, either use a shipped font that covers the script, or romanize / transliterate it (e.g. `日本語``Japanese`); if neither is possible it is out of scope for this frame — do not invent a font name. - `font_family_without_font_face` — every font you name has a matching local `@font-face` **inside this template**. Copy the auto-generated block from `frame.md`; the Claude preset's EB Garamond, Inter, and JetBrains Mono faces live in `assets/fonts/`. Never link Google Fonts and never name a family without a project file, including system CJK / Japanese / Devanagari families (`Hiragino Sans`, `Yu Gothic`, `Noto Sans CJK`, `Noto Sans Devanagari`, …): the render machine is clean headless Chrome, so absent faces silently fall back. For non-Latin visible text, use a shipped face that covers the script or romanize/transliterate it; otherwise it is out of scope.
- **Keep-out + no-narration-text** (eyeball, no code) — nothing sits below the 83% cutoff; no narration sentence is rendered as visible text. - **Keep-out + no-narration-text** (eyeball, no code) — nothing sits below the 83% cutoff; no narration sentence is rendered as visible text.