@vibeo/cli 0.3.4 → 0.4.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.
@@ -0,0 +1,580 @@
1
+ # Vibeo Effects (`@vibeo/effects`)
2
+
3
+ ## Overview
4
+
5
+ `@vibeo/effects` provides advanced animation primitives that go beyond basic interpolation: declarative keyframes, spring physics, scene transitions, and audio-reactive animation hooks.
6
+
7
+ **When to use**: When you need keyframe animation, physics-based springs, transitions between scenes, or audio-driven visuals.
8
+
9
+ ---
10
+
11
+ ## API Reference
12
+
13
+ ### `useKeyframes(frame, keyframes, options?): number`
14
+
15
+ Declarative keyframe animation. Automatically interpolates between keyframe stops.
16
+
17
+ ```ts
18
+ import { useKeyframes } from "@vibeo/effects";
19
+ import { useCurrentFrame, easeInOut } from "@vibeo/core";
20
+
21
+ const frame = useCurrentFrame();
22
+ const y = useKeyframes(frame, { 0: 0, 30: 100, 60: 0 }, { easing: easeInOut });
23
+ ```
24
+
25
+ **Parameters**:
26
+ | Param | Type | Description |
27
+ |-------|------|-------------|
28
+ | `frame` | `number` | Current frame (pass `useCurrentFrame()`) |
29
+ | `keyframes` | `KeyframeMap` | `Record<number, KeyframeStop>` — frame-to-value mapping |
30
+ | `options?` | `KeyframeOptions` | Default easing function |
31
+
32
+ **`KeyframeStop`**: `number | { value: number; easing?: (t: number) => number }`
33
+
34
+ Per-segment easing uses the **starting** stop's easing function for that segment.
35
+
36
+ ```ts
37
+ const scale = useKeyframes(frame, {
38
+ 0: { value: 0, easing: easeIn },
39
+ 20: { value: 1.2, easing: easeOut },
40
+ 30: 1,
41
+ });
42
+ ```
43
+
44
+ ### `useSpring(options): number`
45
+
46
+ Physics-based spring animation. Simulates mass-spring-damper system per frame.
47
+
48
+ ```ts
49
+ import { useSpring } from "@vibeo/effects";
50
+
51
+ const x = useSpring({ from: 0, to: 100 });
52
+ const bouncy = useSpring({ from: 0, to: 1, config: { mass: 1, stiffness: 300, damping: 10 } });
53
+ ```
54
+
55
+ **`SpringOptions`**:
56
+ | Field | Type | Default | Description |
57
+ |-------|------|---------|-------------|
58
+ | `from` | `number` | — | Start value |
59
+ | `to` | `number` | — | Target value |
60
+ | `frame?` | `number` | `useCurrentFrame()` | Frame override |
61
+ | `fps?` | `number` | `useVideoConfig().fps` | FPS override |
62
+ | `config?` | `SpringConfig` | see below | Physics parameters |
63
+
64
+ **`SpringConfig`** (defaults):
65
+ | Field | Default | Description |
66
+ |-------|---------|-------------|
67
+ | `mass` | `1` | Mass of the object |
68
+ | `stiffness` | `170` | Spring stiffness |
69
+ | `damping` | `26` | Damping coefficient |
70
+
71
+ ### `springDuration(options): number`
72
+
73
+ Compute how many frames a spring takes to settle.
74
+
75
+ ```ts
76
+ const dur = springDuration({ fps: 30, config: { stiffness: 300, damping: 10 } });
77
+ ```
78
+
79
+ ### `Transition`
80
+
81
+ Component for transitioning between exactly two children.
82
+
83
+ ```tsx
84
+ import { Transition } from "@vibeo/effects";
85
+ import { Sequence } from "@vibeo/core";
86
+
87
+ <Sequence from={55} durationInFrames={20}>
88
+ <Transition type="fade" durationInFrames={20}>
89
+ <SceneA />
90
+ <SceneB />
91
+ </Transition>
92
+ </Sequence>
93
+ ```
94
+
95
+ **`TransitionProps`**:
96
+ | Prop | Type | Default | Description |
97
+ |------|------|---------|-------------|
98
+ | `type` | `TransitionType` | — | `"fade" \| "wipe" \| "slide" \| "dissolve"` |
99
+ | `durationInFrames` | `number` | — | Frames the transition lasts |
100
+ | `timing?` | `TransitionTiming` | `"in-and-out"` | `"in-and-out" \| "in" \| "out"` |
101
+ | `direction?` | `TransitionDirection` | `"left"` | `"left" \| "right" \| "up" \| "down"` (for wipe/slide) |
102
+ | `children` | `[ReactNode, ReactNode]` | — | Exactly 2 children |
103
+
104
+ ### Transition Strategies
105
+
106
+ ```ts
107
+ import { fade, wipe, slide, dissolve } from "@vibeo/effects";
108
+ ```
109
+
110
+ Each returns `{ childA: CSSProperties; childB: CSSProperties }` for a given `TransitionState`.
111
+
112
+ | Strategy | Effect |
113
+ |----------|--------|
114
+ | `fade` | Opacity crossfade |
115
+ | `wipe` | Clip-path reveal |
116
+ | `slide` | Transform slide in/out |
117
+ | `dissolve` | Mix-blend + opacity |
118
+
119
+ ### `useAudioData(audioSrc, options?): AudioAnalysis | null`
120
+
121
+ Pre-analyzes an audio file and returns per-frame frequency/amplitude data. Returns `null` while loading.
122
+
123
+ ```ts
124
+ import { useAudioData } from "@vibeo/effects";
125
+
126
+ const data = useAudioData("/music.mp3", { fftSize: 2048 });
127
+ if (data) {
128
+ // data.amplitude — overall RMS amplitude (0-1)
129
+ // data.bass — average energy in 20-250 Hz
130
+ // data.mid — average energy in 250-4000 Hz
131
+ // data.treble — average energy in 4000-20000 Hz
132
+ // data.frequencies — Float32Array of FFT magnitude data (dB)
133
+ }
134
+ ```
135
+
136
+ **`AudioDataOptions`**:
137
+ | Field | Type | Default | Description |
138
+ |-------|------|---------|-------------|
139
+ | `fftSize?` | `number` | `2048` | FFT window size (power of 2) |
140
+
141
+ **`AudioAnalysis`**:
142
+ | Field | Type | Description |
143
+ |-------|------|-------------|
144
+ | `amplitude` | `number` | Overall RMS amplitude (0-1 range) |
145
+ | `frequencies` | `Float32Array` | Full FFT magnitude data in dB |
146
+ | `bass` | `number` | Average energy 20-250 Hz |
147
+ | `mid` | `number` | Average energy 250-4000 Hz |
148
+ | `treble` | `number` | Average energy 4000-20000 Hz |
149
+
150
+ ### `useTransitionProgress(durationInFrames): number | null`
151
+
152
+ Returns the transition progress (0 to 1) within a transition window, or `null` outside.
153
+
154
+ ```ts
155
+ const progress = useTransitionProgress(20);
156
+ if (progress !== null) {
157
+ // 0 at start of transition, 1 at end
158
+ }
159
+ ```
160
+
161
+ ### Types
162
+
163
+ ```ts
164
+ import type {
165
+ KeyframeStop,
166
+ KeyframeMap,
167
+ KeyframeOptions,
168
+ SpringConfig,
169
+ SpringOptions,
170
+ TransitionTiming,
171
+ TransitionDirection,
172
+ TransitionType,
173
+ TransitionProps,
174
+ TransitionState,
175
+ AudioAnalysis,
176
+ AudioDataOptions,
177
+ } from "@vibeo/effects";
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Keyframe Animation Recipes
183
+
184
+ ### Fade in
185
+
186
+ ```ts
187
+ const opacity = useKeyframes(frame, { 0: 0, 30: 1 });
188
+ ```
189
+
190
+ ### Bounce
191
+
192
+ ```ts
193
+ const y = useKeyframes(frame, {
194
+ 0: 0,
195
+ 15: { value: -100, easing: easeOut },
196
+ 30: { value: 0, easing: easeIn },
197
+ 45: { value: -40, easing: easeOut },
198
+ 60: 0,
199
+ });
200
+ ```
201
+
202
+ ### Slide in from left
203
+
204
+ ```ts
205
+ const x = useKeyframes(frame, {
206
+ 0: -1920,
207
+ 30: { value: 0, easing: easeOut },
208
+ });
209
+ ```
210
+
211
+ ### Scale pulse
212
+
213
+ ```ts
214
+ const scale = useKeyframes(frame, { 0: 1, 15: 1.2, 30: 1 }, { easing: easeInOut });
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Spring Animation Guide
220
+
221
+ ### Named Spring Presets
222
+
223
+ | Name | stiffness | damping | mass | Use case |
224
+ |------|-----------|---------|------|----------|
225
+ | **snappy** | 300 | 30 | 1 | UI elements, quick responses |
226
+ | **bouncy** | 400 | 10 | 1 | Playful entrances, logos |
227
+ | **gentle** | 100 | 26 | 1 | Subtle movements, text |
228
+ | **heavy** | 50 | 20 | 3 | Large elements, dramatic reveals |
229
+ | **elastic** | 200 | 8 | 1 | Overshoot effects, attention grabbers |
230
+
231
+ ```ts
232
+ // Snappy
233
+ useSpring({ from: 0, to: 1, config: { stiffness: 300, damping: 30, mass: 1 } })
234
+ // Bouncy
235
+ useSpring({ from: 0, to: 1, config: { stiffness: 400, damping: 10, mass: 1 } })
236
+ // Gentle
237
+ useSpring({ from: 0, to: 1, config: { stiffness: 100, damping: 26, mass: 1 } })
238
+ // Heavy
239
+ useSpring({ from: 0, to: 1, config: { stiffness: 50, damping: 20, mass: 3 } })
240
+ // Elastic
241
+ useSpring({ from: 0, to: 1, config: { stiffness: 200, damping: 8, mass: 1 } })
242
+ ```
243
+
244
+ **Tuning guide**:
245
+ - Higher `stiffness` = faster motion, more overshoot
246
+ - Higher `damping` = less bounce, settles faster
247
+ - Higher `mass` = slower, more momentum
248
+
249
+ ---
250
+
251
+ ## Transition Usage Between Scenes
252
+
253
+ ```tsx
254
+ function MyVideo() {
255
+ return (
256
+ <>
257
+ {/* Scene A plays frames 0-59 */}
258
+ <Sequence from={0} durationInFrames={75}>
259
+ <SceneA />
260
+ </Sequence>
261
+
262
+ {/* 15-frame fade transition overlapping scenes */}
263
+ <Sequence from={55} durationInFrames={20}>
264
+ <Transition type="fade" durationInFrames={20}>
265
+ <SceneA />
266
+ <SceneB />
267
+ </Transition>
268
+ </Sequence>
269
+
270
+ {/* Scene B continues */}
271
+ <Sequence from={75} durationInFrames={90}>
272
+ <SceneB />
273
+ </Sequence>
274
+ </>
275
+ );
276
+ }
277
+ ```
278
+
279
+ For directional transitions:
280
+ ```tsx
281
+ <Transition type="slide" durationInFrames={20} direction="right">
282
+ <OldScene />
283
+ <NewScene />
284
+ </Transition>
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Audio-Reactive Animation Patterns
290
+
291
+ ### Beat-reactive scale
292
+
293
+ ```tsx
294
+ function BeatCircle() {
295
+ const frame = useCurrentFrame();
296
+ const audio = useAudioData("/track.mp3");
297
+ const baseScale = audio ? 1 + audio.bass * 2 : 1;
298
+
299
+ return (
300
+ <div style={{
301
+ width: 100,
302
+ height: 100,
303
+ borderRadius: "50%",
304
+ background: "white",
305
+ transform: `scale(${baseScale})`,
306
+ }} />
307
+ );
308
+ }
309
+ ```
310
+
311
+ ### Audio visualizer bars
312
+
313
+ ```tsx
314
+ function Visualizer() {
315
+ const audio = useAudioData("/music.mp3", { fftSize: 512 });
316
+ if (!audio) return null;
317
+
318
+ const barCount = 32;
319
+ const step = Math.floor(audio.frequencies.length / barCount);
320
+
321
+ return (
322
+ <div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
323
+ {Array.from({ length: barCount }, (_, i) => {
324
+ const db = audio.frequencies[i * step];
325
+ const height = Math.max(2, ((db + 100) / 100) * 200);
326
+ return <div key={i} style={{ width: 8, height, margin: 1, background: "cyan" }} />;
327
+ })}
328
+ </div>
329
+ );
330
+ }
331
+ ```
332
+
333
+ ### Background color shift with amplitude
334
+
335
+ ```tsx
336
+ function ReactiveBackground({ children }: { children: React.ReactNode }) {
337
+ const audio = useAudioData("/music.mp3");
338
+ const hue = audio ? Math.round(audio.amplitude * 360) : 200;
339
+ const lightness = audio ? 20 + audio.amplitude * 30 : 20;
340
+
341
+ return (
342
+ <div style={{
343
+ width: "100%",
344
+ height: "100%",
345
+ background: `hsl(${hue}, 70%, ${lightness}%)`,
346
+ }}>
347
+ {children}
348
+ </div>
349
+ );
350
+ }
351
+ ```
352
+
353
+ ---
354
+
355
+ ## CodeBlock Recipe (for developer/tutorial content)
356
+
357
+ The most common component in programming videos. Takes a plain string + optional highlight words:
358
+
359
+ ```tsx
360
+ function CodeBlock({
361
+ code,
362
+ highlights = [],
363
+ fontSize = 24,
364
+ startFrame = 0,
365
+ lineDelay = 3,
366
+ }: {
367
+ code: string;
368
+ highlights?: { word: string; color: string }[];
369
+ fontSize?: number;
370
+ startFrame?: number;
371
+ lineDelay?: number;
372
+ }) {
373
+ const frame = useCurrentFrame();
374
+ const lines = code.split("\n");
375
+
376
+ return (
377
+ <div style={{
378
+ background: "#1e1e2e",
379
+ borderRadius: 12,
380
+ padding: "24px 32px",
381
+ fontFamily: "'SF Mono', 'Cascadia Code', monospace",
382
+ fontSize,
383
+ lineHeight: 1.6,
384
+ overflow: "hidden",
385
+ }}>
386
+ {lines.map((line, i) => {
387
+ const lineFrame = startFrame + i * lineDelay;
388
+ const opacity = interpolate(frame, [lineFrame, lineFrame + 10], [0, 1], {
389
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
390
+ });
391
+
392
+ let html = line.replace(/&/g, "&amp;").replace(/</g, "&lt;");
393
+ for (const h of highlights) {
394
+ html = html.replaceAll(h.word, `<span style="color:${h.color}">${h.word}</span>`);
395
+ }
396
+
397
+ return (
398
+ <div key={i} style={{ opacity, whiteSpace: "pre" }} dangerouslySetInnerHTML={{ __html: html || "&nbsp;" }} />
399
+ );
400
+ })}
401
+ </div>
402
+ );
403
+ }
404
+
405
+ // Usage:
406
+ <CodeBlock
407
+ code={`function Foo() {\n return <div>hello</div>;\n}`}
408
+ highlights={[{ word: "function", color: "#c678dd" }, { word: "return", color: "#c678dd" }]}
409
+ startFrame={10}
410
+ />
411
+ ```
412
+
413
+ ---
414
+
415
+ ## SVG Animation Recipes
416
+
417
+ ### Spring-scale an SVG (logo entrance)
418
+
419
+ ```tsx
420
+ function AnimatedLogo() {
421
+ const frame = useCurrentFrame();
422
+ const scale = useSpring({ from: 0, to: 1, frame, fps: 30, config: { stiffness: 400, damping: 10 } });
423
+ const rotation = interpolate(frame, [0, 30], [180, 0], { extrapolateRight: "clamp" });
424
+
425
+ return (
426
+ <svg width={200} height={200} style={{
427
+ transform: `scale(${scale}) rotate(${rotation}deg)`,
428
+ filter: `drop-shadow(0 0 ${20 * scale}px rgba(97, 218, 251, 0.6))`,
429
+ }}>
430
+ {/* SVG content */}
431
+ </svg>
432
+ );
433
+ }
434
+ ```
435
+
436
+ ### Rotating SVG with glow
437
+
438
+ ```tsx
439
+ const frame = useCurrentFrame();
440
+ const rotation = (frame % 90) * 4; // full rotation every 3s at 30fps
441
+ const glowIntensity = interpolate(frame % 60, [0, 30, 60], [10, 25, 10]);
442
+
443
+ <svg style={{
444
+ transform: `rotate(${rotation}deg)`,
445
+ filter: `drop-shadow(0 0 ${glowIntensity}px cyan)`,
446
+ }} />
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Split-Screen / Comparison Layout
452
+
453
+ Before/after code comparison — common in tutorial content:
454
+
455
+ ```tsx
456
+ function SplitScreen({
457
+ left,
458
+ right,
459
+ dividerLabel = "VS",
460
+ }: {
461
+ left: React.ReactNode;
462
+ right: React.ReactNode;
463
+ dividerLabel?: string;
464
+ }) {
465
+ const frame = useCurrentFrame();
466
+ const { width, height } = useVideoConfig();
467
+
468
+ // Stagger: left appears first, divider, then right
469
+ const leftOpacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
470
+ const rightOpacity = interpolate(frame, [20, 35], [0, 1], { extrapolateRight: "clamp" });
471
+ const dividerOpacity = interpolate(frame, [10, 20], [0, 1], { extrapolateRight: "clamp" });
472
+
473
+ return (
474
+ <div style={{ display: "flex", width, height }}>
475
+ <div style={{ flex: 1, opacity: leftOpacity, padding: 40 }}>{left}</div>
476
+ <div style={{
477
+ width: 60, display: "flex", alignItems: "center", justifyContent: "center",
478
+ opacity: dividerOpacity, color: "#888", fontSize: 20, fontWeight: 700,
479
+ }}>
480
+ {dividerLabel}
481
+ </div>
482
+ <div style={{ flex: 1, opacity: rightOpacity, padding: 40 }}>{right}</div>
483
+ </div>
484
+ );
485
+ }
486
+ ```
487
+
488
+ For vertical video (9:16), stack top/bottom instead of left/right:
489
+
490
+ ```tsx
491
+ <div style={{ display: "flex", flexDirection: "column", width, height }}>
492
+ <div style={{ flex: 1 }}>{before}</div>
493
+ <div style={{ height: 4, background: "#333" }} />
494
+ <div style={{ flex: 1 }}>{after}</div>
495
+ </div>
496
+ ```
497
+
498
+ ---
499
+
500
+ ## Gotchas and Tips
501
+
502
+ 1. **`useAudioData` returns `null` initially** — always handle the loading state.
503
+
504
+ 2. **`useKeyframes` clamps at boundaries** — it won't extrapolate beyond the first/last keyframe.
505
+
506
+ 3. **`<Transition>` must have exactly 2 children** — it throws if you pass more or fewer.
507
+
508
+ 4. **Spring simulations are cached** at the module level for identical parameters, so repeated renders are cheap.
509
+
510
+ 5. **`useKeyframes` requires you to pass `frame` explicitly** — it doesn't call `useCurrentFrame()` internally. This gives you flexibility to use any frame value.
511
+
512
+ 6. **`springDuration()` is useful for sizing `<Sequence>` wrappers** to match how long a spring animation takes to settle.
513
+
514
+
515
+ ---
516
+
517
+ ## LLM & Agent Integration
518
+
519
+ Vibeo's CLI is built with [incur](https://github.com/wevm/incur), making it natively discoverable by AI agents and LLMs.
520
+
521
+ ### Discovering the API
522
+
523
+ ```bash
524
+ # Get a compact summary of all CLI commands (ideal for LLM system prompts)
525
+ bunx @vibeo/cli --llms
526
+
527
+ # Get the full manifest with schemas, examples, and argument details
528
+ bunx @vibeo/cli --llms-full
529
+
530
+ # Get JSON Schema for a specific command (useful for structured tool calls)
531
+ bunx @vibeo/cli render --schema
532
+ bunx @vibeo/cli create --schema
533
+ ```
534
+
535
+ ### Using as an MCP Server
536
+
537
+ ```bash
538
+ # Start Vibeo as an MCP (Model Context Protocol) server
539
+ bunx @vibeo/cli --mcp
540
+
541
+ # Register as a persistent MCP server for your agent
542
+ bunx @vibeo/cli mcp add
543
+ ```
544
+
545
+ This lets LLMs call `create`, `render`, `preview`, and `list` as structured tool calls through the MCP protocol.
546
+
547
+ ### Generating Skill Files
548
+
549
+ ```bash
550
+ # Sync skill files to your agent's skill directory
551
+ bunx @vibeo/cli skills add
552
+ ```
553
+
554
+ This generates markdown skill files that agents like Claude Code can discover and use to write Vibeo code without reading source.
555
+
556
+ ### Agent-Friendly Output
557
+
558
+ ```bash
559
+ # Output as JSON for programmatic consumption
560
+ bunx @vibeo/cli list --entry src/index.tsx --format json
561
+
562
+ # Output as YAML
563
+ bunx @vibeo/cli list --entry src/index.tsx --format yaml
564
+
565
+ # Filter output to specific keys
566
+ bunx @vibeo/cli list --entry src/index.tsx --filter-output compositions[0].id
567
+
568
+ # Count tokens in output (useful for context window planning)
569
+ bunx @vibeo/cli render --schema --token-count
570
+ ```
571
+
572
+ ### How LLMs Should Use Vibeo
573
+
574
+ 1. **Discover commands**: Run `bunx @vibeo/cli --llms` to get the command manifest
575
+ 2. **Create a project**: `bunx @vibeo/cli create my-video --template basic`
576
+ 3. **Edit `src/index.tsx`**: Write React components using `@vibeo/core` hooks and components
577
+ 4. **Preview**: `bunx @vibeo/cli preview --entry src/index.tsx`
578
+ 5. **Render**: `bunx @vibeo/cli render --entry src/index.tsx --composition MyComp`
579
+
580
+ All commands accept `--format json` for structured output that LLMs can parse reliably.