@px-petals/roku.motion-web 1.0.0 → 1.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.
Files changed (77) hide show
  1. package/bands/bands.js +3 -1
  2. package/bundle/bundle.js +56 -0
  3. package/list/list.js +12 -1
  4. package/lottie/lottie.layers.js +64 -11
  5. package/lottie/lottie.paint.js +30 -0
  6. package/lottie/lottie.paths.js +19 -4
  7. package/lottie/lottie.props.js +18 -8
  8. package/lottie/lottie.shapes.js +2 -1
  9. package/mixer/mixer.js +31 -8
  10. package/mixer/mixer.mix.js +17 -0
  11. package/motion/motion.convert.js +32 -1
  12. package/motion/motion.flow.js +20 -2
  13. package/motion/motion.machine.js +28 -13
  14. package/motion/motion.values.js +13 -9
  15. package/motion.js +196 -20
  16. package/package.json +10 -16
  17. package/player/player.js +25 -5
  18. package/readme.md +78 -3
  19. package/script/luau/lib/base/base.js +202 -0
  20. package/script/luau/lib/base/base.table.js +189 -0
  21. package/script/luau/lib/lib.args.js +152 -0
  22. package/script/luau/lib/lib.js +23 -0
  23. package/script/luau/lib/math/math.bit32.js +144 -0
  24. package/script/luau/lib/math/math.js +288 -0
  25. package/script/luau/lib/math/math.vector.js +125 -0
  26. package/script/luau/lib/string/string.format.js +143 -0
  27. package/script/luau/lib/string/string.js +160 -0
  28. package/script/luau/lib/string/string.pattern.js +505 -0
  29. package/script/luau/load/load.js +362 -0
  30. package/script/luau/load/load.ops.js +79 -0
  31. package/script/luau/luau.convert.js +259 -0
  32. package/script/luau/luau.js +132 -0
  33. package/script/luau/luau.md +111 -0
  34. package/script/luau/vm/vm.js +167 -0
  35. package/script/luau/vm/vm.meta.js +401 -0
  36. package/script/luau/vm/vm.num.js +236 -0
  37. package/script/luau/vm/vm.run.js +842 -0
  38. package/script/luau/vm/vm.value.js +406 -0
  39. package/script/play/play.js +199 -0
  40. package/script/play/play.objects.js +295 -0
  41. package/script/play/play.scope.js +91 -0
  42. package/script/rive/api/api.statics.js +31 -0
  43. package/script/rive/api/api.type.js +188 -0
  44. package/script/rive/api/artboard/artboard.js +89 -0
  45. package/script/rive/api/assets/assets.js +72 -0
  46. package/script/rive/api/data/data.value.js +99 -0
  47. package/script/rive/api/draw/draw.contours.js +163 -0
  48. package/script/rive/api/draw/draw.measure.js +196 -0
  49. package/script/rive/api/draw/draw.paint.js +235 -0
  50. package/script/rive/api/draw/draw.path.js +153 -0
  51. package/script/rive/api/draw/draw.renderer.js +117 -0
  52. package/script/rive/api/math/math.color.js +96 -0
  53. package/script/rive/api/math/math.mat2d.js +180 -0
  54. package/script/rive/api/math/math.vector.js +121 -0
  55. package/script/rive/api/media/media.audio.js +113 -0
  56. package/script/rive/api/media/media.draw.js +73 -0
  57. package/script/rive/api/media/media.image.js +61 -0
  58. package/script/rive/api/media/media.mesh.js +50 -0
  59. package/script/rive/api/pointer/pointer.event.js +64 -0
  60. package/script/rive/api/pointer/pointer.route.js +69 -0
  61. package/script/rive/api/text/text.font.js +129 -0
  62. package/script/rive/api/text/text.js +278 -0
  63. package/script/rive/api/text/text.layout.js +259 -0
  64. package/script/rive/context/context.js +76 -0
  65. package/script/rive/context/context.memory.js +196 -0
  66. package/script/rive/context/context.viewmodel.js +302 -0
  67. package/script/rive/context/lists/lists.js +160 -0
  68. package/script/rive/context/lists/lists.values.js +281 -0
  69. package/script/rive/host/host.globals.js +44 -0
  70. package/script/rive/host/host.js +533 -0
  71. package/script/rive/host/host.node.js +45 -0
  72. package/script/rive/rive.js +23 -0
  73. package/script/rive/rive.md +180 -0
  74. package/script/script.js +11 -0
  75. package/skin/skin.deform.js +514 -0
  76. package/skin/skin.ik.js +260 -0
  77. package/skin/skin.js +297 -0
package/bands/bands.js CHANGED
@@ -329,8 +329,10 @@ function visit(node, found) {
329
329
  return
330
330
  }
331
331
  if (typeof node.rid === 'string' && 'k' in node) found(node)
