mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(skills): add typography and motion principles, fix validate $& bug (#228)
Add two new skill reference files that address measured LLM composition failures: - fonts.md: Typography principles — banned fonts, guardrails for violations (pairing two sans-serifs, defaulting to 400/700 weight), and guidance the LLM genuinely doesn't apply without being told (register switching, tension as meaning, easing direction as emotion). Includes Google Fonts API discovery script with 7-category multi-strategy query. - motion-principles.md: Motion design principles — guardrails for same-ease and same-speed defaults, y-axis entrance monotony, and guidance for build/breathe/ resolve scene structure, hard cuts as intentional transitions, visual composition rules for video-not-web density. Both files validated against baseline evals: 3 compositions created without guidance confirmed the LLM reaches for banned fonts (Inter, Cormorant Garamond, Playfair Display, Roboto Condensed), uses power2.out on 45-72% of tweens, enters 80%+ of elements from y-axis, and pairs multiple sans-serifs. Also: - Fix validate.ts $& replacement bug (runtime source containing $& caused String.prototype.replace to re-insert the matched <script src=""> tag) - Clean up font loading guidance across skills (compiler embeds automatically) - Update house-style.md to reference fonts.md Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
fc973ee2e8
commit
4c5b8e38a1
@@ -129,4 +129,4 @@ tl.seek(0);
|
||||
- Sync to transcript timestamps.
|
||||
- One group visible at a time.
|
||||
- Every group must have a hard `tl.set` kill at `group.end`.
|
||||
- Check project root for font files before defaulting to Google Fonts.
|
||||
- The compiler embeds supported fonts automatically — just declare `font-family` in CSS.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
style="
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Inter', sans-serif; font-size: 72px; color: #fff;
|
||||
font-family: 'Bricolage Grotesque', sans-serif; font-size: 72px; color: #fff;
|
||||
background: #111;
|
||||
"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Typography
|
||||
|
||||
The compiler embeds supported fonts — just write `font-family` in CSS.
|
||||
|
||||
## Banned
|
||||
|
||||
Inter, Roboto, Open Sans, Noto Sans, Arimo, Lato, Source Sans, PT Sans, Nunito, Poppins, Outfit, Sora, Playfair Display, Cormorant Garamond, Bodoni Moda, EB Garamond, Cinzel, Prata
|
||||
|
||||
## Guardrails
|
||||
|
||||
You know these rules but you violate them. Stop.
|
||||
|
||||
- **Don't pair two sans-serifs.** You do this constantly — one for headlines, one for body. Cross the boundary: serif + sans, or sans + mono.
|
||||
- **One expressive font per scene.** You pick two interesting fonts trying to make it "better." One performs, one recedes.
|
||||
- **Weight contrast must be extreme.** You default to 400 vs 700. Video needs 300 vs 900. The difference must be visible in motion at a glance.
|
||||
- **Video sizes, not web sizes.** Body: 20px minimum. Headlines: 60px+. Data labels: 16px. You will try to use 14px. Don't.
|
||||
|
||||
## What You Don't Do Without Being Told
|
||||
|
||||
- **Tension should mean something.** Don't pattern-match pairings. Ask WHY these two fonts disagree. The pairing should embody the content's contradiction — mechanical vs human, public vs private, institutional vs personal. If you can't articulate the tension, it's arbitrary.
|
||||
- **Register switching.** Assign different fonts to different communicative modes — one voice for statements, another for data, another for attribution. Not hierarchy on a page. Voices in a conversation.
|
||||
- **Tension can live inside a single font.** A font that looks familiar but is secretly strange creates tension with the viewer's expectations, not with another font.
|
||||
- **One variable changed = dramatic contrast.** Same letterforms, monospaced vs proportional. Same family at different optical sizes. Changing only rhythm while everything else stays constant.
|
||||
- **Double personality works.** Two expressive fonts can coexist if they share an attitude (both irreverent, both precise) even when their forms are completely different.
|
||||
- **Time is hierarchy.** The first element to appear is the most important. In video, sequence replaces position.
|
||||
- **Motion is typography.** How a word enters carries as much meaning as the font. A 0.1s slam vs a 2s fade — same font, completely different message.
|
||||
- **Fixed reading time.** 3 seconds on screen = must be readable in 2. Fewer words, larger type.
|
||||
- **Tracking tighter than web.** -0.03em to -0.05em on display sizes. Video encoding compresses letter detail.
|
||||
|
||||
## Finding Fonts
|
||||
|
||||
Don't default to what you know. If the content is luxury, a grotesque sans might create more tension than the expected Didone serif. Decide the register first, then search.
|
||||
|
||||
Save this script to `/tmp/fontquery.py` and run with `curl -s 'https://fonts.google.com/metadata/fonts' > /tmp/gfonts.json && python3 /tmp/fontquery.py /tmp/gfonts.json`:
|
||||
|
||||
```python
|
||||
import json, sys
|
||||
from collections import OrderedDict
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
data = json.load(f)
|
||||
fonts = data.get("familyMetadataList", [])
|
||||
|
||||
ban = {"Inter","Roboto","Open Sans","Noto Sans","Lato","Poppins","Source Sans 3",
|
||||
"PT Sans","Nunito","Outfit","Sora","Playfair Display","Cormorant Garamond",
|
||||
"Bodoni Moda","EB Garamond","Cinzel","Prata","Arimo","Source Sans Pro"}
|
||||
skip_pfx = ("Roboto","Noto ","Google Sans","Bpmf","Playwrite","Anek","BIZ ",
|
||||
"Nanum","Shippori","Sawarabi","Zen ","Kaisei","Kiwi ","Yuji ","Radio ")
|
||||
|
||||
def ok(f):
|
||||
if f["family"] in ban: return False
|
||||
if any(f["family"].startswith(b) for b in skip_pfx): return False
|
||||
if "latin" not in (f.get("subsets") or []): return False
|
||||
return True
|
||||
|
||||
seen = set()
|
||||
R = OrderedDict()
|
||||
|
||||
# Trending Sans — recent (2022+), popular (<300)
|
||||
R["Trending Sans"] = []
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
if f.get("category") in ("Sans Serif","Display") and f.get("dateAdded","") >= "2022-01-01" and f.get("popularity",9999) < 300:
|
||||
R["Trending Sans"].append(f); seen.add(f["family"])
|
||||
|
||||
# Trending Serif — recent (2018+), popular (<600)
|
||||
R["Trending Serif"] = []
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
if f.get("category") == "Serif" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
|
||||
R["Trending Serif"].append(f); seen.add(f["family"])
|
||||
|
||||
# Monospace — recent (2018+), popular (<600)
|
||||
R["Monospace"] = []
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
if f.get("category") == "Monospace" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
|
||||
R["Monospace"].append(f); seen.add(f["family"])
|
||||
|
||||
# Impact & Condensed — curated names + heavy display fonts
|
||||
R["Impact & Condensed"] = []
|
||||
impact = {"Bebas Neue","Archivo Black","Big Shoulders Display","Teko","League Gothic",
|
||||
"Barlow Condensed","Staatliches","Anton","Oswald","Saira","Syne",
|
||||
"Titillium Web","Alumni Sans","Advent Pro"}
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
is_impact = f["family"] in impact
|
||||
is_heavy_display = ("Display" in (f.get("classifications") or [])
|
||||
and any(k in list(f.get("fonts",{}).keys()) for k in ("800","900"))
|
||||
and f.get("popularity",9999) < 400
|
||||
and f.get("category") in ("Sans Serif","Display"))
|
||||
if is_impact or is_heavy_display:
|
||||
R["Impact & Condensed"].append(f); seen.add(f["family"])
|
||||
|
||||
# Bold Geometric Display — curated
|
||||
R["Bold Geometric Display"] = []
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
if f["family"] in {"DM Serif Display","Abril Fatface","Righteous","Orbitron","Black Ops One"}:
|
||||
R["Bold Geometric Display"].append(f); seen.add(f["family"])
|
||||
|
||||
# Script & Handwriting — popular (<300)
|
||||
R["Script & Handwriting"] = []
|
||||
for f in fonts:
|
||||
if not ok(f) or f["family"] in seen: continue
|
||||
if f.get("category") == "Handwriting" and f.get("popularity",9999) < 300:
|
||||
R["Script & Handwriting"].append(f); seen.add(f["family"])
|
||||
|
||||
# Established Classics — good older fonts
|
||||
R["Established Classics"] = []
|
||||
classics = {"Josefin Sans","Raleway","Montserrat","Abel","Exo","Red Hat Display",
|
||||
"Rubik","Alegreya","Arvo","Besley","Crimson Text","Fraunces",
|
||||
"Lora","Merriweather","Vollkorn"}
|
||||
for f in fonts:
|
||||
if f["family"] in classics and f["family"] not in seen:
|
||||
R["Established Classics"].append(f); seen.add(f["family"])
|
||||
|
||||
# Print
|
||||
for cat in R:
|
||||
R[cat].sort(key=lambda x: x.get("popularity",9999))
|
||||
limits = {"Trending Sans":15,"Trending Serif":12,"Monospace":8,
|
||||
"Impact & Condensed":12,"Bold Geometric Display":8,
|
||||
"Script & Handwriting":10,"Established Classics":20}
|
||||
for cat in R:
|
||||
items = R[cat][:limits.get(cat,10)]
|
||||
if not items: continue
|
||||
print(f"--- {cat} ({len(items)}) ---")
|
||||
for ff in items:
|
||||
var = "VAR" if ff.get("axes") else " "
|
||||
print(f' {ff.get("popularity"):4d} | {var} | {ff["family"]}')
|
||||
print()
|
||||
```
|
||||
|
||||
Seven categories: trending sans, trending serif, monospace, impact/condensed, bold geometric, script/handwriting, and established classics. Cross classification boundaries when pairing.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Motion Principles
|
||||
|
||||
## Guardrails
|
||||
|
||||
You know these rules but you violate them. Stop.
|
||||
|
||||
- **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene.
|
||||
- **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately.
|
||||
- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing.
|
||||
- **Don't use the same stagger on every scene.** Each scene needs its own rhythm.
|
||||
- **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful.
|
||||
- **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut.
|
||||
|
||||
## What You Don't Do Without Being Told
|
||||
|
||||
### Easing is emotion, not technique
|
||||
|
||||
The transition is the verb. The easing is the adverb. A slide-in with `expo.out` = confident. With `sine.inOut` = dreamy. With `elastic.out` = playful. Same motion, different meaning. Choose the adverb deliberately.
|
||||
|
||||
**Direction rules — these are not optional:**
|
||||
|
||||
- `.out` for elements entering. Starts fast, decelerates. Feels responsive. This is your default.
|
||||
- `.in` for elements leaving. Starts slow, accelerates away. Throws them off.
|
||||
- `.inOut` for elements moving between positions.
|
||||
|
||||
You get this backwards constantly. Ease-in for entrances feels sluggish. Ease-out for exits feels reluctant.
|
||||
|
||||
### Speed communicates weight
|
||||
|
||||
- Fast (0.15-0.3s) — energy, urgency, confidence
|
||||
- Medium (0.3-0.5s) — professional, most content
|
||||
- Slow (0.5-0.8s) — gravity, luxury, contemplation
|
||||
- Very slow (0.8-2.0s) — cinematic, emotional, atmospheric
|
||||
|
||||
### Scene structure: build / breathe / resolve
|
||||
|
||||
Every scene has three phases. You dump everything in the build and leave nothing for breathe or resolve.
|
||||
|
||||
- **Build (0-30%)** — elements enter, staggered. Don't dump everything at once.
|
||||
- **Breathe (30-70%)** — content visible, alive with ONE ambient motion.
|
||||
- **Resolve (70-100%)** — exit or decisive end. Exits are faster than entrances.
|
||||
|
||||
### Transitions are meaning
|
||||
|
||||
- **Crossfade** = "this continues"
|
||||
- **Hard cut** = "wake up" / disruption
|
||||
- **Slow dissolve** = "drift with me"
|
||||
|
||||
You crossfade everything. Use hard cuts for disruption and register shifts.
|
||||
|
||||
### Choreography is hierarchy
|
||||
|
||||
The element that moves first is perceived as most important. Stagger in order of importance, not DOM order. Don't wait for completion — overlap entries. Total stagger sequence under 500ms regardless of item count.
|
||||
|
||||
### Asymmetry
|
||||
|
||||
Entrances need longer than exits. A card takes 0.4s to appear but 0.25s to disappear.
|
||||
|
||||
## Visual Composition
|
||||
|
||||
You build for the web. Video frames are not pages.
|
||||
|
||||
- **Two focal points minimum per scene.** The eye needs somewhere to travel. Never a single text block floating in empty space.
|
||||
- **Fill the frame.** Hero text: 60-80% of width. You will try to use web-sized elements. Don't.
|
||||
- **Three layers minimum per scene.** Background treatment (glow, oversized faded type, color panel). Foreground content. Accent elements (dividers, labels, data bars).
|
||||
- **Background is not empty.** Radial glows, oversized faded type bleeding off-frame, subtle border panels, hairline rules. Pure solid #000 reads as "nothing loaded."
|
||||
- **Anchor to edges.** Pin content to left/top or right/bottom. Centered-and-floating is a web pattern.
|
||||
- **Split frames.** Data panel on the left, content on the right. Top bar with metadata, full-width below. Zone-based layouts, not centered stacks.
|
||||
- **Use structural elements.** Rules, dividers, border panels. They create paths for the eye and animate well (scaleX from 0).
|
||||
@@ -8,7 +8,7 @@ These cause real bugs if violated.
|
||||
|
||||
**Scene visibility:** Scene 1 visible by default (no `opacity: 0`). Scenes 2+ have `opacity: 0` on the CONTAINER div. GSAP reveals them. No visibility shim (`timedEls`).
|
||||
|
||||
**Iframe compatibility:** No external font links (`<link>` to Google Fonts, `@import`). They block sandboxed iframes. Use system fonts.
|
||||
**Fonts:** Just write the `font-family` you want — the compiler embeds supported fonts automatically via `@font-face` with inline data URIs. No need for `<link>` tags or `@import`. Works in all contexts including sandboxed iframes.
|
||||
|
||||
**Element structure:** No `class="clip"` on scene divs in standalone compositions. Only the root div gets `data-composition-id`/`data-start`/`data-duration`.
|
||||
|
||||
@@ -57,7 +57,7 @@ Read [shader-setup.md](./shader-setup.md) for the full setup code these rules ap
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-family: "YOUR FONT", sans-serif; /* compiler embeds supported fonts automatically */
|
||||
}
|
||||
.scene {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user