@amg555/frameforge-mcp 2.4.10 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +22 -4
  2. package/CHANGELOG.md +174 -0
  3. package/CLAUDE.md +192 -9
  4. package/README.md +47 -6
  5. package/output/harness_test_1794418727139/frames/f000000.jpg +1 -0
  6. package/package.json +4 -3
  7. package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
  8. package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
  9. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
  10. package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
  11. package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
  12. package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
  13. package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
  14. package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
  15. package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
  16. package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
  17. package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
  18. package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
  19. package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
  20. package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
  21. package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
  22. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
  23. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
  24. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
  25. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
  26. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
  27. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
  28. package/scripts/deep-validate.js +52 -2
  29. package/src/audio-mixer/mixer.js +594 -63
  30. package/src/cli/doctor.js +36 -2
  31. package/src/cli/make.js +113 -9
  32. package/src/harness/bin/harness.mjs +127 -5
  33. package/src/harness/lib/capture.mjs +215 -192
  34. package/src/harness/lib/verify-quality.mjs +47 -7
  35. package/src/mcp-server/server.js +1015 -59
  36. package/src/skills/sync-upstream.js +290 -0
  37. package/src/utils/media-downloader.js +162 -0
  38. package/src/utils/transcribe.js +341 -0
  39. package/src/video-editor/editor.js +96 -11
  40. package/src/workflows/ingest.js +10 -0
