@px-petals/roku.motion-web 0.0.0-stage → 1.0.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.
package/list/list.js ADDED
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Lists: a view-model list property drawn as one instance of a row artboard
3
+ * for each item, as Rive's ArtboardComponentList does.
4
+ *
5
+ * A bundle's `lists` names each list: its view-model path, the row artboards
6
+ * it draws (a Lottie each, with the row view model's slots), the flex of the
7
+ * container the rows sit in, and the container's box in each layout band.
8
+ * This player compiles the row's Lottie once for each item, so every row
9
+ * has its own slots, filled from the item's instance through the bind's
10
+ * converters, and lays the rows out with `list.flex.js`. A row with a state
11
+ * machine runs it for each item, with the runner nested artboards use.
12
+ *
13
+ * The rows are drawn over the animation, after it. A host changes them with
14
+ * `setInputs({ rows: [ ... ] })`: a row whose item has not changed stays as
15
+ * it is, with its machine's state.
16
+ *
17
+ * Not done: pointer listeners and text inside a row, and the list's own
18
+ * transform and opacity.
19
+ */
20
+
21
+ import { createAnimation } from '../lottie/lottie.js'
22
+ import { multiply } from '../lottie/lottie.paths.js'
23
+ import { createNested, extend } from '../motion/motion.nested.js'
24
+ import { slotValues } from '../motion/motion.values.js'
25
+ import { layoutRows } from './list.flex.js'
26
+ import { itemValues, rowNameOf, signature } from './list.items.js'
27
+
28
+ /** @import { Animation } from '../lottie/lottie.js' */
29
+
30
+ /**
31
+ * The scope a row's machine runs its focus in: `row.<id>` for the row's own, `row.<id>/<sid>` for a nested artboard in it.
32
+ *
33
+ * @param {number} id
34
+ * @param {string} sid the machine's, `row` for the row's own
35
+ * @returns {string}
36
+ */
37
+ function scopeOf(id, sid) {
38
+ return sid === 'row' ? 'row.' + id : 'row.' + id + '/' + sid
39
+ }
40
+
41
+ /**
42
+ * @typedef {object} Row
43
+ * @property {string} sig which item it was made for
44
+ * @property {any} entry the list's description of its row artboard
45
+ * @property {Animation} anim its own compiled copy of the row's Lottie
46
+ * @property {ReturnType<typeof createNested> | null} runner its machine, when the row has one
47
+ * @property {number} id which row it is, for the pointer's names
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} Options
52
+ * @property {string} base where the row Lotties' images resolve from
53
+ * @property {() => Record<string, string>} [families] the bundle's fonts by name, as registered, which a text in a row is drawn in
54
+ * @property {() => number} band the width of the layout band showing, 0 for the file's own size
55
+ * @property {() => number} [blended] between two bands that blend, the canvas's width, else 0: the box is then the mix of the two bands'
56
+ * @property {() => (import('../bands/bands.js').Cell | null)} [cell] where the canvas falls in the layout grid, while the fit is layout: the box is then the (bilinear) mix of the cell's corners'; it replaces `band` and `blended`
57
+ * @property {(message: string) => void} error
58
+ * @property {(action: any, scope: string) => void} focus a row's machine ran a focus action: FocusTarget with the machine's own rid, FocusMove or FocusClear; `scope` is the machine (`row.<id>`, or `row.<id>/<sid>` for a nested artboard in the row)
59
+ */
60
+
61
+ /**
62
+ * @param {any[] | undefined} entries a bundle's `lists`
63
+ * @param {Record<string, any>} lotties the rows' Lotties, by file name
64
+ * @param {Options} options
65
+ */
66
+ export function createLists(entries, lotties, options) {
67
+ /** @type {{ entry: any, rows: Row[] }[]} */
68
+ const lists = []
69
+ for (let n = 0; n < (entries || []).length; n++) {
70
+ /** @type {Row[]} */
71
+ const rows = []
72
+ lists.push({ entry: entries ? entries[n] : null, rows })
73
+ }
74
+ let hasMachines = false
75
+ let nextRow = 1
76
+ /** whether the rows changed since the pointer last looked: they hold its targets */
77
+ let moved = false
78
+ /** how many times the rows changed: the focus tree has their nodes */
79
+ let version = 0
80
+ /** @type {Row[]} rows made since the focus tree last took them in */
81
+ let fresh = []
82
+
83
+ /**
84
+ * @param {any} list
85
+ * @param {any} item
86
+ * @returns {Row | null}
87
+ */
88
+ function makeRow(list, item) {
89
+ const name = rowNameOf(item, list.entry.rows)
90
+ if (name === null) {
91
+ options.error("list '" + list.entry.path + "' has no row for " + signature(item))
92
+ return null
93
+ }
94
+ const entry = list.entry.rows[name]
95
+ const lottie = lotties[entry.lottie]
96
+ if (!lottie) return null
97
+
98
+ const anim = createAnimation(lottie, { base: options.base, families: options.families })
99
+ const values = itemValues(item, entry.data)
100
+ const table = slotValues(entry.data, values, options.error)
101
+ for (const sid in table) anim.slots[sid] = table[sid]
102
+
103
+ // the row's own machine, and the machines of the nested artboards in it: each runs on the row's clock
104
+ const machines = entry.machine ? [ { sid: 'row', fps: entry.machine.fps, machine: entry.machine.machine, animations: entry.machine.animations, inputs: {}, remoteKeys: entry.remoteKeys } ] : []
105
+ for (let n = 0; n < (entry.nested || []).length; n++) machines.push(entry.nested[n])
106
+
107
+ const id = nextRow++
108
+ /** @type {Row['runner']} */
109
+ let runner = null
110
+ if (machines.length) {
111
+ runner = createNested(machines, {
112
+ focus: (action, sid) => options.focus(action, scopeOf(id, sid)),
113
+ // the row's own machine sets properties of the row's view model (a listener, a state's action): the slots follow
114
+ values: (sid, changed) => {
115
+ if (sid !== 'row') return
116
+ const table = slotValues(entry.data, extend(values, changed), options.error)
117
+ for (const slot in table) anim.slots[slot] = table[slot]
118
+ },
119
+ })
120
+ runner.takeInputs(values)
121
+ hasMachines = true
122
+ }
123
+ const row = { sig: signature(item), entry, anim, runner, id }
124
+ // the shapes its pointer listeners watch, and its focus nodes, are recorded as it draws
125
+ const watched = runner ? Object.keys(runner.pointerTargets()) : []
126
+ const nodes = entry.focus ? entry.focus.nodes : []
127
+ for (let n = 0; n < nodes.length; n++) if (nodes[n].rid !== '' && watched.indexOf(nodes[n].rid) < 0) watched.push(nodes[n].rid)
128
+ if (watched.length) anim.hits.watch(watched)
129
+ fresh.push(row)
130
+ return row
131
+ }
132
+
133
+ /**
134
+ * @param {string} name `<row>|<target>`
135
+ * @returns {{ row: Row, target: string } | null}
136
+ */
137
+ function rowOf(name) {
138
+ const at = name.indexOf('|')
139
+ if (at < 0) return null
140
+ const id = Number(name.slice(0, at))
141
+ for (let n = 0; n < lists.length; n++) {
142
+ for (let k = 0; k < lists[n].rows.length; k++) if (lists[n].rows[k].id === id) return { row: lists[n].rows[k], target: name.slice(at + 1) }
143
+ }
144
+ return null
145
+ }
146
+
147
+ /**
148
+ * @param {string} scope `row.<id>` or `row.<id>/<sid>`
149
+ * @returns {{ row: Row & { runner: NonNullable<Row['runner']> }, sid: string } | null}
150
+ */
151
+ function machineOf(scope) {
152
+ const at = scope.indexOf('/')
153
+ const id = Number((at < 0 ? scope : scope.slice(0, at)).slice(4))
154
+ for (let n = 0; n < lists.length; n++) {
155
+ for (let k = 0; k < lists[n].rows.length; k++) {
156
+ const row = lists[n].rows[k]
157
+ if (row.id === id && row.runner) return { row: /** @type {any} */ (row), sid: at < 0 ? 'row' : scope.slice(at + 1) }
158
+ }
159
+ }
160
+ return null
161
+ }
162
+
163
+ return {
164
+ /** How many lists the bundle has */
165
+ count: lists.length,
166
+
167
+ /**
168
+ * Brings every list's rows up to date with the view-model values. A row
169
+ * whose item is unchanged is kept.
170
+ *
171
+ * @param {Record<string, any>} values
172
+ */
173
+ update(values) {
174
+ for (let n = 0; n < lists.length; n++) {
175
+ const list = lists[n]
176
+ const items = values[list.entry.path]
177
+ if (!Array.isArray(items)) continue
178
+
179
+ /** @type {Row[]} */
180
+ const next = []
181
+ for (let k = 0; k < items.length; k++) {
182
+ const kept = list.rows[k] && list.rows[k].sig === signature(items[k]) ? list.rows[k] : null
183
+ const row = kept || makeRow(list, items[k])
184
+ if (row) next.push(row)
185
+ }
186
+ if (next.length !== list.rows.length || next.some((row, k) => row !== list.rows[k])) {
187
+ moved = true
188
+ version++
189
+ }
190
+ list.rows = next
191
+ }
192
+ },
193
+
194
+ /**
195
+ * Where the pointer listeners of the rows' machines are, each row's under
196
+ * its own name: `<row>|<target>`, a target as the row's machine names it.
197
+ *
198
+ * @returns {Record<string, string[]>}
199
+ */
200
+ pointerTargets() {
201
+ moved = false
202
+ /** @type {Record<string, string[]>} */
203
+ const found = {}
204
+ for (let n = 0; n < lists.length; n++) {
205
+ for (let k = 0; k < lists[n].rows.length; k++) {
206
+ const row = lists[n].rows[k]
207
+ if (!row.runner) continue
208
+ const targets = row.runner.pointerTargets()
209
+ for (const name in targets) found[row.id + '|' + name] = targets[name]
210
+ }
211
+ }
212
+ return found
213
+ },
214
+
215
+ /** Changes whenever rows come or go, which is when the focus tree needs their nodes again */
216
+ get version() {
217
+ return version
218
+ },
219
+
220
+ /**
221
+ * A list's rows' focus nodes, named by the host's tree: `row.<id>/<rid>`
222
+ * (and `row.<id>/<sid>/<rid>` for a nested artboard in the row). Their
223
+ * topmost node is inside `marker`, the node that holds the list's place.
224
+ *
225
+ * @param {string} listId the list's id (`list.1`)
226
+ * @param {string} marker the list's node in the host's tree
227
+ * @returns {any[]}
228
+ */
229
+ nodes(listId, marker) {
230
+ const found = []
231
+ for (let n = 0; n < lists.length; n++) {
232
+ if (lists[n].entry.id !== listId) continue
233
+ for (let k = 0; k < lists[n].rows.length; k++) {
234
+ const row = lists[n].rows[k]
235
+ const own = row.entry.focus ? row.entry.focus.nodes : []
236
+ const prefix = 'row.' + row.id + '/'
237
+ for (let j = 0; j < own.length; j++) {
238
+ const node = extend(own[j], { rid: prefix + own[j].rid })
239
+ if (own[j].rid === '') {
240
+ // the row's own artboard is a node of the tree, inside the list's
241
+ node.parent = marker
242
+ node.kind = 'group'
243
+ } else {
244
+ node.parent = own[j].parent === '' ? (own.some((/** @type {any} */ one) => one.rid === '') ? prefix : marker) : prefix + own[j].parent
245
+ }
246
+ if (node.name) node.name = 'row ' + row.id + '/' + node.name
247
+ found.push(node)
248
+ }
249
+ }
250
+ }
251
+ return found
252
+ },
253
+
254
+ /**
255
+ * The key bindings of the rows' focus nodes, named as `nodes` names them,
256
+ * with the machine each runs in as `scope`.
257
+ *
258
+ * @returns {any[]}
259
+ */
260
+ bindings() {
261
+ const found = []
262
+ for (let n = 0; n < lists.length; n++) {
263
+ for (let k = 0; k < lists[n].rows.length; k++) {
264
+ const row = lists[n].rows[k]
265
+ const prefix = 'row.' + row.id + '/'
266
+ const own = row.entry.remoteKeys || []
267
+ for (let j = 0; j < own.length; j++) found.push(extend(own[j], { target: prefix + own[j].target, scope: 'row.' + row.id }))
268
+ const inside = row.entry.nested || []
269
+ for (let j = 0; j < inside.length; j++) {
270
+ const keys = inside[j].remoteKeys || []
271
+ for (let i = 0; i < keys.length; i++) found.push(extend(keys[i], { target: prefix + inside[j].sid + '/' + keys[i].target, scope: 'row.' + row.id + '/' + inside[j].sid }))
272
+ }
273
+ }
274
+ }
275
+ return found
276
+ },
277
+
278
+ /**
279
+ * Focus actions the rows' machines ran as they started take effect: the
280
+ * focus tree has their nodes now.
281
+ */
282
+ flushFocus() {
283
+ const made = fresh
284
+ fresh = []
285
+ for (let n = 0; n < made.length; n++) {
286
+ const runner = made[n].runner
287
+ if (runner) runner.flushFocus()
288
+ }
289
+ },
290
+
291
+ /**
292
+ * A node of a row gained or lost focus.
293
+ *
294
+ * @param {'Focus' | 'Blur'} type
295
+ * @param {string} scope `row.<id>` or `row.<id>/<sid>`
296
+ * @param {string} rid the node in its machine's artboard
297
+ */
298
+ focusEvent(type, scope, rid) {
299
+ const found = machineOf(scope)
300
+ if (found && found.row.runner) found.row.runner.focusEvent(type, found.sid, rid)
301
+ },
302
+
303
+ /**
304
+ * Runs a key binding's actions in the machine of a row it belongs to.
305
+ *
306
+ * @param {string} scope `row.<id>` or `row.<id>/<sid>`
307
+ * @param {any[]} actions
308
+ */
309
+ act(scope, actions) {
310
+ const found = machineOf(scope)
311
+ if (found) found.row.runner.act(found.sid, actions)
312
+ },
313
+
314
+ /**
315
+ * Where a row's node was drawn, for the arrows.
316
+ *
317
+ * @param {string} rid `row.<id>/<rid>`
318
+ * @returns {{ x: number, y: number } | null}
319
+ */
320
+ center(rid) {
321
+ const at = rid.indexOf('/')
322
+ const found = at < 0 ? null : rowOf(rid.slice(4, at) + '|' + rid.slice(at + 1))
323
+ return found ? found.row.anim.hits.center(found.target) : null
324
+ },
325
+
326
+ /** Whether rows came or went since the pointer last asked for its targets */
327
+ get isPointerStale() {
328
+ return moved
329
+ },
330
+
331
+ /**
332
+ * Whether a canvas pixel is on a target of a row, in the last frame drawn.
333
+ *
334
+ * @param {string} name `<row>|<target>`
335
+ * @param {number} x
336
+ * @param {number} y
337
+ * @returns {boolean}
338
+ */
339
+ hit(name, x, y) {
340
+ const found = rowOf(name)
341
+ return !!found && found.row.anim.hits.test(found.target, x, y)
342
+ },
343
+
344
+ /**
345
+ * A pointer event on a target of a row's machine.
346
+ *
347
+ * @param {string} type
348
+ * @param {string} name `<row>|<target>`
349
+ * @returns {boolean} whether a row owns the target
350
+ */
351
+ pointer(type, name) {
352
+ const found = rowOf(name)
353
+ return !!found && !!found.row.runner && found.row.runner.pointer(type, found.target)
354
+ },
355
+
356
+ /**
357
+ * Moves the rows' machines on.
358
+ *
359
+ * @param {number} seconds
360
+ */
361
+ tick(seconds) {
362
+ for (let n = 0; n < lists.length; n++) {
363
+ for (let k = 0; k < lists[n].rows.length; k++) {
364
+ const runner = lists[n].rows[k].runner
365
+ if (runner) runner.tick(seconds)
366
+ }
367
+ }
368
+ },
369
+
370
+ /** Whether any row runs a machine, which keeps the clock going */
371
+ get isAnimated() {
372
+ return hasMachines
373
+ },
374
+
375
+ /**
376
+ * What each row of a list has now: the slot values its item gave it, and
377
+ * the frame of its Lottie it draws.
378
+ *
379
+ * @param {string} path the list's view-model path
380
+ * @returns {{ slots: Record<string, any>, frame: number }[]}
381
+ */
382
+ inspect(path) {
383
+ const found = []
384
+ for (let n = 0; n < lists.length; n++) {
385
+ if (lists[n].entry.path !== path) continue
386
+ for (let k = 0; k < lists[n].rows.length; k++) found.push({ slots: lists[n].rows[k].anim.slots, frame: rowFrame(lists[n].rows[k]) })
387
+ }
388
+ return found
389
+ },
390
+
391
+ /**
392
+ * Where each list's rows are, in the artboard of the layout band showing.
393
+ *
394
+ * @returns {{ path: string, rows: { x: number, y: number, width: number, height: number }[] }[]}
395
+ */
396
+ layout() {
397
+ /** @type {{ path: string, rows: { x: number, y: number, width: number, height: number }[] }[]} */
398
+ const found = []
399
+ for (let n = 0; n < lists.length; n++) {
400
+ const placed = place(lists[n], options.band(), options.blended ? options.blended() : 0, options.cell ? options.cell() : null)
401
+ const rows = []
402
+ for (let k = 0; k < lists[n].rows.length; k++) rows.push({ x: placed.left + placed.x[k], y: placed.top + placed.y[k], width: lists[n].rows[k].entry.width, height: lists[n].rows[k].entry.height })
403
+ found.push({ path: lists[n].entry.path, rows })
404
+ }
405
+ return found
406
+ },
407
+
408
+ /**
409
+ * Draws every row.
410
+ *
411
+ * @param {CanvasRenderingContext2D} ctx
412
+ * @param {import('../lottie/lottie.paths.js').Matrix} matrix the artboard's space onto the canvas
413
+ */
414
+ draw(ctx, matrix) {
415
+ for (let n = 0; n < lists.length; n++) {
416
+ const list = lists[n]
417
+ if (!list.rows.length) continue
418
+ const placed = place(list, options.band(), options.blended ? options.blended() : 0, options.cell ? options.cell() : null)
419
+ for (let k = 0; k < list.rows.length; k++) {
420
+ const row = list.rows[k]
421
+ const at = multiply(matrix, [ 1, 0, 0, 1, placed.left + placed.x[k], placed.top + placed.y[k] ])
422
+ ctx.save()
423
+ // a row is cut off at its own edge, as an artboard is
424
+ ctx.setTransform(at[0], at[1], at[2], at[3], at[4], at[5])
425
+ ctx.beginPath()
426
+ ctx.rect(0, 0, row.entry.width, row.entry.height)
427
+ ctx.clip()
428
+ row.anim.draw(ctx, rowFrame(row), at)
429
+ ctx.restore()
430
+ }
431
+ }
432
+ },
433
+ }
434
+ }
435
+
436
+ /**
437
+ * The frame of a row's Lottie to draw: the machine's, else the first. The
438
+ * machines of the nested artboards in the row set where their layers' time
439
+ * remaps are in the row's slots.
440
+ *
441
+ * @param {Row} row
442
+ * @returns {number}
443
+ */
444
+ function rowFrame(row) {
445
+ if (!row.runner) return row.anim.inPoint
446
+ row.runner.apply(row.anim.slots, row.anim.fps)
447
+ const time = row.anim.slots.row
448
+ if (time === undefined) return row.anim.inPoint
449
+ // a Lottie draws up to its out point, not at it: a finished machine holds the last frame it has
450
+ return Math.min(row.anim.inPoint + time * row.anim.fps, Math.max(row.anim.outPoint - 0.001, row.anim.inPoint))
451
+ }
452
+
453
+ /**
454
+ * The container's box at a band of the layout grid.
455
+ *
456
+ * @param {any} entry the list's description
457
+ * @param {{ width: number, height: number }} corner the band
458
+ * @returns {{ width: number, x: number, y: number, w: number, h: number } | null}
459
+ */
460
+ function boxAt(entry, corner) {
461
+ const all = entry.gridBoxes ? entry.boxes.concat(entry.gridBoxes) : entry.boxes
462
+ let found = null
463
+ // a box says its height unless the file is older than the grid
464
+ for (let n = 0; n < all.length; n++) if (all[n].width === corner.width && (all[n].height === undefined || all[n].height === corner.height)) found = all[n]
465
+ return found
466
+ }
467
+
468
+ /**
469
+ * The container's box in the band showing, and where its rows sit in it.
470
+ *
471
+ * @param {{ entry: any, rows: Row[] }} list
472
+ * @param {number} band the layout band's width, 0 for the file's own
473
+ * @param {number} blended the canvas's width between two blending bands, else 0
474
+ * @param {import('../bands/bands.js').Cell | null} [cell] where the canvas falls in the layout grid
475
+ * @returns {{ left: number, top: number, x: number[], y: number[] }}
476
+ */
477
+ function place(list, band, blended, cell) {
478
+ const boxes = list.entry.boxes
479
+ let box = boxes[0]
480
+ let at = 0
481
+ for (let n = 0; n < boxes.length; n++) {
482
+ const isOwn = band === 0 ? boxes[n].authored : boxes[n].width === band
483
+ if (isOwn) {
484
+ box = boxes[n]
485
+ at = n
486
+ }
487
+ }
488
+ const after = boxes[at + 1]
489
+ const corner = cell ? boxAt(list.entry, cell.corner) : null
490
+ if (cell && corner) {
491
+ // the container's box is bilinear in the size inside a cell that blends: a straight line along one side of it
492
+ const right = (cell.right && boxAt(list.entry, cell.right)) || corner
493
+ const up = (cell.up && boxAt(list.entry, cell.up)) || corner
494
+ const diag = (cell.diag && boxAt(list.entry, cell.diag)) || (cell.right ? right : up)
495
+ const mix = (/** @type {'x' | 'y' | 'w' | 'h'} */ key) => (corner[key] * (1 - cell.u) + right[key] * cell.u) * (1 - cell.v) + (up[key] * (1 - cell.u) + diag[key] * cell.u) * cell.v
496
+ box = { width: corner.width, authored: false, x: mix('x'), y: mix('y'), w: mix('w'), h: mix('h') }
497
+ } else if (blended > box.width && after && after.width > box.width) {
498
+ // the container's box is a straight line in the width between bands that blend
499
+ const t = Math.min(1, (blended - box.width) / (after.width - box.width))
500
+ const mix = (/** @type {number} */ a, /** @type {number} */ b) => a + (b - a) * t
501
+ box = { width: blended, authored: false, x: mix(box.x, after.x), y: mix(box.y, after.y), w: mix(box.w, after.w), h: mix(box.h, after.h) }
502
+ }
503
+ const sizes = []
504
+ for (let n = 0; n < list.rows.length; n++) sizes.push({ w: list.rows[n].entry.width, h: list.rows[n].entry.height })
505
+ const laid = layoutRows(list.entry.flex, box, sizes)
506
+ return { left: box.x, top: box.y, x: laid.x, y: laid.y }
507
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A Lottie animation drawn on a 2D canvas, for the subset Motion's bundles
3
+ * use: shape layers (groups, paths, rectangles, ellipses, stars, fills,
4
+ * strokes, gradients, dashes, trims, merges), precomps, solids, images,
5
+ * masks and mattes. Compiled once; each frame is drawn from scratch.
6
+ *
7
+ * `slots` is live: a slot id set there overrides the property carrying it,
8
+ * which is how live data reaches the animation without recompiling it.
9
+ */
10
+
11
+ import { createHits } from '../hit/hit.js'
12
+ import { compileLayers, drawLayers } from './lottie.layers.js'
13
+ import { evaluator } from './lottie.props.js'
14
+
15
+ /** @import { Matrix } from './lottie.paths.js' */
16
+ /** @import { Slots } from './lottie.props.js' */
17
+
18
+ /**
19
+ * @typedef {object} Animation
20
+ * @property {number} width
21
+ * @property {number} height
22
+ * @property {number} fps
23
+ * @property {number} inPoint first frame
24
+ * @property {number} outPoint the frame it ends at, exclusive
25
+ * @property {Slots} slots slot id → live value, read every frame
26
+ * @property {import('../hit/hit.js').Hits} hits where the last frame drew the shapes pointer listeners target
27
+ * @property {Promise<void>} ready resolves once its images have loaded
28
+ * @property {(sid: string, frame: number) => any} probe the value the property carrying a slot id has at a frame, as drawn (a live slot value included); undefined when there is none
29
+ * @property {(ctx: CanvasRenderingContext2D, frame: number, matrix: Matrix) => void} draw
30
+ */
31
+
32
+ /**
33
+ * @param {any} lottie parsed Lottie JSON
34
+ * @param {object} [options]
35
+ * @param {string} [options.base] where image paths that aren't data URIs resolve from
36
+ * @param {() => Record<string, string>} [options.families] the bundle's fonts by name, as registered, which a text a precomp holds is drawn in
37
+ * @returns {Animation}
38
+ */
39
+ export function createAnimation(lottie, options) {
40
+ const base = (options && options.base) || ''
41
+ /** @type {Slots} */
42
+ const slots = {}
43
+
44
+ /** @type {Record<string, any>} */
45
+ const assets = {}
46
+ for (let n = 0; n < (lottie.assets || []).length; n++) assets[lottie.assets[n].id] = lottie.assets[n]
47
+
48
+ const loading = loadImages(lottie.assets || [], base)
49
+ const hits = createHits()
50
+ const context = { assets, images: loading.images, fps: lottie.fr || 30, slots, hits, families: (options && options.families) || null }
51
+ const layers = compileLayers(lottie.layers, context)
52
+ /** @type {Record<string, import('./lottie.props.js').Evaluator> | null} */
53
+ let probes = null
54
+
55
+ return {
56
+ width: lottie.w || 0,
57
+ height: lottie.h || 0,
58
+ fps: lottie.fr || 30,
59
+ inPoint: lottie.ip || 0,
60
+ outPoint: lottie.op || 0,
61
+ slots,
62
+ hits,
63
+ ready: loading.ready,
64
+ probe(sid, frame) {
65
+ if (!probes) probes = collectProbes(lottie.layers, slots)
66
+ return probes[sid] ? probes[sid](frame) : undefined
67
+ },
68
+ draw(ctx, frame, matrix) {
69
+ hits.begin()
70
+ drawLayers(ctx, layers, context, frame, matrix, 1)
71
+ },
72
+ }
73
+ }
74
+
75
+ /**
76
+ * An evaluator for each slotted property in a layer list, by slot id; the
77
+ * first property to carry an id answers for it. Properties inside precomps
78
+ * run on their own clocks and are not probed.
79
+ *
80
+ * @param {any[]} layers
81
+ * @param {Slots} slots
82
+ * @returns {Record<string, import('./lottie.props.js').Evaluator>}
83
+ */
84
+ function collectProbes(layers, slots) {
85
+ /** @type {Record<string, import('./lottie.props.js').Evaluator>} */
86
+ const found = {}
87
+ const visit = (/** @type {any} */ value) => {
88
+ if (!value || typeof value !== 'object') return
89
+ if (typeof value.sid === 'string' && value.k !== undefined && !found[value.sid]) found[value.sid] = evaluator(value, slots, undefined)
90
+ for (const key in value) visit(value[key])
91
+ }
92
+ visit(layers)
93
+ return found
94
+ }
95
+
96
+ /**
97
+ * The image assets, loading. lottie-roku embeds every image as a data URI,
98
+ * so a bundle's load without a request.
99
+ *
100
+ * @param {any[]} assets
101
+ * @param {string} base
102
+ * @returns {{ images: Record<string, HTMLImageElement>, ready: Promise<void> }}
103
+ */
104
+ function loadImages(assets, base) {
105
+ /** @type {Record<string, HTMLImageElement>} */
106
+ const images = {}
107
+ const pending = []
108
+ for (let n = 0; n < assets.length; n++) {
109
+ const asset = assets[n]
110
+ const isImage = !asset.layers && typeof asset.p === 'string'
111
+ if (!isImage) continue
112
+
113
+ const image = new Image()
114
+ const source = asset.e === 1 || asset.p.indexOf('data:') === 0 ? asset.p : base + (asset.u || '') + asset.p
115
+ pending.push(new Promise((resolve) => {
116
+ // a missing image draws nothing, as it does in the reference players
117
+ image.onload = () => resolve(undefined)
118
+ image.onerror = () => resolve(undefined)
119
+ }))
120
+ image.src = source
121
+ images[asset.id] = image
122
+ }
123
+ return { images, ready: Promise.all(pending).then(() => undefined) }
124
+ }