diff --git a/packages/core/scripts/test-hyperframe-runtime-contract.ts b/packages/core/scripts/test-hyperframe-runtime-contract.ts index 699dafb37..d10cf8080 100644 --- a/packages/core/scripts/test-hyperframe-runtime-contract.ts +++ b/packages/core/scripts/test-hyperframe-runtime-contract.ts @@ -27,6 +27,66 @@ for (const snippet of requiredSnippets) { assert(runtimeSource.includes(snippet), `Runtime contract snippet missing: ${snippet}`); } +// Snippet checks cannot prove what `window.__hyperframes` ends up carrying. +// A name can reach the bundle through an unrelated import: `fitTextFontSize.ts` +// imports `prepare`/`layout` from `@chenglou/pretext`, so grepping for +// "pretext" stays green even if the attachment in entry.ts is deleted. Run the +// runtime and read the object back instead. +const { JSDOM } = await import("jsdom"); +const dom = new JSDOM("", { + runScripts: "outside-only", +}); +// The runtime's DOMContentLoaded path calls requestAnimationFrame. jsdom only +// provides it under `pretendToBeVisual`, which also starts a frame loop that +// keeps the process alive, so stub it instead. The stub never fires: we are +// asserting what the runtime attaches at evaluation time, not what it animates. +Object.defineProperty(dom.window, "requestAnimationFrame", { + value: () => 0, + configurable: true, +}); +Object.defineProperty(dom.window, "cancelAnimationFrame", { + value: () => {}, + configurable: true, +}); +dom.window.eval(runtimeSource); + +const hyperframes = (dom.window as unknown as { __hyperframes?: Record }) + .__hyperframes; +assert(hyperframes, "Runtime did not attach window.__hyperframes"); + +// Each of these is an API the agent-facing docs tell composition authors to +// call. If one stops being attached, every composition written against the +// docs throws at render time, which no other check would catch. +const requiredHelpers = ["fitTextFontSize", "getVariables"]; +for (const helper of requiredHelpers) { + assert( + typeof hyperframes[helper] === "function", + `window.__hyperframes.${helper} is not attached`, + ); +} + +const pretext = hyperframes.pretext as Record | undefined; +assert(pretext, "window.__hyperframes.pretext is not attached"); +const requiredPretextFns = [ + "prepare", + "layout", + "prepareWithSegments", + "measureLineStats", + "measureNaturalWidth", +]; +for (const fn of requiredPretextFns) { + assert(typeof pretext[fn] === "function", `window.__hyperframes.pretext.${fn} is not attached`); +} + +// clearCache/setLocale mutate state shared across compositions. Keeping them +// off the surface is a deliberate determinism choice, so assert it holds. +for (const forbidden of ["clearCache", "setLocale"]) { + assert( + !(forbidden in pretext), + `window.__hyperframes.pretext.${forbidden} must not be exposed (mutates shared state)`, + ); +} + const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url))); const manifestPath = resolve(scriptDir, "../dist/hyperframe.manifest.json"); try { diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 5851da238..022709520 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -1,6 +1,7 @@ import { initSandboxRuntimeModular } from "./init"; import { installAuthoredOpacityCapture } from "./colorGrading"; import { fitTextFontSize } from "../text/fitTextFontSize"; +import { pretext } from "../text/pretext"; import { getVariables } from "./getVariables"; type HyperframeWindow = Window & { @@ -8,6 +9,7 @@ type HyperframeWindow = Window & { __hyperframes?: { fitTextFontSize: typeof fitTextFontSize; getVariables: typeof getVariables; + pretext: typeof pretext; }; }; @@ -26,6 +28,7 @@ installAuthoredOpacityCapture(); (window as HyperframeWindow).__hyperframes = { fitTextFontSize, getVariables, + pretext, }; function bootstrapHyperframeRuntime(): void { diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts index 72ec26af2..e64c346fd 100644 --- a/packages/core/src/text/index.ts +++ b/packages/core/src/text/index.ts @@ -1,2 +1,3 @@ export { fitTextFontSize } from "./fitTextFontSize.js"; export type { FitTextOptions, FitTextResult } from "./fitTextFontSize.js"; +export { pretext } from "./pretext.js"; diff --git a/packages/core/src/text/pretext.test.ts b/packages/core/src/text/pretext.test.ts new file mode 100644 index 000000000..64fc03995 --- /dev/null +++ b/packages/core/src/text/pretext.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; + +import { pretext } from "./pretext.js"; + +// Behavior is not asserted here on purpose. `prepare` measures fonts through a +// canvas, which Node does not have, so the real measuring is covered by the +// browser check in `packages/cli` rather than mocked into meaninglessness here. +// What this file guards is the shape of the published surface. +describe("pretext runtime surface", () => { + it("exposes the documented prepare and layout functions", () => { + // The skill file agents are required to read documents + // `window.__hyperframes.pretext.prepare(...)` / `.layout(...)`. If either + // name disappears, every composition written against that doc throws. + expect(typeof pretext.prepare).toBe("function"); + expect(typeof pretext.layout).toBe("function"); + }); + + it("exposes the width helpers the documented shrinkwrap case needs", () => { + // `layout` returns only { lineCount, height }. Width has to come from + // these, so without them "shrinkwrap containers" is not actually doable. + expect(typeof pretext.prepareWithSegments).toBe("function"); + expect(typeof pretext.measureLineStats).toBe("function"); + expect(typeof pretext.measureNaturalWidth).toBe("function"); + }); + + it("does not expose the globally-stateful functions", () => { + // clearCache/setLocale mutate state shared across compositions, which + // would make a render depend on what ran before it. + expect("clearCache" in pretext).toBe(false); + expect("setLocale" in pretext).toBe(false); + }); +}); diff --git a/packages/core/src/text/pretext.ts b/packages/core/src/text/pretext.ts new file mode 100644 index 000000000..655e3de00 --- /dev/null +++ b/packages/core/src/text/pretext.ts @@ -0,0 +1,51 @@ +import { + layout, + measureLineStats, + measureNaturalWidth, + prepare, + prepareWithSegments, +} from "@chenglou/pretext"; + +/** + * The text measurement surface exposed to compositions as + * `window.__hyperframes.pretext`. + * + * Measuring text by writing it into the DOM and reading it back forces a + * reflow, which is both slow per frame and a determinism risk (the value + * depends on when you read it). These measure off a canvas instead, so a + * composition can size a container, pick a font size, or decide a line break + * at any frame without disturbing layout. `fitTextFontSize` is itself built on + * `prepare` + `layout`. + * + * Note the split, because it decides where you call these: `prepare` (and + * `prepareWithSegments`) does the real font measurement and needs a canvas, so + * it only works in a browser, not in Node. Everything downstream of a prepared + * string is arithmetic and is cheap enough to run per frame. + * + * Deliberately omitted from this surface: + * - `clearCache` and `setLocale` mutate process-global state. A composition + * calling either would change how *other* compositions measure, which breaks + * the guarantee that the same file renders the same video every time. + * Withholding `clearCache` does not strand memory: the cache is keyed by + * (segment, font) where segments come from `Intl.Segmenter` at word + * granularity, so it grows with the number of distinct *words* a composition + * renders, not with frames. A counter or typewriter re-measuring every frame + * reuses the same entries. Only genuinely new words allocate, which bounds it + * at a composition's vocabulary. + * - The incremental cursor API (`layoutNextLine`, `walkLineRanges`, and + * friends) has no caller yet. Add it when something needs it. + * + * @see packages/core/src/runtime/entry.ts for where this is attached. + */ +export const pretext = { + /** Measure a string for a CSS font (e.g. `"700 48px Inter"`). */ + prepare, + /** Line count and total height for a prepared string at a given width. */ + layout, + /** Like `prepare`, but retains segments so widths can be measured. */ + prepareWithSegments, + /** Line count plus `maxLineWidth` — the widest rendered line. */ + measureLineStats, + /** Width the string would occupy on a single unwrapped line. */ + measureNaturalWidth, +} as const; diff --git a/skills-manifest.json b/skills-manifest.json index 2f6e534ed..3041dcd80 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 11 }, "hyperframes-core": { - "hash": "e9daaccaed05b8b4", + "hash": "4ca81d4092effc62", "files": 19 }, "hyperframes-creative": { diff --git a/skills/hyperframes-core/references/determinism-rules.md b/skills/hyperframes-core/references/determinism-rules.md index 44aef9835..ced5eb8c4 100644 --- a/skills/hyperframes-core/references/determinism-rules.md +++ b/skills/hyperframes-core/references/determinism-rules.md @@ -56,7 +56,10 @@ Build the visible end-state in static HTML and CSS first, then animate from/to t - Use `position: absolute` for layers and decorative elements, not as the default content-layout strategy. - Prefer transforms and opacity for animation. - Keep text inside its intended container. For dynamic text, use `max-width`, wrapping, or `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })`. -- For text measurement without DOM reflow, use `window.__hyperframes.pretext`: `pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)`. Pure arithmetic, ~0.0002 ms per call — safe for per-frame text reflow, shrinkwrap containers, and computing layout before render. `fitTextFontSize` is built on it. +- For text measurement without DOM reflow, use `window.__hyperframes.pretext`. Measure off a canvas instead of writing into the page and reading it back, so nothing reflows: `pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)` → `{ lineCount, height }`. `prepare` does the font measurement; everything downstream of a prepared string is arithmetic and cheap enough to run per frame. `fitTextFontSize` is built on it. + - `layout` gives you height, not width. To size a container to its text (shrinkwrap), use `pretext.prepareWithSegments(text, font)` and then `pretext.measureNaturalWidth(prepared)` for the single-line width, or `pretext.measureLineStats(prepared, maxWidth)` for `{ lineCount, maxLineWidth }`. + - `font` is a CSS font shorthand string, e.g. `"700 90px Inter"`. + - `clearCache` and `setLocale` are deliberately not exposed: they mutate state shared across compositions, which would make a render depend on what ran before it. - **Do not** use `
` in body text. Forced breaks ignore the actual rendered font width and produce an extra break when the line already wraps naturally, causing overlap. Let text wrap via `max-width`. Exception: short display titles where each word is deliberately on its own line. - **Transformed elements must be block-level + sized.** `transform`/`scaleX`/`scaleY` is a no-op on an inline ``, and scaling an auto-width (0px) element shows nothing → invisible bars/fills. Give them `display: block`/`inline-block`/flex-item **and** a real `width`/`height` (e.g. `width: 100%` inside a sized parent). _(Silent — automated gates may miss it.)_ - **Absolutely-positioned decoratives that pulse or overshoot** (`yoyo` scale, `back.out`) need clearance at their **peak** size and must not straddle an `overflow: hidden` edge — else they overlap a neighbor or get clipped. Position for the largest frame, not the resting one. _(silent.)_