332
+ // a property's `k` holds its values; a gradient's `g.k` is a property itself
333
+ const isProperty = 'a' in node || typeof node.rid === 'string'
332
334
  for (const key in node) {
333
- if (key === 'k' || key === 'rid') continue
335
+ if ((key === 'k' && isProperty) || key === 'rid') continue
334
336
  visit(node[key], found)
335
337
  }
336
338
  }
package/bundle/bundle.js CHANGED
@@ -61,6 +61,25 @@ export function loadJson(url) {
61
61
  })
62
62
  }
63
63
 
64
+ /**
65
+ * @param {string} url
66
+ * @returns {Promise<Uint8Array>}
67
+ */
68
+ export function loadBytes(url) {
69
+ return new Promise((resolve, reject) => {
70
+ const request = new XMLHttpRequest()
71
+ request.open('GET', url, true)
72
+ request.responseType = 'arraybuffer'
73
+ request.onload = () => {
74
+ const isOk = request.status >= 200 && request.status < 300
75
+ if (isOk) resolve(new Uint8Array(request.response))
76
+ else reject(new Error('cannot load ' + url + ' (HTTP ' + request.status + ')'))
77
+ }
78
+ request.onerror = () => reject(new Error('cannot load ' + url))
79
+ request.send()
80
+ })
81
+ }
82
+
64
83
  /**
65
84
  * @param {string} url
66
85
  * @returns {Promise<HTMLImageElement>}
@@ -73,3 +92,40 @@ export function loadImage(url) {
73
92
  image.src = url
74
93
  })
75
94
  }
95
+
96
+ /**
97
+ * What the page gives Motion to run scripts with: `script/script.js`, or a
98
+ * function returning it (or a promise of it), so the page fetches it only
99
+ * when a bundle needs it: `() => import('.../script/script.js')`.
100
+ *
101
+ * @typedef {{ createScripts: Function } | (() => any)} ScriptProvider
102
+ */
103
+
104
+ /**
105
+ * Loads the script runtime and the bundle's bytecode.
106
+ *
107
+ * @param {any} manifest a bundle's motion.json
108
+ * @param {ScriptProvider | undefined} provider
109
+ * @param {(file: string) => Promise<Uint8Array>} fetchBytes a file of the bundle, as bytes
110
+ * @param {(message: string) => void} report
111
+ * @returns {Promise<import('../script/play/play.js').Scripts | null>} null for a bundle with no scripts, or with no way to run them
112
+ */
113
+ export function loadScripts(manifest, provider, fetchBytes, report) {
114
+ const files = manifest.scripts
115
+ if (!files) return Promise.resolve(null)
116
+ if (!provider) {
117
+ report('this bundle has scripts, which run only when the scripts option gives Motion the script host; its script conditions do not pass, and its script converters pass values through')
118
+ return Promise.resolve(null)
119
+ }
120
+
121
+ const ids = Object.keys(files)
122
+ const loading = Promise.resolve(typeof provider === 'function' ? provider() : provider)
123
+ return Promise.all([ loading, Promise.all(ids.map((id) => fetchBytes(files[id]))) ]).then((loaded) => {
124
+ /** @type {Record<string, Uint8Array>} */
125
+ const bytes = {}
126
+ ids.forEach((id, n) => {
127
+ bytes[id] = loaded[1][n]
128
+ })
129
+ return loaded[0].createScripts(manifest, bytes, report)
130
+ })
131
+ }
package/list/list.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import { createAnimation } from '../lottie/lottie.js'
22
22
  import { multiply } from '../lottie/lottie.paths.js'
23
23
  import { createNested, extend } from '../motion/motion.nested.js'
24
- import { slotValues } from '../motion/motion.values.js'
24
+ import { slotValues, sourceValue } from '../motion/motion.values.js'
25
25
  import { layoutRows } from './list.flex.js'
26
26
  import { itemValues, rowNameOf, signature } from './list.items.js'
27
27
 
