@impractical-ai/motion 0.1.5 → 0.1.7
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.
- package/README.md +19 -0
- package/assets/engine.d.ts +793 -5
- package/assets/impractical/SKILL.md +53 -6
- package/assets/kits/actions.ts +803 -0
- package/assets/kits/ensemble.ts +483 -0
- package/assets/kits/highlight.ts +374 -0
- package/assets/kits/impact.ts +103 -0
- package/assets/kits/kits.ts +343 -0
- package/assets/kits/looks.ts +935 -0
- package/assets/kits/morphkit.ts +438 -0
- package/assets/kits/reveal.ts +1364 -0
- package/assets/kits/sentence.ts +218 -0
- package/assets/kits/streamkit.ts +195 -0
- package/dist/{api-KD6BNKBQ.js → api-DR3NJ7TI.js} +5 -2
- package/dist/chunk-77HVPD4G.js +43 -0
- package/dist/{chunk-DMAQPSA4.js → chunk-M7SHFM25.js} +222 -27
- package/dist/{chunk-7724464Z.js → chunk-MQKW7NFI.js} +65 -14
- package/dist/chunk-Z74SLKEU.js +67 -0
- package/dist/{commands-Y35XPDVH.js → commands-R3LJU2BW.js} +29 -4
- package/dist/index.js +6 -5
- package/dist/main-BEONOBG5.js +2218 -0
- package/dist/{stdio-ZFPBM6Z6.js → stdio-XUVQKDCB.js} +3169 -431
- package/package.json +4 -3
- package/dist/chunk-JDN3QHCX.js +0 -98
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Side-effect import: the draft-side working-views engine (Studio reveal
|
|
2
|
+
// verbs on the currently deployed engine). Every kit imports this module,
|
|
3
|
+
// so any kit-using draft carries it. It installs a nonce-gated message
|
|
4
|
+
// listener and stands down when the engine-side bridge takes over.
|
|
5
|
+
import './reveal'
|
|
6
|
+
|
|
7
|
+
// ─── Shared kit primitives ────────────────────────────────────────────────
|
|
8
|
+
// Cross-kit helpers that were each duplicated inline before. `logo.ts` already
|
|
9
|
+
// imports `prand` from `./text`, so cross-kit imports are house style.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is deterministic: nothing reads a clock, and nothing that
|
|
12
|
+
// runs per frame allocates. The kits that use these still obey the five
|
|
13
|
+
// conventions (factory, every tween through `deps.tl`, return `{duration,…}`,
|
|
14
|
+
// `(target, effect, opts?, position?)`, deterministic only) — plus a sixth
|
|
15
|
+
// that was unwritten until now:
|
|
16
|
+
//
|
|
17
|
+
// 6. Kit DOM appends to `stage`, not `stage.parentElement`. The stage div
|
|
18
|
+
// carries `key={build.tick}` (src/stage/main.tsx:1279), so it is
|
|
19
|
+
// destroyed and recreated on every rebuild — anything under it is
|
|
20
|
+
// auto-cleaned. `transition.ts` self-clears its overlays precisely
|
|
21
|
+
// because it mounts on the parent to escape the camera transform.
|
|
22
|
+
|
|
23
|
+
/** Marker for a kit's proxy-tween target.
|
|
24
|
+
*
|
|
25
|
+
* It lives on the tween TARGET, never in `vars`, for two reasons: the Tweak
|
|
26
|
+
* panel enumerates `tween.vars` (engine.ts:1003-1025) and would otherwise show
|
|
27
|
+
* `kit`/`on` as bogus text controls, while `kindOf` (engine.ts:658-670) reads
|
|
28
|
+
* `targets[0]` and can classify from here. Without a marker a proxy tween is
|
|
29
|
+
* classified `'hold'`, which labels it "Hold the frame", HIDES its ease
|
|
30
|
+
* selector (main.tsx:2792), and files it under the "pause" filter chip. */
|
|
31
|
+
export type KitProxy = {
|
|
32
|
+
kit: 'filter' | 'mask' | 'axis' | 'three' | 'audio'
|
|
33
|
+
/** Human label for the element the proxy drives — `labelForTween` uses it. */
|
|
34
|
+
on: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A short, stable description of an element for proxy-tween labels. Mirrors
|
|
38
|
+
* what `describeTarget` (engine.ts:635-644) produces for real element tweens,
|
|
39
|
+
* so the Tweak panel reads consistently across both. */
|
|
40
|
+
export function labelOf(el: Element): string {
|
|
41
|
+
const data = Array.from((el as HTMLElement).attributes ?? [])
|
|
42
|
+
.map((a) => a.name)
|
|
43
|
+
.find((n) => n.startsWith('data-') && n !== 'data-block')
|
|
44
|
+
if (data) return `[${data}]`
|
|
45
|
+
if (el.id) return `#${el.id}`
|
|
46
|
+
const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0]
|
|
47
|
+
return cls ? `.${cls}` : el.tagName.toLowerCase()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One `<svg><defs>` host per build, for SVG filters and masks.
|
|
51
|
+
*
|
|
52
|
+
* Created LAZILY on first use, not in `buildScene`'s prep zone
|
|
53
|
+
* (engine.ts:717-741). The prep zone is for things that must exist *before the
|
|
54
|
+
* direction runs* (fonts, video metadata); a defs host is needed only if a kit
|
|
55
|
+
* that uses one is actually called. Putting it in the prep zone would make all
|
|
56
|
+
* 145 scenes pay for a DOM node they never touch and would couple engine.ts to
|
|
57
|
+
* kit internals. The precedent is `transition.ts`'s lazy `makeRoot()`.
|
|
58
|
+
*
|
|
59
|
+
* Ids are per-build sequential, so preview and export of the same scene
|
|
60
|
+
* produce identical markup. */
|
|
61
|
+
export type DefsHost = {
|
|
62
|
+
def: (prefix: string, node: SVGElement) => string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createDefsHost(stage: HTMLElement): DefsHost {
|
|
66
|
+
const ns = 'http://www.w3.org/2000/svg'
|
|
67
|
+
let defs: SVGDefsElement | null = null
|
|
68
|
+
let n = 0
|
|
69
|
+
return {
|
|
70
|
+
def(prefix, node) {
|
|
71
|
+
if (!defs) {
|
|
72
|
+
const svg = document.createElementNS(ns, 'svg')
|
|
73
|
+
svg.setAttribute('data-kit-defs', '')
|
|
74
|
+
svg.setAttribute('width', '0')
|
|
75
|
+
svg.setAttribute('height', '0')
|
|
76
|
+
svg.style.cssText =
|
|
77
|
+
'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;'
|
|
78
|
+
defs = document.createElementNS(ns, 'defs')
|
|
79
|
+
svg.appendChild(defs)
|
|
80
|
+
stage.appendChild(svg)
|
|
81
|
+
}
|
|
82
|
+
const id = `kit-${prefix}-${n++}`
|
|
83
|
+
node.setAttribute('id', id)
|
|
84
|
+
defs.appendChild(node)
|
|
85
|
+
return id
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Serialize an element's silhouette to a data-URI mask value, from a CLEAN
|
|
91
|
+
* clone. Lifted from `logo.shine` (logo.ts:601-617), which needs the cleaning
|
|
92
|
+
* because by the time it runs, reveal effects have written inline styles onto
|
|
93
|
+
* the live shapes — `draw` zeroes every `stroke-dasharray` and hides fills,
|
|
94
|
+
* which would serialize into a fully transparent mask, and a transparent mask
|
|
95
|
+
* hides everything.
|
|
96
|
+
*
|
|
97
|
+
* Returns `null` when there is no usable source, so callers can warn and
|
|
98
|
+
* degrade rather than silently masking an element to nothing. */
|
|
99
|
+
export function silhouetteUrl(source: Element): string | null {
|
|
100
|
+
if (source instanceof HTMLImageElement) return `url("${source.src}")`
|
|
101
|
+
const svg =
|
|
102
|
+
source instanceof SVGSVGElement
|
|
103
|
+
? source
|
|
104
|
+
: (source.querySelector('svg') as SVGSVGElement | null)
|
|
105
|
+
if (svg) {
|
|
106
|
+
const clean = svg.cloneNode(true) as SVGSVGElement
|
|
107
|
+
for (const node of [clean, ...Array.from(clean.querySelectorAll('*'))]) {
|
|
108
|
+
node.removeAttribute('style')
|
|
109
|
+
node.removeAttribute('stroke-dasharray')
|
|
110
|
+
node.removeAttribute('stroke-dashoffset')
|
|
111
|
+
}
|
|
112
|
+
const markup = new XMLSerializer().serializeToString(clean)
|
|
113
|
+
return `url("data:image/svg+xml;utf8,${encodeURIComponent(markup)}")`
|
|
114
|
+
}
|
|
115
|
+
const img = source.querySelector('img') as HTMLImageElement | null
|
|
116
|
+
return img ? `url("${img.src}")` : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Freeze per-unit widths so a property that changes glyph advance can't
|
|
120
|
+
* reflow the line mid-tween. Third occurrence of this pattern (text.ts:300-306
|
|
121
|
+
* for scramble, text.ts:528-533 for count), which is why it lives here.
|
|
122
|
+
*
|
|
123
|
+
* Matters most for variable-font `wght`: an unlocked weight wave makes the
|
|
124
|
+
* whole line breathe horizontally as it passes. */
|
|
125
|
+
export function lockWidths(units: HTMLElement[]): void {
|
|
126
|
+
// Measure every unit BEFORE mutating any of them — setting `display` on unit
|
|
127
|
+
// i changes layout for unit i+1, so an interleaved read/write loop records
|
|
128
|
+
// widths that are already wrong.
|
|
129
|
+
const widths = units.map((u) => u.offsetWidth)
|
|
130
|
+
units.forEach((u, i) => {
|
|
131
|
+
u.style.width = `${widths[i]}px`
|
|
132
|
+
u.style.display = 'inline-block'
|
|
133
|
+
u.style.textAlign = 'center'
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Give an element's beat its own stacking context so a blend mode composites
|
|
138
|
+
* against that beat and not the whole frame.
|
|
139
|
+
*
|
|
140
|
+
* `mix-blend-mode` composites against the nearest stacking context. Assembled
|
|
141
|
+
* videos hide sections with `autoAlpha: 0` (studio/blocks.ts:1888) and
|
|
142
|
+
* `opacity < 1` creates one — so a blend inside a HIDDEN block is already
|
|
143
|
+
* contained. But a VISIBLE block at opacity 1 (`absolute inset-0`, no
|
|
144
|
+
* z-index) does not create one, so its blend would reach past its own beat and
|
|
145
|
+
* composite against whatever sits underneath. One line fixes it, and without
|
|
146
|
+
* it blends are subtly wrong in exactly the assembled-video case the product
|
|
147
|
+
* generates most. */
|
|
148
|
+
export function isolateBlock(el: Element, stage: HTMLElement): void {
|
|
149
|
+
const host = (el.closest('[data-block]') as HTMLElement | null) ?? stage
|
|
150
|
+
host.style.isolation = 'isolate'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Composes a multi-slot CSS property (`filter`, `mask-image`) so two kits, or
|
|
154
|
+
* two effects from one kit, can drive the same element without clobbering each
|
|
155
|
+
* other's style string.
|
|
156
|
+
*
|
|
157
|
+
* Whatever the element already carried is captured once as the base and always
|
|
158
|
+
* emitted first, so an authored `filter: drop-shadow(...)` survives a later
|
|
159
|
+
* `fx.apply(el, 'blur')`. */
|
|
160
|
+
export type CssChain = {
|
|
161
|
+
/** Register `value` under `slot` and rewrite the property. Idempotent. */
|
|
162
|
+
add: (el: HTMLElement, slot: string, value: string) => void
|
|
163
|
+
/** Drop a slot and rewrite. */
|
|
164
|
+
remove: (el: HTMLElement, slot: string) => void
|
|
165
|
+
/** Is this slot already registered on this element? */
|
|
166
|
+
has: (el: HTMLElement, slot: string) => boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function cssChain(prop: 'filter' | 'maskImage'): CssChain {
|
|
170
|
+
const slots = new WeakMap<HTMLElement, Map<string, string>>()
|
|
171
|
+
const bases = new WeakMap<HTMLElement, string>()
|
|
172
|
+
const joiner = prop === 'filter' ? ' ' : ', '
|
|
173
|
+
|
|
174
|
+
const write = (el: HTMLElement) => {
|
|
175
|
+
const map = slots.get(el)
|
|
176
|
+
const base = bases.get(el) ?? ''
|
|
177
|
+
const parts = [base, ...(map ? Array.from(map.values()) : [])].filter(Boolean)
|
|
178
|
+
const value = parts.join(joiner)
|
|
179
|
+
if (prop === 'filter') {
|
|
180
|
+
el.style.filter = value
|
|
181
|
+
} else {
|
|
182
|
+
el.style.setProperty('mask-image', value)
|
|
183
|
+
el.style.setProperty('-webkit-mask-image', value)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
add(el, slot, value) {
|
|
189
|
+
if (!bases.has(el)) {
|
|
190
|
+
const authored =
|
|
191
|
+
prop === 'filter'
|
|
192
|
+
? el.style.filter
|
|
193
|
+
: el.style.getPropertyValue('mask-image')
|
|
194
|
+
bases.set(el, authored && authored !== 'none' ? authored : '')
|
|
195
|
+
}
|
|
196
|
+
let map = slots.get(el)
|
|
197
|
+
if (!map) {
|
|
198
|
+
map = new Map()
|
|
199
|
+
slots.set(el, map)
|
|
200
|
+
}
|
|
201
|
+
map.set(slot, value)
|
|
202
|
+
write(el)
|
|
203
|
+
},
|
|
204
|
+
remove(el, slot) {
|
|
205
|
+
slots.get(el)?.delete(slot)
|
|
206
|
+
write(el)
|
|
207
|
+
},
|
|
208
|
+
has(el, slot) {
|
|
209
|
+
return slots.get(el)?.has(slot) ?? false
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Resolve a `string | Element` target against the stage, throwing the same
|
|
215
|
+
* shape of error `ctx.q` does (engine.ts:835-839) so a bad selector fails
|
|
216
|
+
* loudly at build time rather than silently animating nothing. */
|
|
217
|
+
export function resolveTarget(
|
|
218
|
+
stage: HTMLElement,
|
|
219
|
+
target: string | Element,
|
|
220
|
+
kind: string
|
|
221
|
+
): HTMLElement {
|
|
222
|
+
const el =
|
|
223
|
+
typeof target === 'string' ? stage.querySelector(target) : (target as Element)
|
|
224
|
+
if (!el) throw new Error(`${kind} target not found: ${String(target)}`)
|
|
225
|
+
return el as HTMLElement
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolve to many elements — the group form. Empty is an error for the same
|
|
229
|
+
* reason: an effect that animates nothing is a silent authoring bug. */
|
|
230
|
+
export function resolveTargets(
|
|
231
|
+
stage: HTMLElement,
|
|
232
|
+
target: string | Element | Element[],
|
|
233
|
+
kind: string
|
|
234
|
+
): HTMLElement[] {
|
|
235
|
+
if (Array.isArray(target)) return target as HTMLElement[]
|
|
236
|
+
if (typeof target !== 'string') return [target as HTMLElement]
|
|
237
|
+
const found = Array.from(stage.querySelectorAll(target)) as HTMLElement[]
|
|
238
|
+
if (!found.length) throw new Error(`${kind} target not found: ${target}`)
|
|
239
|
+
return found
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Element rect in AUTHORED-LAYOUT scene coords.
|
|
243
|
+
*
|
|
244
|
+
* gsap `from()` renders its from-state at BUILD time, so a live rect measured
|
|
245
|
+
* while a direction is being authored is displaced by whatever entrance tweens
|
|
246
|
+
* are mid-flight. Kits that place overlays or compute travel (actions,
|
|
247
|
+
* highlight, looks) want the RESTING layout, so this measures with every
|
|
248
|
+
* transform from the element up to and including the camera-carrying stage
|
|
249
|
+
* temporarily neutralized, then restores them. Scaling OUTSIDE the stage (the
|
|
250
|
+
* studio player fitting the composition to the window) divides out via the
|
|
251
|
+
* stage's own ratio. */
|
|
252
|
+
export function layoutRect(
|
|
253
|
+
stage: HTMLElement,
|
|
254
|
+
el: HTMLElement
|
|
255
|
+
): { left: number; top: number; width: number; height: number } {
|
|
256
|
+
return withNeutralizedTransforms(stage, el, (measure) => measure(el.getBoundingClientRect()))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Element rect in stage-local coords WITH authored transforms applied — where
|
|
260
|
+
* the element actually sits RIGHT NOW, with only the stage's own transform
|
|
261
|
+
* (camera + studio player fit) divided out. `layoutRect` answers "where does
|
|
262
|
+
* this element rest in the authored layout"; `visualRect` answers "where is it
|
|
263
|
+
* at this moment". Anything that AIMS at an element from stage space (cursor
|
|
264
|
+
* hotspots, rings, flights) wants visualRect resolved at action time — a
|
|
265
|
+
* container the direction has scaled mid-timeline moves its children away from
|
|
266
|
+
* their resting rects, and an aim taken from layoutRect lands beside the UI. */
|
|
267
|
+
export function visualRect(
|
|
268
|
+
stage: HTMLElement,
|
|
269
|
+
el: HTMLElement
|
|
270
|
+
): { left: number; top: number; width: number; height: number } {
|
|
271
|
+
const stageRect = stage.getBoundingClientRect()
|
|
272
|
+
const k = stage.offsetWidth ? stageRect.width / stage.offsetWidth : 1
|
|
273
|
+
const r = el.getBoundingClientRect()
|
|
274
|
+
return {
|
|
275
|
+
left: (r.left - stageRect.left) / k,
|
|
276
|
+
top: (r.top - stageRect.top) / k,
|
|
277
|
+
width: r.width / k,
|
|
278
|
+
height: r.height / k,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The TEXT rect of an element in authored-layout scene coords.
|
|
283
|
+
*
|
|
284
|
+
* A block element's box spans its full column even when its glyphs don't —
|
|
285
|
+
* the same tension `textRect` solves for camera framing. Annotations that
|
|
286
|
+
* decorate text (marker swipes, hand-drawn circles, underlines) must fit the
|
|
287
|
+
* GLYPHS, or a highlight on "99.99%" swallows half a card of empty space.
|
|
288
|
+
* Falls back to the element box when there is no text content. */
|
|
289
|
+
export function layoutTextRect(
|
|
290
|
+
stage: HTMLElement,
|
|
291
|
+
el: HTMLElement
|
|
292
|
+
): { left: number; top: number; width: number; height: number } {
|
|
293
|
+
return withNeutralizedTransforms(stage, el, (measure) => {
|
|
294
|
+
const range = document.createRange()
|
|
295
|
+
range.selectNodeContents(el)
|
|
296
|
+
const r = range.getBoundingClientRect()
|
|
297
|
+
return r.width > 0 && r.height > 0 ? measure(r) : measure(el.getBoundingClientRect())
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function withNeutralizedTransforms(
|
|
302
|
+
stage: HTMLElement,
|
|
303
|
+
el: HTMLElement,
|
|
304
|
+
read: (
|
|
305
|
+
measure: (r: DOMRect) => { left: number; top: number; width: number; height: number }
|
|
306
|
+
) => { left: number; top: number; width: number; height: number }
|
|
307
|
+
): { left: number; top: number; width: number; height: number } {
|
|
308
|
+
const touched: Array<{ node: HTMLElement; transform: string }> = []
|
|
309
|
+
let node: HTMLElement | null = el
|
|
310
|
+
while (node) {
|
|
311
|
+
if (node.style.transform) {
|
|
312
|
+
touched.push({ node, transform: node.style.transform })
|
|
313
|
+
node.style.transform = 'none'
|
|
314
|
+
}
|
|
315
|
+
if (node === stage) break
|
|
316
|
+
node = node.parentElement
|
|
317
|
+
}
|
|
318
|
+
const stageRect = stage.getBoundingClientRect()
|
|
319
|
+
const k = stage.offsetWidth ? stageRect.width / stage.offsetWidth : 1
|
|
320
|
+
const result = read((r) => ({
|
|
321
|
+
left: (r.left - stageRect.left) / k,
|
|
322
|
+
top: (r.top - stageRect.top) / k,
|
|
323
|
+
width: r.width / k,
|
|
324
|
+
height: r.height / k,
|
|
325
|
+
}))
|
|
326
|
+
for (const { node: touchedNode, transform } of touched) {
|
|
327
|
+
touchedNode.style.transform = transform
|
|
328
|
+
}
|
|
329
|
+
return result
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Warn once per (scope, key). Mirrors `logo.ts`'s `warned` Set: kits degrade
|
|
333
|
+
* with a console warning rather than throwing, because templates and the AI
|
|
334
|
+
* director can regenerate markup out from under a direction and a hard throw
|
|
335
|
+
* turns "renders flat" into "renders not at all". */
|
|
336
|
+
export function createWarner(scope: string) {
|
|
337
|
+
const seen = new Set<string>()
|
|
338
|
+
return (key: string, message: string) => {
|
|
339
|
+
if (seen.has(key)) return
|
|
340
|
+
seen.add(key)
|
|
341
|
+
console.warn(`[${scope}] ${message}`)
|
|
342
|
+
}
|
|
343
|
+
}
|