/ references / animation-pitfalls.md
animation-pitfalls.md
1 # Animation Pitfalls: HTML Animation Bugs and Rules 2 3 The most common bugs when building animations and how to avoid them. Every rule comes from a real failure case. 4 5 Read this before writing animation code. It saves an entire iteration. 6 7 ## 1. Stacking Context -- `position: relative` is a Default Obligation 8 9 **The bug**: A sentence-wrap element contained 3 bracket-layer children (`position: absolute`). sentence-wrap didn't have `position: relative`, so the absolute brackets used `.canvas` as their coordinate system, floating 200px below the screen. 10 11 **Rule**: 12 - Any container with `position: absolute` children **must** explicitly set `position: relative` 13 - Even if you don't need visual offset, set `position: relative` as the coordinate anchor 14 - If you're writing `.parent { ... }` and a child has `.child { position: absolute }`, instinctively add relative to parent 15 16 **Quick check**: For every `position: absolute`, trace up the ancestor chain and ensure the nearest positioned ancestor is the coordinate system you *intend*. 17 18 ## 2. Character Trap -- Don't Depend on Rare Unicode 19 20 **The bug**: Tried using `␣` (U+2423 OPEN BOX) to visualize "space token". Noto Serif SC / Cormorant Garamond don't have this glyph, rendered as blank/tofu, audience couldn't see it at all. 21 22 **Rule**: 23 - **Every character that appears in animation must exist in your chosen fonts** 24 - Common rare character blacklist: `␣ ␀ ␐ ␋ ↩ ⏎ ⌘ ⌥ ⌃ ⇧ ␦ ␖ ␛` 25 - To represent meta-characters like "space / enter / tab", use **CSS-constructed semantic boxes**: 26 ```html 27 <span class="space-key">Space</span> 28 ``` 29 ```css 30 .space-key { 31 display: inline-flex; 32 padding: 4px 14px; 33 border: 1.5px solid var(--accent); 34 border-radius: 4px; 35 font-family: monospace; 36 font-size: 0.3em; 37 letter-spacing: 0.2em; 38 text-transform: uppercase; 39 } 40 ``` 41 - Emoji also needs verification: some emoji fall back to gray boxes outside Noto Emoji, use `emoji` font-family or SVG 42 43 ## 3. Data-Driven Grid/Flex Templates 44 45 **The bug**: Code had `const N = 6` tokens, but CSS hardcoded `grid-template-columns: 80px repeat(5, 1fr)`. The 6th token had no column, entire matrix misaligned. 46 47 **Rule**: 48 - When count comes from a JS array (`TOKENS.length`), CSS template should also be data-driven 49 - Option A: Inject via CSS variable from JS 50 ```js 51 el.style.setProperty('--cols', N); 52 ``` 53 ```css 54 .grid { grid-template-columns: 80px repeat(var(--cols), 1fr); } 55 ``` 56 - Option B: Use `grid-auto-flow: column` for browser auto-expansion 57 - **Banned: "Fixed number + JS constant" combination**, N changes but CSS doesn't sync 58 59 ## 4. Transition Gaps -- Scene Switches Must Be Continuous 60 61 **The bug**: Between zoom1 (13-19s) and zoom2 (19.2-23s), the main sentence was already hidden. zoom1 fade out (0.6s) + zoom2 fade in (0.6s) + stagger delay (0.2s+) = ~1 second of pure blank. Audience thought the animation froze. 62 63 **Rule**: 64 - When switching scenes continuously, fade out and fade in must **cross-fade**, not "previous fully disappears before next starts" 65 ```js 66 // Bad: 67 if (t >= 19) hideZoom('zoom1'); // 19.0s out 68 if (t >= 19.4) showZoom('zoom2'); // 19.4s in -> 0.4s blank in between 69 70 // Good: 71 if (t >= 18.6) hideZoom('zoom1'); // Start fade out 0.4s early 72 if (t >= 18.6) showZoom('zoom2'); // Simultaneous fade in (cross-fade) 73 ``` 74 - Or use an "anchor element" (like the main sentence) as visual continuity between scenes, briefly reappearing during zoom transitions 75 - Calculate CSS transition durations carefully, avoid triggering the next transition before the previous one finishes 76 77 ## 5. Pure Render Principle -- Animation State Should Be Seekable 78 79 **The bug**: Used `setTimeout` + `fireOnce(key, fn)` chains to trigger animation state. Normal playback was fine, but when doing frame-by-frame recording or seeking to an arbitrary time point, the setTimeouts had already fired and couldn't "go back in time". 80 81 **Rule**: 82 - `render(t)` function should ideally be a **pure function**: given t, output unique DOM state 83 - If side effects are necessary (e.g., class toggle), use a `fired` set with explicit reset: 84 ```js 85 const fired = new Set(); 86 function fireOnce(key, fn) { if (!fired.has(key)) { fired.add(key); fn(); } } 87 function reset() { fired.clear(); /* clear all .show classes */ } 88 ``` 89 - Expose `window.__seek(t)` for Playwright / debugging: 90 ```js 91 window.__seek = (t) => { reset(); render(t); }; 92 ``` 93 - Animation-related setTimeout shouldn't span >1 second, otherwise seek-back will break things 94 95 ## 6. Pre-Font-Load Measurement = Wrong Measurement 96 97 **The bug**: Called `charRect(idx)` to measure bracket positions right at DOMContentLoaded, before fonts loaded. Every character width was the fallback font's width, positions all wrong. Once fonts loaded (~500ms later), bracket `left: Xpx` still had the old values, permanently offset. 98 99 **Rule**: 100 - Any layout code depending on DOM measurement (`getBoundingClientRect`, `offsetWidth`) **must** be wrapped in `document.fonts.ready.then()` 101 ```js 102 document.fonts.ready.then(() => { 103 requestAnimationFrame(() => { 104 buildBrackets(...); // Fonts ready, measurement accurate 105 tick(); // Animation starts 106 }); 107 }); 108 ``` 109 - Extra `requestAnimationFrame` gives the browser a frame to commit layout 110 - If using Google Fonts CDN, `<link rel="preconnect">` speeds up initial load 111 112 ## 7. Recording Prep -- Leave Handshake Points for Video Export 113 114 **The bug**: Playwright `recordVideo` defaults to 25fps, starts recording from context creation. Page load, font load -- the first 2 seconds all get recorded. Delivered video has 2 seconds of blank/white flash at the start. 115 116 **Rule**: 117 - `render-video.js` handles this: warmup navigate -> reload to restart animation -> wait duration -> ffmpeg trim head + convert to H.264 MP4 118 - Animation's **frame 0** should be the final layout's complete initial state (not blank or loading) 119 - Want 60fps? Use ffmpeg `minterpolate` post-processing, don't expect browser source framerate 120 - Want GIF? Two-stage palette (`palettegen` + `paletteuse`), can compress a 30s 1080p animation to 3MB 121 122 See `video-export.md` for complete script usage. 123 124 ## 8. Batch Export -- tmp Directory Must Include PID to Prevent Concurrent Conflicts 125 126 **The bug**: Used `render-video.js` to record 3 HTMLs in parallel. TMP_DIR only used `Date.now()` for naming, 3 processes starting the same millisecond shared the same tmp directory. The first process to finish cleaned up tmp, the other two got `ENOENT` when reading the directory, all crashed. 127 128 **Rule**: 129 - Any temporary directory that multiple processes might share must include **PID or random suffix** in its name: 130 ```js 131 const TMP_DIR = path.join(DIR, '.video-tmp-' + Date.now() + '-' + process.pid); 132 ``` 133 - If you genuinely want multi-file parallelism, use shell `&` + `wait` rather than forking in one node script 134 - For batch recording multiple HTMLs, conservative approach: **serial** execution (2 or fewer can be parallel, 3+ just queue) 135 136 ## 9. Progress Bar / Replay Button in Recording -- Chrome Elements Pollute Video 137 138 **The bug**: Animation HTML had `.progress` progress bar, `.replay` replay button, `.counter` timestamp for human debugging convenience. When recorded to MP4 these elements appeared in the video bottom, like DevTools got screenshotted into the delivery. 139 140 **Rule**: 141 - Human-facing "chrome elements" (progress bar / replay button / footer / masthead / counter / phase labels) and video content must be managed separately 142 - **Convention class name** `.no-record`: any element with this class is automatically hidden by the recording script 143 - Script side (`render-video.js`) injects CSS by default to hide common chrome class names: 144 ``` 145 .progress .counter .phases .replay .masthead .footer .no-record [data-role="chrome"] 146 ``` 147 - Injected via Playwright's `addInitScript` (fires before each navigate, stable across reloads) 148 - To see the original HTML (with chrome), add `--keep-chrome` flag 149 150 ## 10. First Few Seconds of Recording Show Animation Repeating -- Warmup Frame Leak 151 152 **The bug**: Old `render-video.js` flow was `goto -> wait fonts 1.5s -> reload -> wait duration`. Recording starts at context creation, warmup phase already played part of the animation, reload restarts from 0. Result: first few seconds show "animation mid-point + cut + animation starts from 0", strong repetition feel. 153 154 **Rule**: 155 - **Warmup and Record must use independent contexts**: 156 - Warmup context (no `recordVideo` option): Only loads URL, waits for fonts, then closes 157 - Record context (with `recordVideo`): Fresh state, animation starts from t=0 158 - ffmpeg `-ss trim` can only cut Playwright's tiny startup latency (~0.3s), **cannot** be used to mask warmup frames; source must be clean 159 - Recording context close = webm file written to disk, this is Playwright's constraint 160 - Relevant code pattern: 161 ```js 162 // Phase 1: warmup (throwaway) 163 const warmupCtx = await browser.newContext({ viewport }); 164 const warmupPage = await warmupCtx.newPage(); 165 await warmupPage.goto(url, { waitUntil: 'networkidle' }); 166 await warmupPage.waitForTimeout(1200); 167 await warmupCtx.close(); 168 169 // Phase 2: record (fresh) 170 const recordCtx = await browser.newContext({ viewport, recordVideo }); 171 const page = await recordCtx.newPage(); 172 await page.goto(url, { waitUntil: 'networkidle' }); 173 await page.waitForTimeout(DURATION * 1000); 174 await page.close(); 175 await recordCtx.close(); 176 ``` 177 178 ## 11. Don't Draw "Fake Chrome" Inside the Canvas -- Decorative Player UI Clashes with Real Chrome 179 180 **The bug**: Animation used the `Stage` component, which already has a built-in scrubber + timecode + pause button (these are `.no-record` chrome, auto-hidden on export). Then a decorative progress bar `00:60 --- CC-DESIGN / ANATOMY` was drawn at the bottom for "magazine page-number feel". **Result**: User sees two progress bars -- one from Stage controls, one decorative. Complete visual collision, identified as a bug. "Why is there another progress bar in the video?" 181 182 **Rule**: 183 184 - Stage already provides: scrubber + timecode + pause/replay button. **Do not draw** additional progress indicators, current timecodes, copyright strips, chapter counters inside the canvas -- they either clash with chrome or are filler slop (violating the "earn its place" principle). 185 - "Page-number feel" / "magazine feel" / "bottom attribution strip" are **decorative urges** that AI auto-adds frequently. Be alert for each one -- does it convey irreplaceable information, or just fill empty space? 186 - If you firmly believe a bottom strip must exist (e.g., the animation's theme IS about player UI), it must be **narratively necessary** and **visually distinct from Stage scrubber** (different position, different form, different color tone). 187 188 **Element Attribution Test** (every element drawn into the canvas must answer): 189 190 | What it belongs to | Treatment | 191 |------------|------| 192 | A specific scene's narrative content | OK, keep it | 193 | Global chrome (control/debug use) | Add `.no-record` class, hidden on export | 194 | **Neither belongs to any scene, nor is chrome** | **Delete**. This is orphaned filler slop | 195 196 **Self-check (3 seconds before delivery)**: Take a static screenshot and ask yourself: 197 198 - Is there anything that "looks like video player UI" (horizontal progress bar, timecode, control-button appearance)? 199 - If yes, does removing it harm the narrative? If not, delete it. 200 - Is the same type of information (progress/time/attribution) appearing twice? Merge into chrome at one location. 201 202 **Anti-pattern**: Drawing `00:42 --- PROJECT NAME` at the bottom, `CH 03 / 06` chapter counter at bottom-right, version number `v0.3.1` at screen edge -- all are fake chrome filler. 203 204 ## 12. Recording Lead Blank + Recording Start Offset -- `__ready` x tick x lastTick Triple Trap 205 206 **Bug A (Lead blank)**: 60-second animation exported to MP4, first 2-3 seconds are blank page. `ffmpeg --trim=0.3` can't cut it. 207 208 **Bug B (Start offset, real incident)**: Exported 24-second video, user perception: "video doesn't start playing until 19 seconds in". Actually the animation recorded from t=5, recorded to t=24 then looped back to t=0, recorded 5 more seconds -- so the last 5 seconds of the video are the animation's actual beginning. 209 210 **Root cause** (both bugs share one root cause): 211 212 Playwright `recordVideo` starts writing WebM from `newContext()` moment. Babel/React/font loading takes L seconds (2-6s). The recording script waits for `window.__ready = true` as the "animation starts here" anchor -- it must be strictly paired with animation `time = 0`. Two common mistakes: 213 214 | Mistake | Symptom | 215 |------|------| 216 | `__ready` set in `useEffect` or synchronous setup phase (before tick's first frame) | Recording script thinks animation started, but WebM is still recording blank page -> **lead blank** | 217 | tick's `lastTick = performance.now()` initialized at **script top level** | Font loading L seconds get counted into first frame's `dt`, `time` instantly jumps to L -> recording is offset L seconds throughout -> **start offset** | 218 219 **Correct complete starter tick template** (hand-written animations must use this skeleton): 220 221 ```js 222 // --- state --- 223 let time = 0; 224 let playing = false; // Default off, wait for fonts ready to start 225 let lastTick = null; // Sentinel -- tick's first frame forces dt to 0 (don't use performance.now()) 226 const fired = new Set(); 227 228 // --- tick --- 229 function tick(now) { 230 if (lastTick === null) { 231 lastTick = now; 232 window.__ready = true; // Pair: "recording start point" and "animation t=0" same frame 233 render(0); // Render once more to ensure DOM is ready (fonts are ready at this point) 234 requestAnimationFrame(tick); 235 return; 236 } 237 const dt = (now - lastTick) / 1000; // dt only starts advancing after first frame 238 lastTick = now; 239 240 if (playing) { 241 let t = time + dt; 242 if (t >= DURATION) { 243 t = window.__recording ? DURATION - 0.001 : 0; // Don't loop when recording, keep 0.001s to preserve last frame 244 if (!window.__recording) fired.clear(); 245 } 246 time = t; 247 render(time); 248 } 249 requestAnimationFrame(tick); 250 } 251 252 // --- boot --- 253 // Don't immediately rAF at top level -- start only after fonts loaded 254 document.fonts.ready.then(() => { 255 render(0); // Draw initial frame first (fonts ready) 256 playing = true; 257 requestAnimationFrame(tick); // First tick will pair __ready + t=0 258 }); 259 260 // --- seek interface (for render-video defensive correction) --- 261 window.__seek = (t) => { fired.clear(); time = t; lastTick = null; render(t); }; 262 ``` 263 264 **Why this template is correct**: 265 266 | Aspect | Why it must be this way | 267 |------|-------------| 268 | `lastTick = null` + first frame `return` | Prevents the L seconds from "script load to tick first execution" being counted as animation time | 269 | `playing = false` default | During font loading, even if `tick` runs it doesn't advance time, preventing render misalignment | 270 | `__ready` set in tick's first frame | Recording script starts timing at this moment, the corresponding frame is animation's true t=0 | 271 | `document.fonts.ready.then(...)` before starting tick | Avoids font fallback width measurement, prevents first-frame font jump | 272 | `window.__seek` exists | Lets `render-video.js` actively correct -- second line of defense | 273 274 **Recording script's corresponding defense**: 275 1. `addInitScript` injects `window.__recording = true` (before page goto) 276 2. `waitForFunction(() => window.__ready === true)`, records this moment's offset as ffmpeg trim 277 3. **Additionally**: After `__ready`, actively `page.evaluate(() => window.__seek && window.__seek(0))`, force-resetting any time offset in the HTML -- second line of defense against HTMLs that don't strictly follow the starter template 278 279 **Verification method**: After exporting MP4 280 ```bash 281 ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png 282 ffmpeg -i video.mp4 -ss $DURATION-0.1 -vframes 1 frame-end.png 283 ``` 284 First frame must be animation's t=0 initial state (not mid-point, not black), last frame must be animation's final state (not a point from the second loop). 285 286 **Reference implementation**: `assets/animations.jsx`'s Stage component and `scripts/render-video.js` both implement this protocol. Hand-written HTML must use the starter tick template -- every line guards against a specific bug. 287 288 ## 13. No Loop During Recording -- `window.__recording` Signal 289 290 **The bug**: Animation Stage defaults to `loop=true` (convenient for browser viewing). `render-video.js` waits 300ms buffer after recording duration seconds before stopping, this 300ms lets Stage enter the next loop. ffmpeg `-t DURATION` truncates, but the last 0.5-1s falls into the next loop -- video suddenly jumps back to frame 1 (Scene 1), audience thinks the video is broken. 291 292 **Root cause**: No "I'm being recorded" handshake between recording script and HTML. HTML doesn't know it's being recorded, still loops as in browser interaction mode. 293 294 **Rule**: 295 296 1. **Recording script**: Inject `window.__recording = true` in `addInitScript` (before page goto): 297 ```js 298 await recordCtx.addInitScript(() => { window.__recording = true; }); 299 ``` 300 301 2. **Stage component**: Detect this signal, force loop=false: 302 ```js 303 const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop; 304 // ... 305 if (next >= duration) return effectiveLoop ? 0 : duration - 0.001; 306 // ^ keep 0.001 to prevent Sprite end=duration from turning off 307 ``` 308 309 3. **Final Sprite's fadeOut**: In recording scenarios should be `fadeOut={0}`, otherwise the video ending will fade to transparent/dark -- users expect to stop on a clear final frame, not fade out. For hand-written HTML, recommend all final Sprites use `fadeOut={0}`. 310 311 **Reference implementation**: `assets/animations.jsx`'s Stage and `scripts/render-video.js` both have built-in handshake. Hand-written Stage must implement `__recording` detection -- otherwise recording will hit this bug. 312 313 **Verification**: After exporting MP4, `ffmpeg -ss 19.8 -i video.mp4 -frames:v 1 end.png`, check if 0.2s before the end is still the expected final frame, without a sudden switch to another scene. 314 315 ## 14. 60fps Video Uses Frame Duplication by Default -- minterpolate Has Poor Compatibility 316 317 **The bug**: `convert-formats.sh` generated 60fps MP4 using `minterpolate=fps=60:mi_mode=mci...`, which couldn't open in some versions of macOS QuickTime / Safari (black screen or refused to play). VLC / Chrome could open it. 318 319 **Root cause**: minterpolate outputs an H.264 elementary stream containing SEI / SPS fields that some players have trouble parsing. 320 321 **Rule**: 322 323 - Default 60fps uses simple `fps=60` filter (frame duplication), wide compatibility (QuickTime/Safari/Chrome/VLC all work) 324 - High-quality interpolation uses `--minterpolate` flag to explicitly enable -- but **must test locally** on target player before delivery 325 - 60fps label value is **platform algorithm recognition** (Bilibili / YouTube prioritize streaming for 60fps-tagged content), actual perceived smoothness improvement for CSS animations is minimal 326 - Add `-profile:v high -level 4.0` to improve H.264 general compatibility 327 328 **`convert-formats.sh` has switched to compatibility mode by default**. If you need high-quality interpolation: 329 ```bash 330 bash convert-formats.sh input.mp4 --minterpolate 331 ``` 332 333 ## 15. `file://` + External `.jsx` CORS Trap -- Single-File Delivery Must Inline the Engine 334 335 **The bug**: Animation HTML used `<script type="text/babel" src="animations.jsx"></script>` to load the engine externally. Double-click to open locally (`file://` protocol) -> Babel Standalone uses XHR to fetch `.jsx` -> Chrome reports `Cross origin requests are only supported for protocol schemes: http, https, chrome, chrome-extension...` -> entire page is black screen, no `pageerror` reported only console error, easy to misdiagnose as "animation didn't trigger". 336 337 Even starting an HTTP server may not help -- with a global proxy, `localhost` can get proxied too, returning 502 / connection failure. 338 339 **Rule**: 340 341 - **Single-file delivery (double-click to open HTML)** -> `animations.jsx` must be **inlined** into a `<script type="text/babel">...</script>` tag, don't use `src="animations.jsx"` 342 - **Multi-file project (running HTTP server for demo)** -> External loading is fine, but clearly document the `python3 -m http.server 8000` command 343 - Decision criterion: Is the deliverable an "HTML file" or "a project directory with a server"? Former uses inline 344 - Stage component / animations.jsx is often 200+ lines -- pasting into HTML `<script>` block is perfectly acceptable, don't worry about file size 345 346 **Minimum verification**: Double-click your generated HTML, **don't** use any server to open it. If Stage displays the animation's first frame correctly, it passes. 347 348 ## 16. Cross-Scene Inverted Color Context -- Don't Hardcode Colors on In-Canvas Elements 349 350 **The bug**: In a multi-scene animation, `ChapterLabel` / `SceneNumber` / `Watermark` etc. (elements that **appear across all scenes**) had hardcoded `color: '#1A1A1A'` (dark text) in the component. First 4 scenes with light backgrounds were fine, but at scene 5 with a dark background, "05" and the watermark completely disappeared -- no error, no check triggered, critical information invisible. 351 352 **Rule**: 353 354 - **Cross-scene reusable in-canvas elements** (chapter labels / scene numbers / timecodes / watermarks / copyright strips) **must not hardcode color values** 355 - Use one of three approaches instead: 356 1. **`currentColor` inheritance**: Element only sets `color: currentColor`, parent scene container sets `color: calculated-value` 357 2. **invert prop**: Component accepts `<ChapterLabel invert />` to manually switch light/dark 358 3. **Auto-calculate based on background**: `color: contrast-color(var(--scene-bg))` (CSS 4 new API, or JS detection) 359 - Before delivery, use Playwright to capture **a representative frame from each scene**, visually check that cross-scene elements are all visible 360 361 The insidiousness of this bug is that **there are no error reports**. Only human eyes or OCR can catch it. 362 363 ## Quick Self-Check List (5 Seconds Before Starting) 364 365 - [ ] Every `position: absolute`'s parent has `position: relative`? 366 - [ ] Special characters in animation (`␣` `⌘` `emoji`) exist in chosen fonts? 367 - [ ] Grid/Flex template count matches JS data length? 368 - [ ] Scene transitions have cross-fade, no >0.3s pure blank? 369 - [ ] DOM measurement code wrapped in `document.fonts.ready.then()`? 370 - [ ] `render(t)` is pure, or has explicit reset mechanism? 371 - [ ] Frame 0 is a complete initial state, not blank? 372 - [ ] No "fake chrome" decorations in canvas (progress bar/timecode/bottom attribution strip clashing with Stage scrubber)? 373 - [ ] Animation tick's first frame synchronously sets `window.__ready = true`? (Built into animations.jsx; hand-written HTML add yourself) 374 - [ ] Stage detects `window.__recording` to force loop=false? (Hand-written HTML must add) 375 - [ ] Final Sprite's `fadeOut` set to 0 (video end stops on clear frame)? 376 - [ ] 60fps MP4 uses frame duplication mode by default (compatible), only add `--minterpolate` for high-quality interpolation? 377 - [ ] After export, extract frame 0 + last frame to verify they are animation's initial/final states? 378 - [ ] For specific brands (Stripe/Anthropic/etc.): Completed the brand asset protocol (SKILL.md section 1.a five steps)? Have you written `brand-spec.md`? 379 - [ ] Single-file deliverable HTML: `animations.jsx` is inlined, not `src="..."`? (External .jsx under file:// causes CORS black screen) 380 - [ ] Cross-scene elements (chapter labels/watermark/scene numbers) don't have hardcoded colors? Visible against every scene's background?