@impractical-ai/motion 0.1.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,914 @@
1
+ // Impractical Motion — public engine surface v1.0.
2
+ //
3
+ // A scene is two files:
4
+ // App.tsx the stage markup (plain React + CSS; this is your product UI)
5
+ // direction.ts the choreography — `export default (ctx: DirectionContext) => {...}`
6
+ //
7
+ // The timeline is created PAUSED and rendered by seeking frame by frame, so
8
+ // every animation must be a function of timeline position alone. Never use
9
+ // Date.now(), Math.random(), setTimeout, CSS animations, or autoplaying video —
10
+ // they desynchronise the export. Use ctx.video() for footage.
11
+ //
12
+ // Generated by scripts/build-engine-dts.mjs — do not edit by hand.
13
+
14
+ import type gsap from 'gsap'
15
+ import type { SplitText } from 'gsap/SplitText'
16
+
17
+ // ─── Transitions — cover and reveal the whole frame ──────────────────────
18
+
19
+ export type TransitionStyle =
20
+ | 'wipe' // a solid slab sweeps across the frame
21
+ | 'pixels' // a grid of squares snaps in, sweeping directionally
22
+ | 'iris'
23
+
24
+ export type TransitionDirection = 'left' | 'right' | 'up' | 'down'
25
+
26
+ export type TransitionOpts = {
27
+ color?: string
28
+ /** Travel direction of the sweep (wipe/pixels). Default 'left'. */
29
+ direction?: TransitionDirection
30
+ duration?: number
31
+ ease?: string
32
+ /** Pixel cell size in px (pixels style). Default: frame height / 9. */
33
+ cell?: number
34
+ /** Iris center as fractions of the frame, e.g. `{ x: 0.7, y: 0.4 }`. */
35
+ at?: { x?: number; y?: number }
36
+ }
37
+
38
+ export type TransitionResult = {
39
+ duration: number
40
+ /** The overlay's root element — e.g. to hide it manually mid-timeline. */
41
+ root: HTMLElement
42
+ }
43
+
44
+ export type TransitionKit = {
45
+ /** Cover the frame — put this at the end of a clip. Appends to the
46
+ * timeline's end when no position is given. */
47
+ out: (
48
+ style: TransitionStyle,
49
+ opts?: TransitionOpts,
50
+ position?: gsap.Position
51
+ ) => TransitionResult
52
+ /** Start covered and reveal the scene — put this at a clip's start
53
+ * (position 0). Continues the same travel direction as its `out`. */
54
+ in: (
55
+ style: TransitionStyle,
56
+ opts?: TransitionOpts,
57
+ position?: gsap.Position
58
+ ) => TransitionResult
59
+ }
60
+
61
+ // ─── Text — named, deterministic text animations ─────────────────────────
62
+
63
+ export type TextUnit = 'chars' | 'words' | 'lines'
64
+
65
+ export type RevealEffect =
66
+ | 'rise' // masked characters rise in from below (the house classic)
67
+ | 'fall' // masked characters drop in from above
68
+ | 'blur' // words de-blur and fade in
69
+ | 'wave' // characters bob up with an overshoot, heavily overlapped
70
+ | 'flip' // characters flip up from flat (3D rotationX)
71
+ | 'pop' // characters scale in with a springy overshoot
72
+ | 'slide' // words slide in from the left
73
+ | 'typewriter' // characters appear one by one, no easing
74
+ | 'scramble' // characters cycle through glyphs, then lock in
75
+ | 'wipe' // the whole element is revealed by a moving clip edge
76
+ | 'bounce' // characters drop onto the baseline and bounce (CustomBounce)
77
+ | 'elastic' // characters stretch up from the baseline (elastic ease)
78
+ | 'arc' // characters swing in along a curved path (MotionPath)
79
+ | 'streak'
80
+
81
+ export type ExitEffect =
82
+ | 'rise' // masked characters continue up and out
83
+ | 'fall' // masked characters drop down and out
84
+ | 'blur' // words blur away
85
+ | 'fade' // the whole element fades out
86
+ | 'pop' // characters shrink away
87
+ | 'scramble' // characters unlock into glyph noise, then vanish
88
+ | 'wipe' // a clip edge eats the element left to right
89
+ | 'scatter' // characters burst apart and fall under gravity (Physics2D)
90
+ | 'streak'
91
+
92
+ export type EmphasisEffect =
93
+ | 'pulse' // scale up and settle back (a breath)
94
+ | 'wiggle' // playful rotation wiggle that settles (CustomWiggle)
95
+ | 'shake'
96
+
97
+ export type StaggerFrom = 'start' | 'center' | 'end' | 'edges'
98
+
99
+ export type TextEffectOpts = {
100
+ by?: TextUnit // which unit to animate (effects pick a sensible default)
101
+ duration?: number // per-unit duration, seconds
102
+ stagger?: number // seconds between units
103
+ ease?: string
104
+ from?: StaggerFrom // where the stagger radiates from (default 'start')
105
+ }
106
+
107
+ export type EmphasisOpts = {
108
+ duration?: number // full span of the gesture
109
+ strength?: number // pulse: target scale; wiggle: degrees; shake: px
110
+ ease?: string
111
+ }
112
+
113
+ export type UnderlineOpts = {
114
+ color?: string
115
+ thickness?: number // stroke width, px
116
+ duration?: number
117
+ ease?: string
118
+ offsetY?: number // gap between text baseline box and the line, px
119
+ }
120
+
121
+ export type TextResult = {
122
+ duration: number // full span including stagger — for sequencing
123
+ targets: Element[]
124
+ }
125
+
126
+ export type CountOpts = {
127
+ to: number
128
+ from?: number // defaults to whatever number the element already shows
129
+ duration?: number
130
+ ease?: string
131
+ }
132
+
133
+ export type TextKit = {
134
+ /** Animate text in. Returns the total span so you can sequence after it. */
135
+ reveal: (
136
+ target: string | Element,
137
+ effect: RevealEffect,
138
+ opts?: TextEffectOpts,
139
+ position?: gsap.Position
140
+ ) => TextResult
141
+ /** Animate text out (mirror of reveal). */
142
+ exit: (
143
+ target: string | Element,
144
+ effect: ExitEffect,
145
+ opts?: TextEffectOpts,
146
+ position?: gsap.Position
147
+ ) => TextResult
148
+ /** Deterministic number count-up on an element's text (for stat pills and
149
+ * badges — pairs with a camera push-in). Snaps to integers on the
150
+ * timeline; width is locked so the layout doesn't breathe. */
151
+ count: (
152
+ target: string | Element,
153
+ opts: CountOpts,
154
+ position?: gsap.Position
155
+ ) => TextResult
156
+ /** A settle-back gesture on a whole element (no split): pulse, wiggle,
157
+ * shake. Great on CTAs and accent words after they appear. */
158
+ emphasize: (
159
+ target: string | Element,
160
+ effect: EmphasisEffect,
161
+ opts?: EmphasisOpts,
162
+ position?: gsap.Position
163
+ ) => TextResult
164
+ /** Draw a hand-sketched underline stroke beneath an element. The stroke
165
+ * spans the element's box — target a tight element (an inline-block span
166
+ * around the exact words), not a full-width heading. */
167
+ underline: (
168
+ target: string | Element,
169
+ opts?: UnderlineOpts,
170
+ position?: gsap.Position
171
+ ) => TextResult
172
+ }
173
+
174
+ // ─── Logo — named, deterministic brand-mark animations ───────────────────
175
+
176
+ export type LogoRevealEffect =
177
+ | 'pop' // scales in with a springy overshoot
178
+ | 'blur' // de-blurs and settles from slightly oversized
179
+ | 'rise' // rises in from below behind an overflow mask
180
+ | 'wipe' // a clip edge sweeps the logo in (direction opt)
181
+ | 'iris' // a circular clip opens from the center
182
+ | 'slit' // a letterbox slit opens from a horizontal line
183
+ | 'spin' // rotates in while scaling up
184
+ | 'flip' // 3D flip up from edge-on
185
+ | 'bounce' // drops in and bounces on the baseline (CustomBounce)
186
+ | 'glitch' // seeded slice-band glitch that settles clean
187
+ | 'draw' // SVG only: strokes trace the paths, fills crossfade in
188
+ | 'build'
189
+
190
+ export type LogoExitEffect =
191
+ | 'pop' // shrinks away
192
+ | 'blur' // blurs out
193
+ | 'fade' // plain fade
194
+ | 'wipe' // a clip edge eats the logo (direction opt)
195
+ | 'iris' // the circular clip closes
196
+ | 'drop'
197
+
198
+ export type LogoEmphasisEffect =
199
+ | 'pulse' // scale up and settle back (one breath)
200
+ | 'breathe' // slow repeated scale loop (finite — set repeats)
201
+ | 'wiggle'
202
+
203
+ export type LogoDirection = 'left' | 'right' | 'up' | 'down'
204
+
205
+ export type LogoEffectOpts = {
206
+ duration?: number
207
+ ease?: string
208
+ stagger?: number // draw/build: seconds between shapes
209
+ direction?: LogoDirection // wipe: which way the edge travels
210
+ }
211
+
212
+ export type LogoEmphasisOpts = {
213
+ duration?: number
214
+ strength?: number // pulse/breathe: target scale; wiggle: degrees
215
+ ease?: string
216
+ repeats?: number // breathe: number of breaths (default 3)
217
+ }
218
+
219
+ export type ShineOpts = {
220
+ color?: string // band color (default white)
221
+ width?: number // band width as a fraction of the logo box (default 0.18)
222
+ angle?: number // gradient angle, degrees (default 105)
223
+ duration?: number
224
+ ease?: string
225
+ }
226
+
227
+ export type LogoResult = {
228
+ duration: number // full span including stagger — for sequencing
229
+ targets: Element[]
230
+ }
231
+
232
+ export type LogoKit = {
233
+ /** Animate a logo in. Returns the total span so you can sequence after it. */
234
+ reveal: (
235
+ target: string | Element,
236
+ effect: LogoRevealEffect,
237
+ opts?: LogoEffectOpts,
238
+ position?: gsap.Position
239
+ ) => LogoResult
240
+ /** Animate a logo out (mirror of reveal). */
241
+ exit: (
242
+ target: string | Element,
243
+ effect: LogoExitEffect,
244
+ opts?: LogoEffectOpts,
245
+ position?: gsap.Position
246
+ ) => LogoResult
247
+ /** A settle-back gesture on the whole mark: pulse, breathe, wiggle. */
248
+ emphasize: (
249
+ target: string | Element,
250
+ effect: LogoEmphasisEffect,
251
+ opts?: LogoEmphasisOpts,
252
+ position?: gsap.Position
253
+ ) => LogoResult
254
+ /** A glint sweep across the mark. Follows the logo's silhouette (the
255
+ * sheen is masked by the SVG shapes or the image alpha). */
256
+ shine: (
257
+ target: string | Element,
258
+ opts?: ShineOpts,
259
+ position?: gsap.Position
260
+ ) => LogoResult
261
+ }
262
+
263
+ // ─── Effects — CSS filters and SVG filter primitives ─────────────────────
264
+
265
+ export type FilterTier = 'css' | 'svg'
266
+
267
+ export type FilterEffect =
268
+ // ── css tier ────────────────────────────────────────────────────────────
269
+ /** Gaussian blur. Replaces the hand-rolled whip-pan "stage-blur spike". */
270
+ | 'blur'
271
+ /** A halo in the element's own colour, from stacked drop-shadows. */
272
+ | 'glow'
273
+ /** Colour lifts (>1) or drains (<1). */
274
+ | 'saturate'
275
+ /** Brightness pull-down — for backgrounding a layer under an overlay. */
276
+ | 'dim'
277
+ /** Hue rotation drift. */
278
+ | 'hue'
279
+ // ── svg tier ────────────────────────────────────────────────────────────
280
+ /** DIRECTIONAL blur along `angle`. CSS filter cannot do this at all. */
281
+ | 'smear'
282
+ /** Chromatic aberration: red and blue channels offset in opposite
283
+ * directions. The glitch/impact look, without an asset. */
284
+ | 'chroma'
285
+ /** Turbulence-driven displacement: heat haze, liquid warp, dissolve. */
286
+ | 'displace'
287
+ /** Fractal-noise film grain composited over the source. */
288
+ | 'grain'
289
+ /** Bright-pass blur screened back over the source — bloom. */
290
+ | 'bloom'
291
+ /** Posterize to N discrete levels per channel. */
292
+ | 'threshold'
293
+
294
+ export type FilterOpts = {
295
+ /** Peak value of the effect's primary parameter. */
296
+ amount?: number
297
+ /** Secondary constant: turbulence frequency, bloom radius, grain scale. */
298
+ detail?: number
299
+ duration?: number
300
+ ease?: string
301
+ /** Direction in degrees for `smear` and `chroma`. 0 = horizontal. */
302
+ angle?: number
303
+ /** Tint for `glow` and `bloom`. Defaults to the element's computed colour. */
304
+ color?: string
305
+ }
306
+
307
+ export type FilterResult = {
308
+ /** Full span in seconds, including the return leg of a `spike`. */
309
+ duration: number
310
+ targets: Element[]
311
+ }
312
+
313
+ export type FilterKit = {
314
+ /** Ramp an effect ON and leave it there — a state change. */
315
+ apply: (
316
+ target: string | Element | Element[],
317
+ effect: FilterEffect,
318
+ opts?: FilterOpts,
319
+ position?: gsap.Position
320
+ ) => FilterResult
321
+ /** Ramp it back to nothing. */
322
+ clear: (
323
+ target: string | Element | Element[],
324
+ effect: FilterEffect,
325
+ opts?: FilterOpts,
326
+ position?: gsap.Position
327
+ ) => FilterResult
328
+ /** In and out in ONE gesture: the whip-pan blur, the impact bloom, the
329
+ * glitch chroma pop. One tween, so the Tweak panel shows ONE row with one
330
+ * slider, one duration and one ease — instead of the two rows the
331
+ * hand-rolled pattern produces, which the user has to keep in sync. */
332
+ spike: (
333
+ target: string | Element | Element[],
334
+ effect: FilterEffect,
335
+ opts?: FilterOpts,
336
+ position?: gsap.Position
337
+ ) => FilterResult
338
+ }
339
+
340
+ // ─── Masks — soft edges, mattes, fills, and blend modes ──────────────────
341
+
342
+ export type MaskShape =
343
+ /** Hard directional edge (clip-path inset) — same geometry as logo.wipe. */
344
+ | 'wipe'
345
+ /** SOFT directional edge. The capability clip-path structurally lacks. */
346
+ | 'feather'
347
+ /** Hard circle growing from a point. */
348
+ | 'iris'
349
+ /** Soft circle with a feathered falloff. */
350
+ | 'radial'
351
+ /** Letterbox opening from a horizontal centre line. */
352
+ | 'slit'
353
+ /** Two panels parting from the centre outward. */
354
+ | 'barn'
355
+ /** N bands opening together. */
356
+ | 'blinds'
357
+ /** Caller-supplied clip-path or mask-image value — the escape hatch. */
358
+ | 'shape'
359
+
360
+ export type BlendMode =
361
+ | 'screen'
362
+ | 'multiply'
363
+ | 'overlay'
364
+ | 'difference'
365
+ | 'exclusion'
366
+ | 'color-dodge'
367
+ | 'soft-light'
368
+ | 'luminosity'
369
+ | 'plus-lighter'
370
+ | 'normal'
371
+
372
+ export type MaskDirection = 'left' | 'right' | 'up' | 'down'
373
+
374
+ export type MatteKind = 'luma' | 'alpha'
375
+
376
+ export type MaskOpts = {
377
+ direction?: MaskDirection
378
+ /** Feather width as a fraction of the element's box. 0 = hard edge. */
379
+ softness?: number
380
+ /** Band count for `blinds`. */
381
+ bands?: number
382
+ /** Centre for `iris` / `radial`, as fractions of the box. */
383
+ at?: { x?: number; y?: number }
384
+ /** For `shape`: any clip-path value. */
385
+ shape?: string
386
+ duration?: number
387
+ ease?: string
388
+ }
389
+
390
+ export type MatteOpts = {
391
+ /** 'luma' uses the source's brightness, 'alpha' its transparency. */
392
+ kind?: MatteKind
393
+ /** Sweep the matte across the target rather than holding it still. */
394
+ sweep?: boolean
395
+ duration?: number
396
+ ease?: string
397
+ }
398
+
399
+ export type FillOpts = {
400
+ /** A CSS gradient, e.g. 'linear-gradient(90deg,#ED1D24,#00B8FF)'. */
401
+ gradient?: string
402
+ /** Or an image URL. */
403
+ image?: string
404
+ /** How far the fill drifts across the glyphs, in percent. */
405
+ drift?: number
406
+ duration?: number
407
+ ease?: string
408
+ }
409
+
410
+ export type BlendOpts = {
411
+ /** Opacity the layer fades up from. Default 0. */
412
+ from?: number
413
+ duration?: number
414
+ ease?: string
415
+ }
416
+
417
+ export type MaskResult = {
418
+ duration: number
419
+ targets: Element[]
420
+ }
421
+
422
+ export type MaskKit = {
423
+ /** Reveal ANY element through a moving mask — the general verb that
424
+ * `text.reveal('wipe')` and `logo.reveal('iris')` are special cases of. */
425
+ reveal: (
426
+ target: string | Element | Element[],
427
+ shape: MaskShape,
428
+ opts?: MaskOpts,
429
+ position?: gsap.Position
430
+ ) => MaskResult
431
+ /** The mirror: mask the element back out. */
432
+ hide: (
433
+ target: string | Element | Element[],
434
+ shape: MaskShape,
435
+ opts?: MaskOpts,
436
+ position?: gsap.Position
437
+ ) => MaskResult
438
+ /** Track matte: `source`'s silhouette becomes `target`'s visibility. */
439
+ matte: (
440
+ target: string | Element,
441
+ source: string | Element,
442
+ opts?: MatteOpts,
443
+ position?: gsap.Position
444
+ ) => MaskResult
445
+ /** Fill an element's GLYPHS with a live layer and drift it. */
446
+ fill: (
447
+ target: string | Element,
448
+ opts?: FillOpts,
449
+ position?: gsap.Position
450
+ ) => MaskResult
451
+ /** Composite a layer against its beat with a blend mode. */
452
+ blend: (
453
+ target: string | Element | Element[],
454
+ mode: BlendMode,
455
+ opts?: BlendOpts,
456
+ position?: gsap.Position
457
+ ) => MaskResult
458
+ }
459
+
460
+ // ─── Type axes — variable-font weight/width/slant animation ──────────────
461
+
462
+ export type AxisName = 'wght' | 'wdth' | 'slnt' | 'opsz' | (string & {})
463
+
464
+ export type AxisEffect =
465
+ /** Ramp the axis from its current value to `to` and hold. */
466
+ | 'ramp'
467
+ /** Ramp up and settle back — one breath. */
468
+ | 'pulse'
469
+ /** A per-character wave that travels through the line once. */
470
+ | 'wave'
471
+
472
+ export type AxisOpts = {
473
+ /** Target axis value. Clamped to the face's declared range. */
474
+ to?: number
475
+ from?: number
476
+ duration?: number
477
+ ease?: string
478
+ /** wave: seconds between adjacent characters. */
479
+ stagger?: number
480
+ }
481
+
482
+ export type AxisResult = { duration: number; targets: Element[] }
483
+
484
+ export type AxisKit = {
485
+ animate: (
486
+ target: string | Element | Element[],
487
+ axis: AxisName,
488
+ effect: AxisEffect,
489
+ opts?: AxisOpts,
490
+ position?: gsap.Position
491
+ ) => AxisResult
492
+ }
493
+
494
+ // ─── 3D — a Three.js layer under the deterministic timeline ──────────────
495
+
496
+ export type ThreeLayerOpts = {
497
+ /** Camera vertical FOV in degrees. */
498
+ fov?: number
499
+ /** Initial camera position. Default { x: 0, y: 0, z: 6 }. */
500
+ camera?: { x?: number; y?: number; z?: number }
501
+ /** Layer opacity 0..1 (the canvas is a normal stage element). */
502
+ opacity?: number
503
+ /** Stacking: 'front' (default) composites the transparent layer above
504
+ * the DOM; 'back' slots it behind — note an opaque App background will
505
+ * hide a 'back' layer entirely. */
506
+ depth?: 'back' | 'front'
507
+ }
508
+
509
+ export type ThreeLayer = {
510
+ /** The scene graph — add meshes, lights, groups. */
511
+ scene: THREE.Scene
512
+ camera: THREE.PerspectiveCamera
513
+ /** The canvas element, for tweening opacity/filters like any element. */
514
+ canvas: HTMLCanvasElement
515
+ /** Drive the layer over [position, position+duration]. `update` runs every
516
+ * frame with eased progress 0..1 BEFORE the render — set uniforms, camera
517
+ * and material state from `t` alone (no clocks, no accumulation). Call
518
+ * once per active span; overlapping drives render redundantly. */
519
+ drive: (
520
+ duration: number,
521
+ update?: (t: number) => void,
522
+ opts?: { ease?: string },
523
+ position?: gsap.Position
524
+ ) => { duration: number }
525
+ }
526
+
527
+ export type ThreeKit = {
528
+ /** Create a 3D layer sized to the stage. Everything async about it (first
529
+ * compile, first paint) completes before window.__ready. */
530
+ layer: (opts?: ThreeLayerOpts) => ThreeLayer
531
+ }
532
+
533
+ // ─── Engine — the direction context you write against ────────────────────
534
+
535
+ /** Render directives a scene (or a kit, during the direction) hands to the
536
+ * exporter. The channel already exists end to end: `export const output` in
537
+ * direction.ts → loadScene → buildScene → window.__config → the exporter's
538
+ * probe — and because __config is assigned AFTER the direction runs, a kit
539
+ * can set these from inside a direction call. Everything optional; absent
540
+ * means today's behaviour exactly. */
541
+ export type RenderConfig = {
542
+ /** Temporal motion blur (Phase 6). `off` (default) renders one sample per
543
+ * frame. Tiers fix samples-per-frame; `runs` limits blur to intervals so a
544
+ * 30s clip with 6s of camera moves does not pay the multiplier throughout. */
545
+ blur?: {
546
+ tier?: 'off' | 'light' | 'full'
547
+ /** Shutter angle in degrees, 0–360. Default 180. Costs nothing — it only
548
+ * changes WHICH times get sampled, never how many. */
549
+ shutter?: number
550
+ /** Blur only these intervals, in seconds. Absent = the whole timeline. */
551
+ runs?: Array<{ from: number; to: number }>
552
+ }
553
+ }
554
+
555
+ /** Committed output of scripts/analyze-music.mjs — one JSON per track in
556
+ * music/analysis/. A static file, identical in every export shard, which is
557
+ * what makes audio-reactive motion compatible with seek-anywhere rendering. */
558
+ export type TrackAnalysis = {
559
+ analyzerVersion: number
560
+ rate: number // envelope grid Hz
561
+ duration: number // exact decoded track length, seconds
562
+ bpm: number
563
+ downbeatPhase: number
564
+ beats: number[]
565
+ downbeats: number[]
566
+ onsets: number[]
567
+ sections: Array<{ start: number; end: number; energy: number }>
568
+ energy: number[]
569
+ bands: { low: number[]; mid: number[]; high: number[] }
570
+ }
571
+
572
+ export type AudioKit = {
573
+ /** True when a real analysis file was loaded. When false every envelope
574
+ * reads 0 and the beat grid is synthetic — directions never throw. */
575
+ live: boolean
576
+ bpm: number
577
+ beats: number[]
578
+ downbeats: number[]
579
+ onsets: number[]
580
+ sections: Array<{ start: number; end: number; energy: number }>
581
+ /** Overall loudness at timeline time t, 0..1. Pure lookup — the exporter
582
+ * loops the track under a longer video, so t wraps at the track length. */
583
+ energy: (t: number) => number
584
+ band: (t: number, band: 'low' | 'mid' | 'high') => number
585
+ /** Timeline time of beat n (downbeat-phase corrected). */
586
+ beat: (n: number) => number
587
+ /** Map the energy envelope onto element properties: one proxy tween whose
588
+ * onUpdate reads energy(t) — a pure function of playhead, so it renders
589
+ * identically from any seek. `map` receives 0..1 and returns gsap vars to
590
+ * set, e.g. (e) => ({ scale: 1 + e * 0.06 }). NO smoothing at read time:
591
+ * an IIR filter over successive frames is the shard-seam bug. */
592
+ drive: (
593
+ target: string | Element,
594
+ map: (energy: number) => Record<string, number | string>,
595
+ opts?: { band?: 'low' | 'mid' | 'high'; from?: number; duration?: number },
596
+ position?: gsap.Position
597
+ ) => { duration: number }
598
+ }
599
+
600
+ export type Output = {
601
+ width: number
602
+ height: number
603
+ fps: number
604
+ render?: RenderConfig
605
+ }
606
+
607
+ export type Rect = { left: number; top: number; width: number; height: number }
608
+
609
+ export type FramingTarget = string | Element | Rect | FramingTarget[]
610
+
611
+ export type Framing = { scale: number; x: number; y: number }
612
+
613
+ export type FramingOpts = {
614
+ scale?: number // explicit zoom level; skips fitting
615
+ fitWidth?: number // target occupies this fraction of the frame width
616
+ fitHeight?: number // target occupies this fraction of the frame height
617
+ maxScale?: number
618
+ offsetX?: number // nudge the framed center, in scene px
619
+ offsetY?: number
620
+ clamp?: boolean // keep the viewport inside the stage (no black past edges)
621
+ }
622
+
623
+ export type TypeOpts = {
624
+ cps?: number // characters per second
625
+ caret?: boolean
626
+ }
627
+
628
+ export type TweenOverride = {
629
+ sig?: string
630
+ start?: number // timeline position, seconds
631
+ duration?: number
632
+ ease?: string
633
+ stagger?: number
634
+ vars?: Record<string, number | string> // end values (scale, x, autoAlpha, …)
635
+ }
636
+
637
+ export type TextStyleOverride = {
638
+ fontFamily?: string
639
+ fontSize?: number
640
+ fontWeight?: number
641
+ textAlign?: 'left' | 'center' | 'right'
642
+ color?: string
643
+ x?: number
644
+ y?: number
645
+ hidden?: boolean
646
+ }
647
+
648
+ export type ObjectPositionOverride = {
649
+ x?: number
650
+ y?: number
651
+ scaleX?: number
652
+ scaleY?: number
653
+ rotation?: number
654
+ hidden?: boolean
655
+ }
656
+
657
+ export type AddedTextLayer = {
658
+ text: string
659
+ x: number
660
+ y: number
661
+ width: number
662
+ }
663
+
664
+ export type SceneOverrides = {
665
+ tweens?: Record<string, TweenOverride>
666
+ cameraSets?: Record<string, Partial<Framing>> // camera.set calls, by order
667
+ text?: Record<string, string> // stable text-fragment id -> replacement copy
668
+ textStyles?: Record<string, TextStyleOverride>
669
+ addedText?: Record<string, AddedTextLayer>
670
+ objects?: Record<string, ObjectPositionOverride> // stable UI wrapper id -> scene-pixel offset
671
+ }
672
+
673
+ export type TweenKind =
674
+ | 'camera'
675
+ | 'element'
676
+ | 'group'
677
+ | 'typing'
678
+ | 'caret'
679
+ | 'hold'
680
+ // Kit proxy tweens (see src/stage/kits.ts KitProxy). Without their own
681
+ // kinds they fall through to 'hold', which labels them "Hold the frame",
682
+ // HIDES their ease selector (studio/main.tsx:2792) and files them under the
683
+ // "pause" filter chip — three real defects.
684
+ | 'filter'
685
+ | 'mask'
686
+ | 'axis'
687
+ | 'three'
688
+
689
+ export type TweenInfo = {
690
+ id: number
691
+ sig: string
692
+ kind: TweenKind
693
+ label: string
694
+ start: number
695
+ duration: number // per-target duration (excludes stagger span)
696
+ total: number // full span including stagger
697
+ ease: string // '' = gsap default
698
+ stagger?: number
699
+ vars: Record<string, number | string>
700
+ overridden: boolean
701
+ }
702
+
703
+ export type CameraSetInfo = Framing & { id: number; overridden: boolean }
704
+
705
+ export type SceneInspection = {
706
+ config: Output
707
+ duration: number
708
+ tweens: TweenInfo[]
709
+ cameraSets: CameraSetInfo[]
710
+ overrides: SceneOverrides
711
+ }
712
+
713
+ export type DirectionContext = {
714
+ gsap: typeof gsap
715
+ tl: gsap.core.Timeline // the master timeline (created paused)
716
+ stage: HTMLElement // scene wrapper the camera transforms
717
+ config: Output
718
+ q: <T extends HTMLElement>(selector: string) => T
719
+ qa: (selector: string) => HTMLElement[]
720
+ camera: {
721
+ framing: (target: FramingTarget, opts?: FramingOpts) => Framing
722
+ full: () => Framing
723
+ set: (framing: Framing) => void
724
+ move: (
725
+ framing: Framing,
726
+ vars?: gsap.TweenVars,
727
+ position?: gsap.Position
728
+ ) => void
729
+ }
730
+ /** Deterministic, seek-driven typing with a fake caret. */
731
+ typeInto: (
732
+ target: string | HTMLTextAreaElement | HTMLInputElement,
733
+ text: string,
734
+ opts?: TypeOpts,
735
+ position?: gsap.Position
736
+ ) => { duration: number; caret: HTMLElement | null }
737
+ /** Rect the finished text will occupy in `target` (for framing the close-up). */
738
+ measureTypedLine: (
739
+ target: string | HTMLTextAreaElement | HTMLInputElement,
740
+ text: string
741
+ ) => Rect
742
+ /** Rect of an element's rendered text content (a block element's box
743
+ * spans its full column — this measures just the text). */
744
+ textRect: (target: string | Element) => Rect
745
+ /** gsap SplitText — animate .chars / .words / .lines. */
746
+ split: (
747
+ target: string | Element,
748
+ vars?: SplitText.Vars
749
+ ) => SplitText
750
+ /** Empty tween that extends the timeline (a beat / settle). */
751
+ hold: (duration: number, position?: gsap.Position) => void
752
+ /** Timeline-driven MP4/WebM playback: scrubs the element's currentTime,
753
+ * so scrubbing and export frames are exact (a free-running <video> would
754
+ * shimmer — never autoplay one). `from` = source offset in seconds;
755
+ * `duration` defaults to the rest of the source. Returns `{ duration }`.
756
+ * The element is muted and paused; exports wait for each frame's decode. */
757
+ video: (
758
+ target: string | HTMLVideoElement,
759
+ opts?: { from?: number; duration?: number; playbackRate?: number },
760
+ position?: gsap.Position
761
+ ) => { duration: number }
762
+ /** Named text animations (built on SplitText, all deterministic).
763
+ * `text.reveal(el, effect)` in: rise, fall, blur, wave, flip, pop, slide,
764
+ * typewriter, scramble, wipe, bounce, elastic, arc, streak. `text.exit(el,
765
+ * effect)` out: rise, fall, blur, fade, pop, scramble, wipe, scatter,
766
+ * streak. `text.count(el, { to, from?, duration? })` counts a number up on
767
+ * the timeline (stat pills). `text.emphasize(el, 'pulse'|'wiggle'|'shake')`
768
+ * is a settle-back gesture for CTAs/accent words. `text.underline(el)`
769
+ * draws a sketched stroke under a tight element.
770
+ *
771
+ * `transition.out(style)` / `transition.in(style)` cover/reveal the whole
772
+ * FRAME with a color (overlay outside the camera): styles 'wipe',
773
+ * 'pixels', 'iris', opts `{ color?, direction?: 'left'|'right'|'up'|
774
+ * 'down', duration?, cell?, at? }`. `out` with no position appends at the
775
+ * clip's end — the standard way to end a clip for a cut; `in` at position
776
+ * 0 starts the clip covered and reveals it. All return `{ duration }` (full span including
777
+ * stagger) for sequencing, and accept `(target, effect, opts?, position?)`
778
+ * with opts `{ by?: 'chars'|'words'|'lines', duration?, stagger?, ease?,
779
+ * from?: 'start'|'center'|'end'|'edges' }`. */
780
+ text: TextKit
781
+ /** Named logo/brand-mark animations (all deterministic). Target the
782
+ * element wrapping the mark — an inline `<svg>`, an `<img>`, or a div
783
+ * around one. `logo.reveal(el, effect)` in: pop, blur, rise, wipe, iris,
784
+ * slit, spin, flip, bounce, glitch, draw, build. `draw` (strokes trace the
785
+ * SVG paths, fills crossfade in — works even on fills-only logos) and
786
+ * `build` (shapes assemble one by one) need INLINE SVG with path/shape
787
+ * elements; on an `<img>` or path-less SVG they fall back to `wipe` with a
788
+ * console warning. `logo.exit(el, effect)` out: pop, blur, fade, wipe,
789
+ * iris, drop. `logo.emphasize(el, 'pulse'|'breathe'|'wiggle')` is a
790
+ * settle-back gesture (breathe takes `repeats`, finite). `logo.shine(el)`
791
+ * sweeps a glint across the mark, masked by the logo's own silhouette.
792
+ * All return `{ duration }` (full span including stagger) and accept
793
+ * `(target, effect, opts?, position?)` with opts `{ duration?, ease?,
794
+ * stagger?, direction?: 'left'|'right'|'up'|'down' }`. */
795
+ logo: LogoKit
796
+ transition: TransitionKit
797
+ /** Real 3D — a Three.js layer under the same paused timeline. Use for
798
+ * depth After Effects fakes and CSS perspective cannot reach: real
799
+ * cameras, lights, materials. `const L = three.layer({ camera: {z: 6} })`
800
+ * gives `{ scene, camera, canvas, drive }` — add meshes and lights to
801
+ * `L.scene` (import * as THREE from 'three'), animate positions/rotations
802
+ * with ordinary `tl.to(mesh.position, {...})` tweens, then
803
+ * `L.drive(duration, (t) => {...}, opts?, position?)` renders the layer
804
+ * over that span (set uniforms/camera from `t` ALONE — no clocks, no
805
+ * setAnimationLoop, no accumulation; the frame must be a pure function of
806
+ * timeline position). The canvas is a normal stage element: tween its
807
+ * opacity, put fx/mask on it, cut it with tl.set. */
808
+ three: ThreeKit
809
+ /** Variable-font axis animation — type whose WEIGHT is the thing that
810
+ * moves. After Effects cannot do this at all. `axis.animate(el, axis,
811
+ * effect, opts?, position?)`: axes `wght` (weight), `wdth` (width), `slnt`
812
+ * (slant), `opsz` (optical size), or any custom axis name; effects `ramp`
813
+ * (go and hold), `pulse` (one breath), `wave` (travels through the
814
+ * characters). Opts `{ from?, to?, duration?, ease?, stagger? }`. On a
815
+ * non-variable font it degrades honestly (wght -> weight staircase, wdth ->
816
+ * scaleX, slnt -> skewX) with a console warning, never a throw. Geist (the
817
+ * house family) IS variable — weight 100-900. */
818
+ axis: AxisKit
819
+ /** The soundtrack's offline analysis (music/analysis/<track>.json).
820
+ * `audio.beat(n)` / `audio.downbeats` place cuts on the REAL grid — the
821
+ * downbeat phase is measured, not assumed to start at 0. `audio.onsets`
822
+ * are the strongest hits; land impact beats on them. `audio.drive(el, map)`
823
+ * makes a property breathe with the track. When no analysis exists,
824
+ * `audio.live` is false and every envelope reads 0 — never throws. */
825
+ audio: AudioKit
826
+ /** Ask the exporter for TRUE temporal motion blur over an interval —
827
+ * sub-frame supersampling at render time, the thing a real-time tool
828
+ * cannot do. Put it on fast camera moves and whip cuts; the preview does
829
+ * not show it (it renders one sample per frame), the MP4 does.
830
+ * `ctx.blur(from, to)` marks [from, to] seconds. Opts: `shutter` degrees
831
+ * (0-360, default 180 — wider smears more), `tier` 'light' (4 samples) or
832
+ * 'full' (8, final renders). Costs render time roughly x samples over the
833
+ * marked intervals only, so mark moves, not holds. */
834
+ blur: (
835
+ from: number,
836
+ to: number,
837
+ opts?: { shutter?: number; tier?: 'light' | 'full' }
838
+ ) => void
839
+ /** Register async prep (texture decode, analysis fetch) started inside the
840
+ * direction. buildScene awaits every registered promise before setting
841
+ * window.__ready, so the exporter never screenshots a half-loaded scene.
842
+ * For kit authors — a direction rarely needs it directly. */
843
+ __await: (work: Promise<unknown>) => void
844
+ /** Visual effects — CSS filters and SVG filter primitives, all
845
+ * deterministic. Three verbs, all `(target, effect, opts?, position?)`:
846
+ * `fx.apply` ramps an effect on and leaves it, `fx.clear` ramps it off,
847
+ * and `fx.spike` does both in ONE gesture (one Tweak row, not two).
848
+ *
849
+ * Effects: `blur` (gaussian), `glow` (halo in the element's own colour),
850
+ * `saturate`, `dim` (brightness), `hue` (rotation), `smear` (DIRECTIONAL
851
+ * blur along `angle` — CSS blur cannot do this), `chroma` (chromatic
852
+ * aberration, the glitch look), `displace` (turbulence warp: heat haze,
853
+ * liquid dissolve), `grain` (film grain), `bloom` (bright-pass glow),
854
+ * `threshold` (posterize to N levels).
855
+ *
856
+ * Opts `{ amount?, detail?, duration?, ease?, angle?, color? }` — `amount`
857
+ * is the effect's peak, `detail` its secondary constant (turbulence
858
+ * frequency, bloom radius), `angle` in degrees for smear/chroma.
859
+ *
860
+ * The whip-pan cut is `fx.spike(stage, 'blur', {}, 'whip1')` alongside a
861
+ * fast `camera.move` — do NOT hand-write a pair of
862
+ * `tl.to(stage, { filter: 'blur(12px)' })` tweens, which produces two
863
+ * unsynced rows in the Tweak panel. */
864
+ fx: FilterKit
865
+ /** Masks, mattes, and blend composition — the things `clip-path` cannot do.
866
+ *
867
+ * `mask.reveal(el, shape, opts?, position?)` and `mask.hide(...)` animate
868
+ * any element through a moving mask. Shapes: `wipe` (hard directional
869
+ * edge), `feather` (SOFT directional edge — clip-path cannot do this),
870
+ * `iris` (hard circle), `radial` (soft circle), `slit` (letterbox opening),
871
+ * `barn` (panels part from centre), `blinds` (N bands), `shape` (your own
872
+ * clip-path via `opts.shape`). Opts `{ direction?: 'left'|'right'|'up'|
873
+ * 'down', softness?: 0-1, bands?, at?: {x,y}, duration?, ease? }`.
874
+ * Prefer `feather` over `wipe` for anything soft or premium — a hard edge
875
+ * on a full-frame reveal reads as cheap.
876
+ *
877
+ * `mask.matte(target, source, opts?, position?)` makes `source`'s
878
+ * silhouette the target's visibility — v1 snapshots the source ONCE, so
879
+ * animating the source will not move the matte; use `{ sweep: true }` to
880
+ * travel it across the target instead.
881
+ *
882
+ * `mask.fill(el, { gradient? | image?, drift? })` fills an element's GLYPHS
883
+ * with a live layer and drifts it (background-clip: text).
884
+ *
885
+ * `mask.blend(el, mode, opts?, position?)` composites a layer against its
886
+ * beat. Modes: screen, multiply, overlay, difference, exclusion,
887
+ * color-dodge, soft-light, luminosity, plus-lighter, normal. `screen` for
888
+ * light on dark, `multiply` for ink on cream, `luminosity` for duotone. */
889
+ mask: MaskKit
890
+ }
891
+
892
+ export type Direction = (ctx: DirectionContext) => void | Promise<void>
893
+
894
+ // ─── Named eases ──────────────────────────────────────────────────────
895
+
896
+ // ─── The promo easing set ─────────────────────────────────────────────────
897
+ // Named curves for the "product promo" motion language (the ginyboi /
898
+ // Apple-promo feel): elements snap in fast and settle long, cameras glide,
899
+ // exits accelerate. These are the exact cubic-beziers from the house
900
+ // animation guidelines, registered once so directions, blocks, and the
901
+ // Tweak panel can all reference them by name.
902
+ //
903
+ // promo-out ease-out-expo (.19, 1, .22, 1) — entrances, card pops
904
+ // promo-soft ease-out-quint (.23, 1, .32, 1) — gentler entrances, settles
905
+ // promo-inout in-out-quint (.86, 0, .07, 1) — camera pushes, recenters
906
+ // promo-in ease-in-quart (.895,.03,.685,.22) — exits, dives
907
+ //
908
+ // Rule of thumb from the reference promos: entrances ~0.4–0.6s on promo-out
909
+ // (visually settled inside 0.15s), line staggers ~0.2s, holds carry a slow
910
+ // linear scale drift (~4%/s, ease 'none'), pops that need bounce use
911
+ // back.out(1.8–2), and background/color cuts are HARD (no crossfade).
912
+
913
+ /** Registered ease names usable anywhere GSAP accepts an ease. */
914
+ export type PromoEase = 'promo-out' | 'promo-soft' | 'promo-inout' | 'promo-in' | 'launch-out'