@@ -54,6 +54,7 @@ function scopeOf(id, sid) {
54
54
  * @property {() => number} band the width of the layout band showing, 0 for the file's own size
55
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
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 {((list: any[]) => ReturnType<typeof import('../skin/skin.js').createSkins>) | null} [skins] makes the skins of a row's `data.skins`; null when the bundle has none, so the skin code is never loaded
57
58
  * @property {(message: string) => void} error
58
59
  * @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
  */
@@ -99,6 +100,15 @@ export function createLists(entries, lotties, options) {
99
100
  const values = itemValues(item, entry.data)
100
101
  const table = slotValues(entry.data, values, options.error)
101
102
  for (const sid in table) anim.slots[sid] = table[sid]
103
+ // the skinned paths the row's binds move, deformed for this item: its animation is its file's name
104
+ const rowSkins = options.skins && entry.data && entry.data.skins ? options.skins(entry.data.skins) : null
105
+ const animationId = String(entry.lottie).replace(/\.json$/, '')
106
+ const deformRow = (/** @type {Record<string, any>} */ current) => {
107
+ if (!rowSkins) return
108
+ const deformed = rowSkins.evaluate((source) => sourceValue(source, current, entry.data, options.error), animationId)
109
+ for (const sid in deformed) anim.slots[sid] = deformed[sid]
110
+ }
111
+ deformRow(values)
102
112
 
103
113
  // the row's own machine, and the machines of the nested artboards in it: each runs on the row's clock
104
114
  const machines = entry.machine ? [ { sid: 'row', fps: entry.machine.fps, machine: entry.machine.machine, animations: entry.machine.animations, inputs: {}, remoteKeys: entry.remoteKeys } ] : []
@@ -115,6 +125,7 @@ export function createLists(entries, lotties, options) {
115
125
  if (sid !== 'row') return
116
126
  const table = slotValues(entry.data, extend(values, changed), options.error)
117
127
  for (const slot in table) anim.slots[slot] = table[slot]
128
+ deformRow(extend(values, changed))
118
129
  },
119
130
  })
120
131
  runner.takeInputs(values)
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { drawDocument } from '../player/overlay/overlay.js'
12
- import { drawOperations } from './lottie.paint.js'
12
+ import { clipToOperations, drawOperations } from './lottie.paint.js'
13
13
  import { fromLottie, IDENTITY, multiply, trace, transformPath } from './lottie.paths.js'
14
14
  import { evaluator } from './lottie.props.js'
15
15
  import { compileItems, compileTransform, runGroup, scalar } from './lottie.shapes.js'
@@ -307,9 +307,10 @@ function clipToMasks(ctx, masks, local, matrix) {
307
307
  }
308
308
 
309
309
  /**
310
- * A layer through its track matte: both drawn on scratch canvases the size
311
- * of the target, the content kept where the matte is opaque (alpha) or
312
- * clear (inverted). Luma mattes are read as alpha mattes.
310
+ * A layer through its track matte. An alpha matte that is one opaque fill
311
+ * clips the layer to its path; any other is drawn with the layer on scratch
312
+ * canvases the size of the target, the content kept where the matte is
313
+ * opaque (alpha) or clear (inverted). Luma mattes are read as alpha mattes.
313
314
  *
314
315
  * @param {CanvasRenderingContext2D} ctx
315
316
  * @param {any} layer
@@ -321,10 +322,16 @@ function clipToMasks(ctx, masks, local, matrix) {
321
322
  * @param {number} opacity
322
323
  */
