feat(core): add fitTextFontSize utility for pixel-accurate text measurement (#152)

Add @chenglou/pretext dependency and fitTextFontSize() utility that uses
canvas measureText to compute the largest font size that fits text within
a given width. Replaces character-count heuristics with actual font-aware
measurement.

- New fitTextFontSize() in @hyperframes/core/text, exposed on window.__hyperframes
- Generalized for all text elements (captions, titles, etc.), not just captions
- Unit tests (mocked pretext) + browser integration test (real Chromium canvas)
- Updated captions skill docs with usage, exit guarantee, and self-lint patterns

Co-authored-by: James <james.russo@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-30 20:14:13 -07:00
committed by GitHub
co-authored by James Claude Opus 4.6
parent dac304ed9f
commit ef6225da1d
12 changed files with 781 additions and 32 deletions
+66 -10
View File
@@ -205,17 +205,47 @@ Break groups on sentence boundaries (`.` `?` `!`), pauses (>150ms gap), or max w
## Text Overflow Prevention
Captions must never clip off-screen. Apply these rules:
Use `window.__hyperframes.fitTextFontSize()` to measure actual rendered text width and compute the correct font size. This replaces character-count heuristics with pixel-accurate measurement powered by [pretext](https://github.com/chenglou/pretext).
- Set `max-width: 1600px` (landscape) or `max-width: 900px` (portrait) on caption container
- Add `overflow: hidden` as a safety net
- **Auto-scale font size** based on character count:
- ≤18 chars → full size (e.g., 78px)
- 1925 chars → reduce ~15% (e.g., 68px)
- 26+ chars → reduce ~25% (e.g., 58px)
- Reduce `letter-spacing` for long text (switch from `-0.02em` to `-0.04em`)
- Give the caption container an explicit `height` (e.g., `200px`) — don't rely on content sizing with absolute children
- Use `position: absolute` on all caption elements — `position: relative` causes overflow
**Usage in composition scripts:**
```js
GROUPS.forEach(function (group, gi) {
// Measure with text-transform applied (captions typically uppercase)
var result = window.__hyperframes.fitTextFontSize(group.text.toUpperCase(), {
fontFamily: "Outfit",
fontWeight: 900,
maxWidth: 1600,
});
// Apply computed font size to all word spans
wordEls.forEach(function (el) {
el.style.fontSize = result.fontSize + "px";
});
// If result.fits is false, text exceeds minFontSize — overflow: hidden catches it
});
```
**Options:**
| Option | Default | Description |
| -------------- | ---------- | ---------------------------------------------------- |
| `maxWidth` | `1600` | Container width in px (1600 landscape, 900 portrait) |
| `baseFontSize` | `78` | Starting font size — used when text fits |
| `minFontSize` | `42` | Floor — never shrink below this |
| `fontWeight` | `900` | Must match the CSS font-weight |
| `fontFamily` | `"Outfit"` | Must match the CSS font-family |
| `step` | `2` | Decrement step in px per iteration |
**Important:** The `fontWeight` and `fontFamily` options must match the CSS applied to the text elements exactly, or measurements will be inaccurate.
**Safety nets (still required in CSS):**
- `max-width: 1600px` (landscape) or `max-width: 900px` (portrait) on caption container
- `overflow: hidden` as a fallback for `fits: false` edge cases
- `position: absolute` on all caption elements
- Explicit `height` on caption container (e.g., `200px`)
## Caption Exit Guarantee
@@ -239,6 +269,32 @@ tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
The `tl.set` at `group.end` is a deterministic kill — it fires at an exact time, doesn't animate, and can't be overridden by other tweens at different times.
**Self-lint rule:** After building the timeline, verify every caption group has a hard kill. Run this check before registering the timeline:
```js
// Caption lint: verify every group has a hard kill
GROUPS.forEach(function (group, gi) {
var el = document.getElementById("cg-" + gi);
if (!el) return;
tl.seek(group.end + 0.01);
var computed = window.getComputedStyle(el);
if (computed.opacity !== "0" && computed.visibility !== "hidden") {
console.warn(
"[caption-lint] group " +
gi +
" ('" +
group.text +
"') still visible at t=" +
(group.end + 0.01).toFixed(2) +
"s",
);
}
});
tl.seek(0); // reset after lint
```
Place this **before** `window.__timelines[id] = tl` so it runs at composition init. Warnings appear in the browser console during `hyperframes dev`.
## Constraints
- **Deterministic.** No `Math.random()`, no `Date.now()`.