@@ -0,0 +1,609 @@
1
+ # Advanced Animation Patterns — ClaudeAnimationBase + OneTake + Collage B-Roll + Remotion
2
+
3
+ Derived from:
4
+ - `JohnHeibel/ClaudeAnimationBase` — organic 12fps cel boil and character emotions (Opus 5.5 pattern)
5
+ - `feitangyuan/onetake` — continuous single-shot seamless camera choreography with 39-move library
6
+ - `pyang5166/gbro-collage-broll` — editorial paper collage + halftone 2.5D tilt + 3-gate QA
7
+ - `remotion-dev/skills` — React composition, spring physics, GSAP markup patterns
8
+
9
+ ---
10
+
11
+ ## 1. ClaudeAnimationBase — 12fps Organic Cel Boil
12
+
13
+ The ClaudeAnimationBase pattern was engineered specifically for Claude Code + Opus 5.5
14
+ code-driven animation. The core idea: hand-drawn frames never hold still. Apply
15
+ deterministic seeded wobble at exactly 12fps to simulate organic life.
16
+
17
+ ### Iron Rules (ClaudeAnimationBase contract)
18
+
19
+ 1. **Quantize to 12fps**: `const step = Math.floor(t * 12)` — animation updates only on 12fps grid, not every rendered frame.
20
+ 2. **Seeded determinism**: `Math.sin(seed * 19.37 + step * 2.41) * amplitude` — seed is a fixed number per element, never `Math.random()`.
21
+ 3. **3-property boil on all hand-drawn entities**: position boil ± 2px, rotation boil ± 3deg, scale boil 0.97–1.03.
22
+ 4. **Character emotion states**: encode as integer (0=neutral, 1=happy, 2=surprised, 3=concerned, 4=excited) and blend linearly between keyframe poses.
23
+ 5. **Never use CSS transitions** — all motion is pure JS returning computed styles per-frame.
24
+
25
+ ### 12fps Boil Template
26
+
27
+ ```javascript
28
+ // In edit.js — organic 12fps boil on a character/doodle element
29
+ layer('char_body').on((t, lt, dur) => {
30
+ if (lt < 0 || lt > dur) return { opacity: 0, display: 'none' };
31
+
32
+ // Quantize to 12fps grid (ClaudeAnimationBase rule #1)
33
+ const step = Math.floor(lt * 12);
34
+ const seed = 7.3; // fixed per element, never random
35
+
36
+ // Seeded position boil (rule #2)
37
+ const bx = Math.sin(seed * 19.37 + step * 2.41) * 2.4; // ±2.4px X
38
+ const by = Math.cos(seed * 11.29 + step * 1.87) * 1.8; // ±1.8px Y
39
+
40
+ // Scale boil (subtle breathing life, rule #3)
41
+ const sc = 1.0 + Math.sin(seed * 7.11 + step * 3.14) * 0.025;
42
+
43
+ // Rotation boil (organic wobble)
44
+ const rot = Math.sin(seed * 13.71 + step * 0.79) * 1.8; // ±1.8deg
45
+
46
+ // Spring entrance (motion craft rule #1)
47
+ const enter = spring(lt, { tension: 200, friction: 20 });
48
+ const exit = lt > dur - 0.25 ? 1 - (dur - lt) / 0.25 : 0;
49
+ const opacity = enter * (1 - exit);
50
+
51
+ return {
52
+ opacity,
53
+ transform: `translate(${bx}px, ${by}px) scale(${sc * (0.88 + 0.12 * enter)}) rotate(${rot}deg)`,
54
+ };
55
+ });
56
+ ```
57
+
58
+ ### Character Emotion Blending
59
+
60
+ ```javascript
61
+ // Emotion state: 0=neutral, 1=happy, 2=surprised, 3=concerned
62
+ // Encoded as a fractional value for smooth cross-blend
63
+ const emotion = 0; // set per scene
64
+
65
+ // Eyebrow positions per emotion [neutral, happy, surprised, concerned]
66
+ const browY = [0, -4, -10, 2];
67
+ const mouthCurve = [0, 8, 4, -5]; // positive = smile, negative = frown
68
+
69
+ const eyebrowShift = browY[emotion] || 0;
70
+ const mouthShift = mouthCurve[emotion] || 0;
71
+ ```
72
+
73
+ ---
74
+
75
+ ## 2. OneTake Continuous Camera Choreography
76
+
77
+ The OneTake pattern eliminates every hard cut. The entire video lives in one
78
+ continuous 2.5D coordinate space. The camera moves through it via spring-physics
79
+ spline rather than mounting/unmounting DOM elements.
80
+
81
+ ### Spatial Canvas Setup
82
+
83
+ ```javascript
84
+ // The whole video is ONE scene positioned on a 2.5D plane.
85
+ // Each narrative beat is a region of this canvas, not a separate HTML page.
86
+
87
+ // Scene regions (scene index → canvas position in pixels)
88
+ const SCENES = [
89
+ { x: 0, y: 0, zoom: 1.00, start: 0 }, // Hook
90
+ { x: 1920, y: 0, zoom: 1.15, start: 12 }, // Mechanism
91
+ { x: 1920, y: 400, zoom: 1.80, start: 25 }, // Zoom Detail
92
+ { x: 960, y: 200, zoom: 0.85, start: 38 }, // Wide Resolution
93
+ ];
94
+
95
+ // Camera spring navigation between scenes
96
+ layer('onetake_camera').on((t, lt, dur) => {
97
+ // Find current and next scene based on t
98
+ let curr = SCENES[SCENES.length - 1];
99
+ let next = curr;
100
+ for (let i = 0; i < SCENES.length - 1; i++) {
101
+ if (t >= SCENES[i].start && t < SCENES[i + 1].start) {
102
+ curr = SCENES[i];
103
+ next = SCENES[i + 1];
104
+ break;
105
+ }
106
+ }
107
+
108
+ // Spring travel from curr to next
109
+ const travelStart = curr.start;
110
+ const travelDur = next.start - curr.start;
111
+ const travelT = Math.max(0, t - travelStart);
112
+ const s = spring(travelT, { tension: 140, friction: 14 });
113
+
114
+ const cx = curr.x + (next.x - curr.x) * s;
115
+ const cy = curr.y + (next.y - curr.y) * s;
116
+ const cz = curr.zoom + (next.zoom - curr.zoom) * s;
117
+
118
+ // Depth-parallax background (z=0.2 moves at 20% of camera speed)
119
+ // Apply via CSS transform to #stage element
120
+ return {
121
+ position: 'absolute', inset: '0',
122
+ transform: `translate(${-cx}px, ${-cy}px) scale(${cz})`,
123
+ transformOrigin: '50% 50%',
124
+ };
125
+ });
126
+ ```
127
+
128
+ ### Zero-Cut Scene Handshake
129
+
130
+ When the camera reaches Scene N+1, Scene N's primary subject shrinks and slides
131
+ to a supporting position rather than disappearing:
132
+
133
+ ```javascript
134
+ // Outgoing subject: compress to supporting node
135
+ layer('scene1_subject_exit').on((t, lt, dur) => {
136
+ if (t < SCENES[1].start - 1.0 || t > SCENES[1].start + 0.5) return { display: 'none' };
137
+ const exitP = Math.min(1, (t - (SCENES[1].start - 1.0)) / 1.0);
138
+ const s = spring(exitP, { tension: 200, friction: 22 });
139
+ return {
140
+ transform: `translateX(${-580 * s}px) scale(${1 - 0.35 * s})`,
141
+ opacity: 1 - s * 0.4,
142
+ };
143
+ });
144
+ // SFX at peak camera velocity (OneTake rule #3)
145
+ cue('whoosh', SCENES[1].start - 0.04, 0.7);
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 3. Editorial Paper Collage & Halftone (gbro-collage-broll)
151
+
152
+ ### 12fps Stop-Motion Drop Entrance
153
+
154
+ ```javascript
155
+ // Collage card entrance — hand-placed, 12fps stepped, not smooth
156
+ layer('collage_card').on((t, lt, dur) => {
157
+ if (lt < 0 || lt > dur) return { opacity: 0, display: 'none' };
158
+
159
+ // 12fps step grid
160
+ const step = Math.floor(lt * 12);
161
+ const dropProgress = Math.min(1, step / 6); // settles in 0.5s (6 frames @ 12fps)
162
+
163
+ const seed = 3.7; // fixed per card
164
+ const scale = 1.15 - 0.15 * dropProgress; // overshoots then settles
165
+ const rot = -4 + Math.sin(seed * 11) * 3; // fixed tilt angle
166
+
167
+ return {
168
+ opacity: dropProgress > 0 ? 1 : 0,
169
+ transform: `scale(${scale}) rotate(${rot}deg) translateY(${(1 - dropProgress) * -60}px)`,
170
+ filter: 'drop-shadow(4px 6px 0px rgba(0,0,0,0.35))',
171
+ };
172
+ });
173
+ ```
174
+
175
+ ### Halftone Overlay (CSS background)
176
+
177
+ ```javascript
178
+ // Halftone texture overlay — applied on Layer 2 (environment/context)
179
+ layer('halftone_env').on((t, lt, dur) => {
180
+ if (lt < 0 || lt > dur) return { opacity: 0, display: 'none' };
181
+ return {
182
+ position: 'absolute', inset: '0',
183
+ backgroundImage: `radial-gradient(circle, #000 1.5px, transparent 1.5px)`,
184
+ backgroundSize: '12px 12px',
185
+ opacity: 0.08,
186
+ mixBlendMode: 'multiply',
187
+ pointerEvents: 'none',
188
+ };
189
+ });
190
+ ```
191
+
192
+ ### 2.5D Perspective Tilt
193
+
194
+ ```javascript
195
+ // Apply to the whole collage card container
196
+ layer('collage_container').on((t, lt, dur) => {
197
+ const tiltX = 15 * Math.sin(lt * 0.3); // gentle breathing tilt
198
+ const tiltY = -10 * Math.cos(lt * 0.2);
199
+ return {
200
+ transform: `perspective(800px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
201
+ transformStyle: 'preserve-3d',
202
+ };
203
+ });
204
+ ```
205
+
206
+ ---
207
+
208
+ ## 4. Reanimate Closed-Loop Pattern (reanimate.niazmorshed.dev)
209
+
210
+ The reanimate pattern is the Opus 5.5 correction loop for existing animations.
211
+
212
+ ```
213
+ Phase 1 — Record: Playwright records every moving element at fixed fps
214
+ Phase 2 — Tabulate: start, duration, distance, easing curve per element
215
+ Phase 3 — Rebuild: Agent rewrites edit.js from the tabulation (not from scratch)
216
+ Phase 4 — Re-record: Same Playwright setup, same seed
217
+ Phase 5 — Diff: Pixel diff + timing diff. Target: 0px, ≤17ms per element
218
+
219
+ Pass/fail thresholds:
220
+ pixel_diff_max: 0 (deterministic rendering must be identical)
221
+ timing_diff_ms: ≤17 (one frame at 60fps)
222
+ easing_diff_score: ≤0.05 (normalized spring curve comparison)
223
+ ```
224
+
225
+ In FrameForge this maps to:
226
+ ```
227
+ render_animation({ mode: "golden", goldenSave: true })
228
+ // then fix edit.js, then:
229
+ render_animation({ mode: "golden", goldenCheck: true })
230
+ // must return: all hashes match
231
+ ```
232
+
233
+ ---
234
+
235
+ ## 5. OneTake — 39-Move Library and Rhythm+Carry Oracle
236
+
237
+ The OneTake system (from `feitangyuan/onetake`) is built around one constraint: **six rejected films, six acceptance criteria**. Every feature came from a specific rejection.
238
+
239
+ ### Why It Exists (Each Rule Cost a Cut)
240
+
241
+ | Rule | Origin |
242
+ |------|--------|
243
+ | Vary shot length by ≥4× | Uniform cadence = "PPT 式" (slideshow feel) — failed twice |
244
+ | Something must survive every section boundary | "Beats that replace each other" = Pocket Weather Club v1, v2 both rejected (carry score 0.00, 0.40) |
245
+ | Camera must chase the subject | One Dot film: "就缺灵动的跟踪运镜" — still camera rejected |
246
+ | Every beat is the collision that starts the next | "knockon" — causal carry |
247
+ | Small elements, big ground | "70% white" genre; full-bleed is the exception |
248
+ | The cause must be visible | Hand drawn from recorded path, or concept's own subject |
249
+
250
+ ### The Rhythm + Carry Oracle (verify_promo.py)
251
+
252
+ Before showing any render, run the oracle:
253
+
254
+ ```bash
255
+ python3 scripts/verify_promo.py draft.mp4 --comp comp.html --ref ref.mp4
256
+ ```
257
+
258
+ **What it checks**:
259
+
260
+ 1. **Rhythm legs**: fail uniform shot lengths, no dead-still stretch, clipped or never-quiet audio
261
+ 2. **Continuity** (the critical leg): fail beats that replace each other (carry score < 0.5). This is the ONLY leg that failed both rejected Pocket Weather cuts (v1 0.00, v2 0.40) while accepted films passed (v3 0.75)
262
+ 3. **Curves**: fail fast moves (> 80 px/frame) in a film without shutter blur
263
+ 4. **Framing**: fail any frame where a declared subject leaves the frame (or is cut off)
264
+
265
+ **Carry score thresholds**:
266
+ - Score < 0.5 → FAIL (the film is a slideshow)
267
+ - Score 0.5–0.75 → WARNING (acceptable if rhythm is strong)
268
+ - Score > 0.75 → PASS
269
+
270
+ ### The OM Motion Library (engine.js `OM` object)
271
+
272
+ **All moves are pure CSS: `(t, opts) → { transform, opacity, clipPath }`.
273
+ Feed `t` via `seg(currentTime, start, end)` to get normalised 0→1 progress.**
274
+
275
+ Never re-derive springs or eases inline. Use `OM.*` — they encode the correct
276
+ physics and carry the carry-score contract (exit velocity ≥ 0.5× peak velocity).
277
+
278
+ ```javascript
279
+ import { OM, seg } from './reel/engine.js';
280
+
281
+ // Usage pattern: seg() gives normalised t for a time window
282
+ layer('hero').on((t) => {
283
+ const p = seg(t, 0.5, 1.5); // 0→1 over 0.5–1.5 seconds
284
+ Object.assign(heroEl.style, OM.spring(p));
285
+ });
286
+ ```
287
+
288
+ **Entrances** (element appearing, enter → stage):
289
+ ```javascript
290
+ OM.spring(t, { scale0: 0.6, scale1: 1.0, opacity0: 0, opacity1: 1 })
291
+ // → { transform: 'scale(…)', opacity: … }
292
+ // Damped spring overshoot. Default tension:180, friction:18. Standard arrival.
293
+
294
+ OM.rise(t, { dist: 60, opacity0: 0 })
295
+ // → { transform: 'translateY(…)', opacity: … }
296
+ // Translates up from dist px below while fading in.
297
+
298
+ OM.iris(t, { cx: '50%', cy: '50%' })
299
+ // → { clipPath: 'circle(…% at cx cy)' }
300
+ // Radial clip-path expands from centre. 141% covers full diagonal.
301
+
302
+ OM.morphRect(t, { axis: 'y' })
303
+ // → { clipPath: 'inset(…% 0 …% 0)' }
304
+ // Clip-path from narrow rect to full frame. axis: 'x' or 'y'.
305
+
306
+ OM.silk(t, { direction: 'left' })
307
+ // → { clipPath: 'inset(0 …% 0 0)' }
308
+ // Curtain-pull clip from the specified edge.
309
+
310
+ OM.zoomThrough(t, { from: 0.8, through: 1.15, to: 1.0 })
311
+ // → { transform: 'scale(…)' }
312
+ // Scale through 1× — portal / push-through effect. Peak at t=0.5.
313
+
314
+ OM.scalePop(t)
315
+ // → { transform: 'scale(…)', opacity: … }
316
+ // Title scales from 1.6× down to 1× with expo ease (HyperFrames title pattern).
317
+
318
+ OM.wordByWord(t, { wordIndex: 0, totalWords: 1, delay: 0.08 })
319
+ // → { transform: 'translateY(…)', opacity: … }
320
+ // Per-word spring stagger. Call with different wordIndex for each word.
321
+
322
+ OM.revealWipe(t, { direction: 'left' })
323
+ // → { clipPath: 'inset(0 …% 0 0)' }
324
+ // Horizontal clip wipe — title text reveal.
325
+
326
+ OM.typewriter(t, { chars: 20 })
327
+ // → { '--tw-chars': revealed }
328
+ // Returns CSS custom property for character count (use in CSS clip on ::before).
329
+
330
+ OM.counterFlip(t, { from: 0, to: 100, height: 60 })
331
+ // → { transform: 'translateY(…)' }
332
+ // Odometer-style numeric reveal via vertical translate.
333
+ ```
334
+
335
+ **Carries** (element surviving a scene boundary — carry score ≥ 0.5 required):
336
+ ```javascript
337
+ OM.irisClose(t, { cx: '50%', cy: '50%' })
338
+ // → { clipPath: 'circle(…% at cx cy)' }
339
+ // Reverse of iris — stage contracts to a point.
340
+
341
+ OM.belt(t, { strips: 6, direction: 'left' })
342
+ // → { '--belt-strips': 6, '--belt-p': t, transform: … }
343
+ // Horizontal strip sequence reveal (film-strip / ticker).
344
+
345
+ OM.ripple(t, { cx: '50%', cy: '50%', opacity0: 0.6 })
346
+ // → { clipPath: 'circle(…% at cx cy)', opacity: … }
347
+ // Expanding + fading radial wave. Used for carry momentum.
348
+ ```
349
+
350
+ **Camera** (keyed, operated feel):
351
+ ```javascript
352
+ OM.camera(t, {
353
+ x0: 0, y0: 0, x1: 20, y1: -10, // pan range in px
354
+ scale0: 1.0, scale1: 1.08, // zoom range
355
+ seed: 'cam', // deterministic noise seed
356
+ shakeAmp: 2, // micro-shake amplitude (px)
357
+ shakeFreq: 1.5, // shake frequency (cycles/sec)
358
+ })
359
+ // → { transform: 'translate(…px, …px) scale(…)' }
360
+ // Slow Ken Burns push + seeded noise micro-shake.
361
+
362
+ OM.handheld(t, { amp: 6, freq: 8, seed: 'hh' })
363
+ // → { transform: 'translate(…px, …px) rotate(…deg)' }
364
+ // Vérité/documentary shake. 3-axis (x, y, z rotate).
365
+ ```
366
+
367
+ **Exits** (faster than entrances — OneTake rule):
368
+ ```javascript
369
+ OM.popOut(t, { scale1: 1.0, scale2: 1.4 })
370
+ // → { transform: 'scale(…)', opacity: … } Uses easeIn.
371
+
372
+ OM.drop(t, { dist: 120 })
373
+ // → { transform: 'translateY(…)', opacity: … } Gravity ease-in.
374
+
375
+ OM.implode(t, { cx: '50%', cy: '50%' })
376
+ // → { clipPath: 'circle(…%)', opacity: … } Iris collapse.
377
+
378
+ OM.slideExit(t, { direction: 'left', dist: 110 })
379
+ // → { transform: 'translateX(…%)' } Off-canvas slide.
380
+ ```
381
+
382
+ **Ambient / Atmospheric** (idle states, breathing room):
383
+ ```javascript
384
+ OM.breathe(t, { amp: 0.015, freq: 0.4 }) // subtle scale oscillation
385
+ OM.float(t, { amp: 8, freq: 0.3 }) // sinusoidal vertical drift
386
+ OM.pulseOpacity(t, { lo: 0.7, hi: 1.0, freq: 1.0 }) // rhythmic emphasis
387
+ OM.odometer(t, { from: 0, to: 9, height: 60 }) // digit column roll
388
+ ```
389
+
390
+ **Carry score** (rhythm oracle — check at t=0.8–1.0):
391
+ ```javascript
392
+ const score = OM.carryScore(1.0); // must be ≥ 0.5 to PASS
393
+ // carryScore < 0.5 → the film is a slideshow (FAIL)
394
+ // carryScore 0.5–0.75 → acceptable if rhythm is strong (WARNING)
395
+ // carryScore > 0.75 → PASS
396
+ ```
397
+
398
+ ### Sound: A Palette in a Room, Never One Beep Per Event
399
+
400
+ OneTake requires **5 materials in one room** — not a raw sine pop on every event (which was "低劣/cheap" within a minute of review):
401
+
402
+ ```python
403
+ # sfx_palette.py — 5 synthesised materials in one shared reverb
404
+ # Each material is a physical model of the medium
405
+
406
+ MATERIALS = {
407
+ 'air': { freq: (800, 4000), attack: 0.005, decay: 0.08, model: 'filtered_noise' },
408
+ 'wood': { freq: (200, 800), attack: 0.002, decay: 0.15, model: 'modal_resonator' },
409
+ 'glass': { freq: (1000, 6000), attack: 0.001, decay: 0.35, model: 'modal_resonator' },
410
+ 'sub': { freq: (40, 120), attack: 0.008, decay: 0.25, model: 'sine_decay' },
411
+ 'bubble': { freq: (400, 1200), attack: 0.003, decay: 0.12, model: 'rising_sine' },
412
+ }
413
+
414
+ ROOM = {
415
+ 'ir': 'voxengo_small_room.wav', # convolution reverb IR
416
+ 'pre_delay': 8, # ms
417
+ 'wet': 0.18, # 18% wet
418
+ }
419
+
420
+ # All peaks at -8 dBFS so music can sit on top
421
+ # Master to -16 LUFS with ceiling -3.5 to -4 dBTP
422
+ ```
423
+
424
+ **Sound events from the composition** — not guessed, read from `window.__events()`:
425
+ ```javascript
426
+ // In your composition's f(t) function:
427
+ window.__events = () => [
428
+ { t: 0.8, material: 'wood', intensity: 0.7, pan: -0.2 },
429
+ { t: 1.5, material: 'air', intensity: 0.4, pan: 0.1 },
430
+ { t: 2.1, material: 'glass', intensity: 0.9, pan: 0.0 },
431
+ ];
432
+ ```
433
+
434
+ ### Hard Rules (Each One Cost a Cut)
435
+
436
+ ```
437
+ ❌ Never default to the template's story
438
+ ❌ Never carry the last film's look into the next
439
+ ❌ Never cut on a metronome (equal shot lengths = slides)
440
+ ❌ Never replace a beat — carry it (something must survive every section boundary)
441
+ ❌ Never show a page as a thumbnail expecting its motion to read
442
+ ❌ Never let the frame be always in motion (rests make bursts land; reference is still 34% of the time)
443
+ ❌ Small elements, big ground (genre frame is 70% white/negative space)
444
+ ❌ The cause must be visible (the hand, or the concept's own subject)
445
+ ❌ Moves from the library, curves chosen by t80
446
+ ❌ Fast moves (> 80 px/frame) need the shutter (real motion blur)
447
+ ❌ No particles/confetti/speck bursts (read as a template)
448
+ ❌ One material set for sound, one room
449
+ ❌ Everything deterministic: __seek(t) twice gives the same frame
450
+ ```
451
+
452
+ ---
453
+
454
+ ## 6. Remotion — React Composition Patterns
455
+
456
+ From `remotion-dev/skills` (first-party skills, v4.0.543+).
457
+
458
+ ### The Golden Rule: `useCurrentFrame()` as Deep as Possible
459
+
460
+ ```tsx
461
+ // ✅ Correct — useCurrentFrame in the smallest component that needs it
462
+ const FadeIn = () => {
463
+ const frame = useCurrentFrame();
464
+ const {fps} = useVideoConfig();
465
+ return (
466
+ <div style={{
467
+ opacity: interpolate(frame, [0, 2 * fps], [0, 1], {
468
+ extrapolateRight: 'clamp',
469
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
470
+ }),
471
+ }}>
472
+ Content
473
+ </div>
474
+ );
475
+ };
476
+
477
+ // ❌ Wrong — subscribes the whole composition on every frame
478
+ const CompositionWrong = () => {
479
+ const frame = useCurrentFrame(); // re-renders everything
480
+ return <div><FadeIn /><OtherStuff /></div>;
481
+ };
482
+ ```
483
+
484
+ ### CSS Transform Shorthands (Not `transform`)
485
+
486
+ ```tsx
487
+ // ✅ Use scale, translate, rotate — Studio can edit these
488
+ style={{
489
+ scale: interpolate(frame, [0, 100], [0, 1], {
490
+ extrapolateLeft: 'clamp',
491
+ extrapolateRight: 'clamp',
492
+ easing: Easing.spring({ damping: 200 }),
493
+ output: 'perceptual-scale', // ALWAYS for scale animations
494
+ }),
495
+ translate: interpolate(frame, [0, 100], ['0px 0px', '100px 100px']),
496
+ }}
497
+
498
+ // ❌ Wrong — transform strings break Studio editing
499
+ const scale = interpolate(frame, [0, 100], [0, 1]);
500
+ style={{ transform: `scale(${scale})` }}
501
+ ```
502
+
503
+ ### Remotion Spring vs FrameForge Spring
504
+
505
+ ```tsx
506
+ // Remotion (React)
507
+ import { Easing } from 'remotion';
508
+ Easing.spring({ damping: 200 }) // damping: high = less bounce
509
+
510
+ // FrameForge (HTML compositor)
511
+ spring(lt, { tension: 200, friction: 20 }) // friction: high = less bounce
512
+
513
+ // Equivalent presets:
514
+ // Remotion damping 200 ≈ FrameForge { tension: 200, friction: 28 } (premium)
515
+ // Remotion damping 100 ≈ FrameForge { tension: 180, friction: 16 } (playful)
516
+ // Remotion damping 50 ≈ FrameForge { tension: 200, friction: 10 } (cartoon)
517
+ ```
518
+
519
+ ### Available Built-in Effects (Remotion @remotion/effects)
520
+
521
+ Color and tone:
522
+ `brightness`, `colorCorrection`, `curves`, `duotone`, `exposure`, `grayscale`, `hue`, `invert`, `levels`, `lut` (3D .cube), `saturation`, `shadowsHighlights`, `tint`, `vibrance`, `whiteBalance`
523
+
524
+ Blur and trails:
525
+ `blur`, `lightTrail`, `linearProgressiveBlur`, `radialProgressiveBlur`, `regionBlur`, `zoomBlur`
526
+
527
+ Lighting:
528
+ `dropShadow`, `glow`, `lightLeak`, `longShadows`, `shine`, `starburst`, `vignette`
529
+
530
+ Distortion:
531
+ `barrelDistortion`, `chromaticAberration`, `cornerPin`, `fisheye`, `noiseDisplacement`, `skew`, `wave`
532
+
533
+ Textures:
534
+ `burlap`, `checkerboard`, `contourLines`, `dotGrid`, `emboss`, `flannel`, `halftone`, `lines`, `liquidContours`, `noise`, `paper`, `scanlines`, `speckle`, `tvSignalOff`, `waves`, `whiteNoise`, `zigzag`
535
+
536
+ Edges: `outline`, `roughenEdges`, `shrinkwrap`, `tear`
537
+
538
+ **Apply to** `<Video>`, `<CanvasImage>`, `<AnimatedImage>`, `<Solid>`, `<HtmlInCanvas>`, `@remotion/shapes`, `@remotion/rive`. For plain HTML elements, wrap in `<HtmlInCanvas>`.
539
+
540
+ ---
541
+
542
+ ## 7. GBro Collage B-Roll — 3-Gate Approval Workflow
543
+
544
+ The gbro-collage-broll system enforces mandatory human approval at three gates before any video generation.
545
+
546
+ ### Why 3 Gates?
547
+ 1. Prevents wrong metaphors from consuming video generation credits
548
+ 2. Forces the agent to think visually before coding
549
+ 3. Each gate narrows scope so errors are caught cheaply
550
+
551
+ ### Gate 1: Visual Metaphor Only (No Images, No Video)
552
+ ```
553
+ Input: ~5-second voiceover script (one sentence / one idea)
554
+ Output:
555
+ - Core meaning (what the viewer must understand)
556
+ - Emotion (calm, surprise, urgency, insight, absurd, ironic)
557
+ - One-sentence visual proposition
558
+ - 3–6 key objects
559
+ - Suggested background colour + accent colours
560
+ - Expected assembly order
561
+
562
+ STOP — wait for user: "可以" / "通过" / "pass" / corrections
563
+ ```
564
+
565
+ ### Gate 2: Static Frame Only (No Video)
566
+ ```
567
+ After Gate 1 approval:
568
+ - Write visual-spec.json (self-contained)
569
+ - Generate static frame (imagegen)
570
+ - QA: is metaphor clear? no text/logos/watermarks? 3-6 large groups? enough negative space?
571
+
572
+ STOP — wait for user confirmation of the still image
573
+ ```
574
+
575
+ ### Gate 3: Video Generation (Default: Gemini Omni Flash)
576
+ ```
577
+ After Gate 2 approval:
578
+ - Prepare first frame (solid colour background = same colour as still)
579
+ - Prepare last frame (confirmed Gate 2 still)
580
+ - Write Omni prompt: "assemble piece by piece, start-frame-empty → end-frame-complete"
581
+ - Generate 5s 9:16 720×1280 24fps silent MP4
582
+
583
+ QA after generation:
584
+ - First frame: near-empty colour field
585
+ - Middle: gradual assembly visible, not overall fade-in
586
+ - No cuts, no zoom, no 3D
587
+ - No text/logos/watermarks
588
+ - Last frame matches Gate 2 still
589
+ ```
590
+
591
+ ### Visual Standards
592
+ ```
593
+ ✓ Strong, flat, uniform colour field background
594
+ ✓ Main subjects as black-and-white halftone photographic cut-outs
595
+ ✓ Key cards/buttons/rules in coloured cardstock (red, yellow, cyan, orange, purple, cream)
596
+ ✓ All paper pieces have crisp cut edges, cream keyline, soft drop shadow, paper grain
597
+ ✓ Motion is assemble-from-empty (not drift, sway, or slow zoom)
598
+ ✗ No subtitles, no full voiceover text, no logos, no watermarks, no UI
599
+ ```
600
+
601
+ ### Colour-to-Emotion Mapping
602
+ ```
603
+ Burnt orange / red → time consumption, labour, urgency
604
+ Mustard yellow → tools, warning, experience loss
605
+ Forest green → cognition, aesthetics, system reset
606
+ Deep purple → norms, sedimentation, long-term memory
607
+ Cyan green → judgment, collaboration, automated execution
608
+ Cobalt blue → (avoid as default — too overused)
609
+ ```