323
324
  function drawMatted(ctx, layer, matte, context, frame, matrix, matteMatrix, opacity) {
325
+ if (clipsByPath(ctx, layer, matte, frame, matteMatrix)) {
326
+ drawLayer(ctx, layer, context, frame, matrix, opacity)
327
+ ctx.restore()
328
+ return
329
+ }
330
+
324
331
  const width = ctx.canvas.width
325
332
  const height = ctx.canvas.height
326
- const content = scratch(width, height)
327
- const mask = scratch(width, height)
333
+ const content = takeScratch(width, height)
334
+ const mask = takeScratch(width, height)
328
335
  if (!content || !mask) {
329
336
  drawLayer(ctx, layer, context, frame, matrix, opacity)
330
337
  return
@@ -340,18 +347,64 @@ function drawMatted(ctx, layer, matte, context, frame, matrix, matteMatrix, opac
340
347
  ctx.setTransform(1, 0, 0, 1, 0, 0)
341
348
  ctx.drawImage(content.canvas, 0, 0)
342
349
  ctx.restore()
350
+ giveScratch(mask)
351
+ giveScratch(content)
343
352
  }
344
353
 
345
354
  /**
355
+ * Clips to the matte's path when an alpha matte is one opaque fill, leaving
356
+ * the clip saved for the caller to restore.
357
+ *
358
+ * @param {CanvasRenderingContext2D} ctx
359
+ * @param {any} layer
360
+ * @param {any} matte
361
+ * @param {number} frame
362
+ * @param {Matrix} matteMatrix
363
+ * @returns {boolean} whether it clipped
364
+ */
365
+ function clipsByPath(ctx, layer, matte, frame, matteMatrix) {
366
+ const isPlainShapes = layer.matte === 1 && matte.shapes && !matte.masks.length && matte.blend === 'source-over'
367
+ if (!isPlainShapes) return false
368
+
369
+ const local = frame - matte.st
370
+ const run = runGroup(matte.shapes, local, IDENTITY, 1, null)
371
+ ctx.save()
372
+ if (clipToOperations(ctx, run.operations, matteMatrix, local)) return true
373
+ ctx.restore()
374
+ return false
375
+ }
376
+
377
+ /** scratch canvases free for a matte to draw on; a matte inside a matte takes others */
378
+ const SCRATCH = /** @type {CanvasRenderingContext2D[]} */ ([])
379
+
380
+ /**
381
+ * A cleared scratch canvas of the size, reused across frames: a matte drawn
382
+ * every frame would otherwise make two canvases a frame.
383
+ *
346
384
  * @param {number} width
347
385
  * @param {number} height
348
386
  * @returns {CanvasRenderingContext2D | null}
349
387
  */
350
- function scratch(width, height) {
351
- const canvas = document.createElement('canvas')
352
- canvas.width = width
353
- canvas.height = height
354
- return canvas.getContext('2d')
388
+ function takeScratch(width, height) {
389
+ const ctx = SCRATCH.pop() || document.createElement('canvas').getContext('2d')
390
+ if (!ctx) return null
391
+ const canvas = ctx.canvas
392
+ // setting the size clears it; a canvas already that size is cleared by hand
393
+ if (canvas.width !== width || canvas.height !== height) {
394
+ canvas.width = width
395
+ canvas.height = height
396
+ } else {
397
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
398
+ ctx.globalCompositeOperation = 'source-over'
399
+ ctx.globalAlpha = 1
400
+ ctx.clearRect(0, 0, width, height)
401
+ }
402
+ return ctx
403
+ }
404
+
405
+ /** @param {CanvasRenderingContext2D} ctx */
406
+ function giveScratch(ctx) {
407
+ SCRATCH.push(ctx)
355
408
  }
356
409
 
357
410
  /**
@@ -35,6 +35,36 @@ export function drawOperations(ctx, operations, layer, opacity, frame) {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Clips to what a layer's operations cover, when that is exactly one opaque
40
+ * solid fill: an alpha matte of it keeps the same pixels as a clip to its
41
+ * path, at none of a matte's offscreen cost. Anything else (strokes,
42
+ * gradients, transparency, several paints, merged paths) returns false and
43
+ * clips nothing.
44
+ *
45
+ * @param {CanvasRenderingContext2D} ctx
46
+ * @param {Operation[]} operations as runGroup returns them
47
+ * @param {Matrix} layer the layer's space onto the canvas
48
+ * @param {number} frame
49
+ * @returns {boolean} whether it clipped
50
+ */
51
+ export function clipToOperations(ctx, operations, layer, frame) {
52
+ if (operations.length !== 1) return false
53
+ const operation = operations[0]
54
+ const style = operation.paint
55
+ const isOpaque = operation.opacity >= 1 && scalar(style.opacity(frame)) >= 100
56
+ const isSolidFill = !style.isStroke && !style.isGradient && (style.color(frame).length < 4 || style.color(frame)[3] >= 1)
57
+ if (!isOpaque || !isSolidFill || !operation.entries.length) return false
58
+ for (let n = 0; n < operation.entries.length; n++) if (operation.entries[n].holder.merge) return false
59
+
60
+ const m = multiply(layer, operation.matrix)
61
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
62
+ ctx.beginPath()
63
+ for (let n = 0; n < operation.entries.length; n++) traceEntry(ctx, operation.entries[n])
64
+ ctx.clip(style.rule)
65
+ return true
66
+ }
67
+
38
68
  /**
39
69
  * @param {CanvasRenderingContext2D} ctx
40
70
  * @param {Operation} operation
@@ -98,16 +98,31 @@ export function trace(ctx, path) {
98
98
  if (!points.length) return
99
99
 
100
100
  ctx.moveTo(points[0].x, points[0].y)
101
- for (let k = 1; k < points.length; k++) {
102
- ctx.bezierCurveTo(cOut[k - 1].x, cOut[k - 1].y, cIn[k].x, cIn[k].y, points[k].x, points[k].y)
103
- }
101
+ for (let k = 1; k < points.length; k++) segment(ctx, points[k - 1], cOut[k - 1], cIn[k], points[k])
104
102
  if (path.closed) {
105
103
  const last = points.length - 1
106
- ctx.bezierCurveTo(cOut[last].x, cOut[last].y, cIn[0].x, cIn[0].y, points[0].x, points[0].y)
104
+ segment(ctx, points[last], cOut[last], cIn[0], points[0])
107
105
  ctx.closePath()
108
106
  }
109
107
  }
110
108
 
109
+ /**
110
+ * One segment: a line where both handles sit on their points, as most of a
111
+ * Lottie's do, else the cubic. A line is far cheaper for the canvas to fill
112
+ * and stroke than a curve, and draws the same.
113
+ *
114
+ * @param {CanvasRenderingContext2D} ctx
115
+ * @param {Point} from
116
+ * @param {Point} out from's out handle
117
+ * @param {Point} into to's in handle
118
+ * @param {Point} to
119
+ */
120
+ function segment(ctx, from, out, into, to) {
121
+ const isStraight = out.x === from.x && out.y === from.y && into.x === to.x && into.y === to.y
122
+ if (isStraight) ctx.lineTo(to.x, to.y)
123
+ else ctx.bezierCurveTo(out.x, out.y, into.x, into.y, to.x, to.y)
124
+ }
125
+
111
126
  // Shapes
112
127
  // ■■■■■■
113
128
 
@@ -9,14 +9,14 @@
9
9
  * where a keyframe carries its own end value in `e`.
10
10
  */
11
11
 
12
- import { mixValues } from '../mixer/mixer.mix.js'
12
+ import { mixValues, settledWeight } from '../mixer/mixer.mix.js'
13
13
 
14
14
  /**
15
15
  * @typedef {(frame: number) => any} Evaluator
16
16
  */
17
17
 
18
18
  /**
19
- * Live slot values, by slot id; `undefined` leaves a property to its keyframes.
19
+ * Live slot values, by slot id; `undefined` leaves a property to its keyframes, and a function gives the value at a frame.
20
20
  * @typedef {Record<string, any>} Slots
21
21
  */
22
22
 
@@ -33,8 +33,12 @@ export function evaluator(property, slots, fallback) {
33
33
  const sid = property.sid
34
34
  if (typeof sid !== 'string') return keyed
35
35
 
36
+ // a nested machine that mixes several animations names where its poses are
37
+ const poseKey = property.pz ? 'pz:' + property.pz.n : ''
36
38
  return (frame) => {
37
39
  const live = slots[sid]
40
+ // a live value can depend on the frame: a skin the animation keys is asked for its path, in the poses of the machine it is drawn in
41
+ if (typeof live === 'function') return live(frame, poseKey ? slots[poseKey] : null)
38
42
  return live === undefined ? keyed(frame) : live
39
43
  }
40
44
  }
@@ -43,10 +47,12 @@ export function evaluator(property, slots, fallback) {
43
47
  * A property of a nested machine that plays several animations at once. The
44
48
  * machine says which it plays, as `{ start, frame, weight }` in the slot
45
49
  * `pz:<id>`: the first frame of the animation's segment on the stitched
46
- * timeline, how far in it is, and its weight. The value is the property's at
47
- * the frame the machine's time remap gives, with each pose that keys it
48
- * mixed in by its weight, in order, as Rive applies animations; one that
49
- * does not key it leaves it alone. Without poses it is the keyframes.
50
+ * timeline, how far in it is, and its weight. Rive applies the poses in
51
+ * order, each mixing the value toward its own by its weight, onto what the
52
+ * last frame left: held, that settles where each pose that keys the property
53
+ * counts by its weight times what the poses after it leave (`settledWeight`),
54
+ * and the setup value no longer shows. One that does not key it leaves it
55
+ * alone; with none keying it, it is the keyframes at the time remap's frame.
50
56
  *
51
57
  * @param {any} property `pz` is `{ n, k }`: the machine's id, and the segments that key the property
52
58
  * @param {Evaluator} keyed
@@ -62,10 +68,14 @@ function posedEvaluator(property, keyed, slots) {
62
68
  if (!poses) return value
63
69
 
64
70
  let mixed = value
71
+ let total = 0
65
72
  for (let n = 0; n < poses.length; n++) {
66
73
  const pose = poses[n]
67
- if (keying.indexOf(pose.start) < 0) continue
68
- mixed = mixValues(mixed, keyed(pose.start + pose.frame), pose.weight)
74
+ const weight = keying.indexOf(pose.start) < 0 ? 0 : settledWeight(poses, n, keying)
75
+ if (weight <= 0) continue
76
+ // a running weighted average: each pose's share of what has been counted so far
77
+ total += weight
78
+ mixed = mixValues(mixed, keyed(pose.start + pose.frame), weight / total)
69
79
  }
70
80
  return mixed
71
81
  }
@@ -205,7 +205,8 @@ export function runGroup(items, frame, matrix, opacity, sink) {
205
205
  } else if (item.kind === 'group') {
206
206
  const local = item.transform(frame)
207
207
  const inner = runGroup(item.items, frame, multiply(matrix, local.matrix), opacity * local.opacity, sink)
208
- if (sink && item.rid) {
208
+ // a group that draws nothing (a Solo's hidden child, or anything faded out) is not a place a pointer can hit
209
+ if (sink && item.rid && opacity * local.opacity > 0) {
209
210
  const rid = sink.prefix + item.rid
210
211
  if (sink.hits.wants(rid)) sink.hits.add(rid, inner.entries, multiply(sink.matrix, multiply(matrix, local.matrix)))
211
212
  }
package/mixer/mixer.js CHANGED
@@ -35,11 +35,14 @@ import { mixValues } from './mixer.mix.js'
35
35
  * @property {number} weight 0 to 1
36
36
  */
37
37
 
38
- /** What an animation does to one property: its value at a frame, whether it keys the property, and, for a pair of axes with one keyed, which */
39
- /** @typedef {{ at: Evaluator, keyed: boolean, axes: number[] | null }} Source */
38
+ /** What an animation does to one property: its value at a frame, whether it keys the property, and, for a pair of axes with one keyed, which; `picks` for a Solo option's visibility, which an animation sets whole, never blends */
39
+ /** @typedef {{ at: Evaluator, keyed: boolean, axes: number[] | null, picks: boolean }} Source */
40
+
41
+ /** The `rid` of a Solo option's visibility: `<object id>.solo` */
42
+ const SOLO = /\.solo$/
40
43
 
41
44
  /** A pose, prepared for the evaluators: where its animation's sources are, and how far its frame is from the drawn one */
42
- /** @typedef {{ sources: Record<string, Source>, offset: number, weight: number }} Prepared */
45
+ /** @typedef {{ animation: string, sources: Record<string, Source>, offset: number, weight: number }} Prepared */
43
46
 
44
47
  /**
45
48
  * What the mixing evaluators share: the poses of the draw, and each property's value after the previous frame.
@@ -59,6 +62,7 @@ const NO_SLOTS = {}
59
62
  * @property {Animation} animation the mixed picture; its `slots` are live
60
63
  * @property {string} carrier the animation whose structure it has
61
64
  * @property {(ctx: CanvasRenderingContext2D, poses: Pose[], matrix: Matrix, frame?: number, passes?: number) => void} draw `frame` counts the stage's steps: drawing the same frame again leaves each property where it was; `passes` applies the poses that many times, for a stage that has stopped but must settle
65
+ * @property {(read: (animation: string) => any, base: any) => Evaluator} mixed a value each animation gives on its own (a slot the player fills, such as a skin's path), mixed over the poses being drawn: each pose whose animation gives one lerps toward it by its weight
62
66
  */
63
67
 
64
68
  /**
@@ -92,6 +96,18 @@ export function createMixer(lotties, options) {
92
96
  }
93
97
  animation.draw(ctx, prepare(lotties, sources, poses, current, carrier), matrix)
94
98
  },
99
+ mixed(read, base) {
100
+ return (frame) => {
101
+ let value = typeof base === 'function' ? base(frame) : base
102
+ for (let n = 0; n < current.poses.length; n++) {
103
+ const pose = current.poses[n]
104
+ const own = read(pose.animation)
105
+ if (own === undefined) continue
106
+ value = mixValues(value, typeof own === 'function' ? own(frame + pose.offset) : own, pose.weight)
107
+ }
108
+ return value
109
+ }
110
+ },
95
111
  }
96
112
  }
97
113
 
@@ -126,7 +142,7 @@ function collect(lottie) {
126
142
  const found = {}
127
143
  visit(lottie, (property) => {
128
144
  if (found[property.rid]) return
129
- found[property.rid] = { at: evaluator(property, NO_SLOTS), keyed: property.ky === 1, axes: Array.isArray(property.kc) ? property.kc : null }
145
+ found[property.rid] = { at: evaluator(property, NO_SLOTS), keyed: property.ky === 1, axes: Array.isArray(property.kc) ? property.kc : null, picks: SOLO.test(property.rid) }
130
146
  })
131
147
  return found
132
148
  }
@@ -144,8 +160,10 @@ function visit(node, found) {
144
160
  return
145
161
  }
146
162
  if (typeof node.rid === 'string' && 'k' in node) found(node)
163
+ // a property's `k` holds its values; a gradient's `g.k` is a property itself
164
+ const isProperty = 'a' in node || typeof node.rid === 'string'
147
165
  for (const key in node) {
148
- if (key === 'k' || key === 'rid') continue
166
+ if ((key === 'k' && isProperty) || key === 'rid') continue
149
167
  visit(node[key], found)
150
168
  }
151
169
  }
@@ -191,7 +209,10 @@ function baseOf(rid, sources, carrier, lottie) {
191
209
  const source = sources[id][rid]
192
210
  if (source && !source.keyed) return source.at(0)
193
211
  }
194
- const first = sources[carrier][rid].at(lottie.ip || 0)
212
+ // a property the carrier does not tag starts from the first animation that does
213
+ let own = sources[carrier][rid]
214
+ for (const id in sources) if (!own) own = sources[id][rid]
215
+ const first = own.at(lottie.ip || 0)
195
216
  if (!Array.isArray(first)) return first
196
217
 
197
218
  const result = first.slice()
@@ -242,7 +263,9 @@ function mixing(rid, base, current) {
242
263
  if (!source || !source.keyed) continue
243
264
 
244
265
  const sample = source.at(frame + pose.offset)
245
- value = source.axes ? mixAxes(value, sample, pose.weight, source.axes) : mixValues(value, sample, pose.weight)
266
+ // a Solo's choice is an id, which Rive sets from any animation applying it, the last one winning
267
+ if (source.picks) value = pose.weight > 0 ? sample : value
268
+ else value = source.axes ? mixAxes(value, sample, pose.weight, source.axes) : mixValues(value, sample, pose.weight)
246
269
  }
247
270
  }
248
271
  current.next[rid] = value
@@ -278,7 +301,7 @@ function prepare(lotties, sources, poses, current, carrier) {
278
301
  isFirst = false
279
302
  }
280
303
  const frame = (lottie.ip || 0) + pose.time * (lottie.fr || 30)
281
- current.poses.push({ sources: sources[pose.animation], offset: frame - drawn, weight: pose.weight })
304
+ current.poses.push({ animation: pose.animation, sources: sources[pose.animation], offset: frame - drawn, weight: pose.weight })
282
305
  }
283
306
  return drawn
284
307
  }
@@ -88,3 +88,20 @@ function mixPoints(a, b, t) {
88
88
  for (let n = 0; n < a.length; n++) points.push(mixVectors(a[n], b[n], t))
89
89
  return points
90
90
  }
91
+
92
+ /**
93
+ * How much a pose counts in the value the poses settle on: its weight, times
94
+ * what each pose after it that keys the value too leaves of it.
95
+ *
96
+ * @param {Array<{ start: number, weight: number }>} poses
97
+ * @param {number} at the pose's index
98
+ * @param {number[]} keying the segments that key the value
99
+ * @returns {number}
100
+ */
101
+ export function settledWeight(poses, at, keying) {
102
+ let weight = poses[at].weight
103
+ for (let n = at + 1; n < poses.length && weight > 0; n++) {
104
+ if (keying.indexOf(poses[n].start) >= 0) weight *= 1 - Math.min(1, poses[n].weight)
105
+ }
106
+ return weight
107
+ }
@@ -14,6 +14,9 @@
14
14
  * @property {(path: string) => any} [read] a view-model property's value, by dotted path
15
15
  * @property {string[]} [names] the names of an enum the value indexes
16
16
  * @property {(message: string) => void} [warn]
17
+ * @property {import('../script/play/play.js').Scripts} [scripts] runs the bundle's script converters
18
+ * @property {string} [type] the bound property's type, so a script is given a colour as one
19
+ * @property {Record<string, any>} [values] every live value, which a script can read
17
20
  */
18
21
 
19
22
  /**
@@ -126,13 +129,20 @@ export function applyConverter(descriptor, value, context) {
126
129
  return enumIndex(value, ctx.names)
127
130
  case 'listLength':
128
131
  return Array.isArray(value) ? value.length : 0
132
+ case 'solo':
133
+ return soloVisibility(descriptor, value)
129
134
  case 'group': {
130
135
  let result = value
131
136
  const items = descriptor.items || []
132
137
  // only the first link sees the enum names
133
- for (let n = 0; n < items.length; n++) result = applyConverter(items[n], result, n === 0 ? ctx : { read: ctx.read, warn: ctx.warn })
138
+ for (let n = 0; n < items.length; n++) result = applyConverter(items[n], result, n === 0 ? ctx : { read: ctx.read, warn: ctx.warn, scripts: ctx.scripts, values: ctx.values })
134
139
  return result
135
140
  }
141
+ case 'script':
142
+ // run by the bundle's script host; without one the value passes through
143
+ if (ctx.scripts) return ctx.scripts.convert(descriptor, value, false, ctx.type === 'color' ? 'color' : undefined, ctx.values)
144
+ warnUnknown(descriptor, ctx)
145
+ return value
136
146
  default:
137
147
  warnUnknown(descriptor, ctx)
138
148
  return value
@@ -171,6 +181,8 @@ export function reverseConverter(descriptor, value, context) {
171
181
  for (let n = items.length - 1; n >= 0; n--) result = reverseConverter(items[n], result, ctx)
172
182
  return result
173
183
  }
184
+ case 'script':
185
+ return ctx.scripts ? ctx.scripts.convert(descriptor, value, true) : value
174
186
  default:
175
187
  return value
176
188
  }
@@ -358,6 +370,25 @@ function pad(d, text) {
358
370
  return d.atEnd ? result.slice(0, wanted) : result.slice(result.length - wanted)
359
371
  }
360
372
 
373
+ /**
374
+ * A Solo option's visibility, in percent: 100 when the bound value names it,
375
+ * 0 otherwise. A number names an option by its place among the Solo's options
376
+ * (`index`), an enum or a string by name (`names`, the ones that pick the
377
+ * option; an enum's value is its index, which `ids` lists). A descriptor with
378
+ * neither is an option no value reaches.
379
+ *
380
+ * @param {any} d
381
+ * @param {any} value
382
+ */
383
+ function soloVisibility(d, value) {
384
+ if (Array.isArray(d.names)) {
385
+ if (typeof value === 'string') return d.names.indexOf(value) >= 0 ? 100 : 0
386
+ return Array.isArray(d.ids) && typeof value === 'number' && d.ids.indexOf(value) >= 0 ? 100 : 0
387
+ }
388
+ if (typeof d.index === 'number') return Math.round(numeric(value)) === d.index ? 100 : 0
389
+ return 0
390
+ }
391
+
361
392
  /**
362
393
  * An enum value as its index, from an index or a name.
363
394
  *
@@ -31,6 +31,7 @@ const MAX_SETTLE = 16
31
31
  * @property {(action: any) => void} [focus] a focus action (FocusTarget, FocusMove, FocusClear) ran
32
32
  * @property {(layer: number, state: any, fadeMs: number, ease?: number[]) => void} [layer] a layer entered a state, fading in along `ease`'s curve; replaces `play`
33
33
  * @property {(layer: number) => number} [elapsed] ms the layer's current state has played, unwrapped
34
+ * @property {import('../script/play/play.js').Scripts | null} [scripts] the scripts the bundle runs, read when a guard or action needs them
34
35
  */
35
36
 
36
37
  /**
@@ -54,6 +55,8 @@ export function createFlow(manifest, hooks) {
54
55
  let transitions = {}
55
56
  /** Entry actions and listeners that run while a state is being entered must not settle the machine under it */
56
57
  let busy = 0
58
+ /** the interaction whose actions are running, which a script's pointer event is of */
59
+ let trigger = ''
57
60
 
58
61
  /**
59
62
  * The defaults, with the caller's inputs over them; then the first state.
@@ -182,7 +185,7 @@ export function createFlow(manifest, hooks) {
182
185
  for (let n = 0; n < MAX_SETTLE; n++) {
183
186
  /** @type {any[]} */
184
187
  const taken = []
185
- for (let layer = 0; layer < graphs.length; layer++) taken.push(takeTransition(graphs[layer], currents[layer], values, events, clockOf(layer)))
188
+ for (let layer = 0; layer < graphs.length; layer++) taken.push(takeTransition(graphs[layer], currents[layer], values, events, clockOf(layer), hooks.scripts || null))
186
189
  if (!taken.some(Boolean)) break
187
190
  // an event that moved the machine is spent
188
191
  events = {}
@@ -270,6 +273,16 @@ export function createFlow(manifest, hooks) {
270
273
  settle()
271
274
  }
272
275
 
276
+ /**
277
+ * Values were written straight into `values` (by the bundle's scripted
278
+ * objects, which the next ones read at once): the player redraws and the
279
+ * machine settles, as for `takeInputs`.
280
+ */
281
+ function changed() {
282
+ if (graph) settle()
283
+ else hooks.valuesChanged(values)
284
+ }
285
+
273
286
  /**
274
287
  * @param {Record<string, any>} inputs
275
288
  * @returns {boolean} whether anything changed or fired
@@ -325,7 +338,9 @@ export function createFlow(manifest, hooks) {
325
338
  */
326
339
  function click(isUntargeted) {
327
340
  if (!graph) return
341
+ trigger = 'Click'
328
342
  run(interactionActions(graph, 'Click', '', isUntargeted ? '' : undefined))
343
+ trigger = ''
329
344
  settle()
330
345
  }
331
346
 
@@ -337,7 +352,9 @@ export function createFlow(manifest, hooks) {
337
352
  */
338
353
  function pointer(type, target) {
339
354
  if (!graph) return
355
+ trigger = type
340
356
  run(interactionActions(graph, type, '', target))
357
+ trigger = ''
341
358
  settle()
342
359
  }
343
360
 
@@ -370,7 +387,7 @@ export function createFlow(manifest, hooks) {
370
387
  /** @param {any[]} actions */
371
388
  function run(actions) {
372
389
  if (!graph || !actions || !actions.length) return
373
- const effects = perform(actions, graph, values, events)
390
+ const effects = perform(actions, graph, values, events, hooks.scripts || null, trigger)
374
391
  for (let n = 0; n < effects.events.length; n++) hooks.event(effects.events[n])
375
392
  if (effects.changed) hooks.valuesChanged(values)
376
393
  if (hooks.focus) for (let n = 0; n < effects.focus.length; n++) hooks.focus(effects.focus[n])
@@ -380,6 +397,7 @@ export function createFlow(manifest, hooks) {
380
397
  start,
381
398
  jumpTo,
382
399
  takeInputs,
400
+ changed,
383
401
  act,
384
402
  focusEvent,
385
403
  click,