@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
@@ -11,6 +11,7 @@
11
11
  * @typedef {Record<string, boolean>} Events pending events, name → true
12
12
  * @typedef {{ changed: boolean, events: string[], focus: any[] }} Effects `focus`: the focus actions (FocusTarget, FocusMove, FocusClear) to run once the values are set
13
13
  * @typedef {{ elapsed: number, length: number }} Clock how long the current state has played, and how long its timeline is, in ms
14
+ * @typedef {import('../script/play/play.js').Scripts} Scripts the scripts a bundle runs; without them a Script guard fails and a RunScript action does nothing
14
15
  */
15
16
 
16
17
  /**
@@ -80,10 +81,11 @@ export function isEvent(graph, name) {
80
81
  * @param {Values} values
81
82
  * @param {Events} events
82
83
  * @param {Clock | null} [clock] the current state's time, for exit times; without it exit times are not checked
84
+ * @param {Scripts | null} [scripts]
83
85
  * @returns {string} the next state, or ""
84
86
  */
85
- export function nextState(graph, current, values, events, clock) {
86
- const transition = takeTransition(graph, current, values, events, clock || null)
87
+ export function nextState(graph, current, values, events, clock, scripts) {
88
+ const transition = takeTransition(graph, current, values, events, clock || null, scripts || null)
87
89
  return transition ? transition.toState : ''
88
90
  }
89
91
 
@@ -96,18 +98,19 @@ export function nextState(graph, current, values, events, clock) {
96
98
  * @param {Values} values
97
99
  * @param {Events} events
98
100
  * @param {Clock | null} clock
101
+ * @param {Scripts | null} [scripts]
99
102
  * @returns {any} the transition, or null
100
103
  */
101
- export function takeTransition(graph, current, values, events, clock) {
104
+ export function takeTransition(graph, current, values, events, clock, scripts) {
102
105
  for (let n = 0; n < graph.states.length; n++) {
103
106
  const state = graph.states[n]
104
107
  if (state.type !== 'GlobalState') continue
105
- const transition = firstPassing(state.transitions, values, events, current, clock)
108
+ const transition = firstPassing(state.transitions, values, events, current, clock, scripts || null)
106
109
  if (transition) return transition
107
110
  }
108
111
 
109
112
  const state = find(graph, current)
110
- return state ? firstPassing(state.transitions, values, events, '', clock) : null
113
+ return state ? firstPassing(state.transitions, values, events, '', clock, scripts || null) : null
111
114
  }
112
115
 
113
116
  /**
@@ -148,8 +151,9 @@ export function nextExit(state, length, after) {
148
151
  * @param {Events} events
149
152
  * @param {string} skip a target to pass over, or ""
150
153
  * @param {Clock | null} clock
154
+ * @param {Scripts | null} scripts
151
155
  */
152
- function firstPassing(transitions, values, events, skip, clock) {
156
+ function firstPassing(transitions, values, events, skip, clock, scripts) {
153
157
  const list = transitions || []
154
158
  for (let n = 0; n < list.length; n++) {
155
159
  const transition = list[n]
@@ -158,7 +162,7 @@ function firstPassing(transitions, values, events, skip, clock) {
158
162
  // with a clock, an exit time is checked exactly and replaces the guard that waits for the whole timeline
159
163
  const isTimed = !!clock && !!transition.exitTime
160
164
  if (isTimed && clock.elapsed < exitMs(transition, clock.length)) continue
161
- if (guardsPass(transition.guards, values, events, isTimed)) return transition
165
+ if (guardsPass(transition.guards, values, events, isTimed, scripts)) return transition
162
166
  }
163
167
  return null
164
168
  }
@@ -168,12 +172,13 @@ function firstPassing(transitions, values, events, skip, clock) {
168
172
  * @param {Values} values
169
173
  * @param {Events} events
170
174
  * @param {boolean} isTimed the transition's exit time is checked apart; its `exit` guard is skipped
175
+ * @param {Scripts | null} scripts
171
176
  */
172
- function guardsPass(guards, values, events, isTimed) {
177
+ function guardsPass(guards, values, events, isTimed, scripts) {
173
178
  const list = guards || []
174
179
  for (let n = 0; n < list.length; n++) {
175
180
  if (isTimed && list[n].exit === true) continue
176
- if (!guardPasses(list[n], values, events)) return false
181
+ if (!guardPasses(list[n], values, events, scripts)) return false
177
182
  }
178
183
  return true
179
184
  }
@@ -184,9 +189,12 @@ function guardsPass(guards, values, events, isTimed) {
184
189
  * @param {any} guard
185
190
  * @param {Values} values
186
191
  * @param {Events} events
192
+ * @param {Scripts | null} scripts
187
193
  */
188
- function guardPasses(guard, values, events) {
194
+ function guardPasses(guard, values, events, scripts) {
189
195
  if (guard.type === 'Event') return events[guard.inputName] === true
196
+ // a scripted condition: the script says; with no way to run it, it does not pass
197
+ if (guard.type === 'Script') return !!scripts && scripts.evaluate(guard, values)
190
198
 
191
199
  const value = values[guard.inputName]
192
200
  const expected = resolve(guard.compareTo, values)
@@ -229,13 +237,15 @@ function compare(a, condition, b) {
229
237
  * @param {Graph} graph
230
238
  * @param {Values} values updated in place
231
239
  * @param {Events} events updated in place: Fire adds a pending event
240
+ * @param {Scripts | null} [scripts] runs a RunScript action
241
+ * @param {string} [trigger] the interaction the actions run for, which a script's pointer event is of
232
242
  * @returns {Effects}
233
243
  */
234
- export function perform(actions, graph, values, events) {
244
+ export function perform(actions, graph, values, events, scripts, trigger) {
235
245
  /** @type {Effects} */
236
246
  const effects = { changed: false, events: [], focus: [] }
237
247
  const list = actions || []
238
- for (let n = 0; n < list.length; n++) runOne(list[n], graph, values, events, effects)
248
+ for (let n = 0; n < list.length; n++) runOne(list[n], graph, values, events, effects, scripts || null, trigger || '')
239
249
  return effects
240
250
  }
241
251
 
@@ -245,8 +255,10 @@ export function perform(actions, graph, values, events) {
245
255
  * @param {Values} values
246
256
  * @param {Events} events
247
257
  * @param {Effects} effects
258
+ * @param {Scripts | null} scripts
259
+ * @param {string} trigger
248
260
  */
249
- function runOne(action, graph, values, events, effects) {
261
+ function runOne(action, graph, values, events, effects, scripts, trigger) {
250
262
  const name = action.inputName
251
263
  const current = values[name]
252
264
  switch (action.type) {
@@ -264,6 +276,9 @@ function runOne(action, graph, values, events, effects) {
264
276
  case 'OpenUrl':
265
277
  effects.events.push('openUrl:' + action.url)
266
278
  break
279
+ case 'RunScript':
280
+ if (scripts && scripts.perform(action, trigger, values, events)) effects.changed = true
281
+ break
267
282
  case 'Toggle':
268
283
  if (typeof current === 'boolean') setValue(values, name, !current, effects)
269
284
  break
@@ -88,15 +88,16 @@ function assetId(value, property) {
88
88
  * @param {Data} data
89
89
  * @param {Record<string, any>} values
90
90
  * @param {(message: string) => void} [warn] a converter this player does not know
91
+ * @param {import('../script/play/play.js').Scripts | null} [scripts] runs the bundle's script converters
91
92
  * @returns {Record<string, any>}
92
93
  */
93
- export function slotValues(data, values, warn) {
94
+ export function slotValues(data, values, warn, scripts) {
94
95
  /** @type {Record<string, any>} */
95
96
  const table = {}
96
97
  const slots = (data && data.slots) || []
97
98
  for (let n = 0; n < slots.length; n++) {
98
99
  const slot = slots[n]
99
- const value = slotValue(slot, values, data, warn)
100
+ const value = slotValue(slot, values, data, warn, scripts)
100
101
  if (value !== undefined) table[slot.sid] = value
101
102
  }
102
103
  return table
@@ -122,12 +123,13 @@ export function propertyOf(data, path) {
122
123
  * @param {Record<string, any>} values
123
124
  * @param {Data} data
124
125
  * @param {(message: string) => void} [warn]
126
+ * @param {import('../script/play/play.js').Scripts | null} [scripts]
125
127
  */
126
- function sourceValue(source, values, data, warn) {
128
+ export function sourceValue(source, values, data, warn, scripts) {
127
129
  const raw = values[source.path]
128
130
  if (raw === undefined || !source.converter) return raw
129
131
  const property = propertyOf(data, source.path)
130
- return applyConverter(source.converter, raw, { read: (path) => values[path], names: property && property.values, warn })
132
+ return applyConverter(source.converter, raw, { read: (path) => values[path], names: property && property.values, warn, scripts: scripts || undefined, values, type: property ? property.type : undefined })
131
133
  }
132
134
 
133
135
  /**
@@ -135,21 +137,22 @@ function sourceValue(source, values, data, warn) {
135
137
  * @param {Record<string, any>} values
136
138
  * @param {Data} data
137
139
  * @param {(message: string) => void} [warn]
140
+ * @param {import('../script/play/play.js').Scripts | null} [scripts]
138
141
  */
139
- function slotValue(slot, values, data, warn) {
142
+ function slotValue(slot, values, data, warn, scripts) {
140
143
  const sources = slot.sources || []
141
144
  if (!sources.length) return undefined
142
145
 
143
146
  const isPair = slot.type === 'Position' || slot.type === 'Vector'
144
147
  if (!isPair) {
145
- const raw = sourceValue(sources[0], values, data, warn)
148
+ const raw = sourceValue(sources[0], values, data, warn, scripts)
146
149
  return raw === undefined ? undefined : convert(raw, slot.conversion, propertyOf(data, sources[0].path))
147
150
  }
148
151
 
149
152
  const pair = Array.isArray(slot.base) ? slot.base.slice() : [ 0, 0 ]
150
153
  let found = false
151
154
  for (let n = 0; n < sources.length; n++) {
152
- const raw = sourceValue(sources[n], values, data, warn)
155
+ const raw = sourceValue(sources[n], values, data, warn, scripts)
153
156
  if (raw === undefined) continue
154
157
  const converted = convert(raw, slot.conversion)
155
158
  if (converted === undefined) continue
@@ -222,8 +225,9 @@ export function publicValues(data, values) {
222
225
  * @param {any} write a `data.writes` entry
223
226
  * @param {any} drawn the Lottie property's value
224
227
  * @param {any} current the view-model value now, whose alpha a colour keeps
228
+ * @param {import('../script/play/play.js').Scripts | null} [scripts] reverses a script converter
225
229
  */
226
- export function writtenValue(write, drawn, current) {
230
+ export function writtenValue(write, drawn, current, scripts) {
227
231
  const isPair = write.type === 'Position' || write.type === 'Vector'
228
232
  let value = isPair && Array.isArray(drawn) ? drawn[write.axis || 0] : drawn
229
233
  switch (write.conversion) {
@@ -246,7 +250,7 @@ export function writtenValue(write, drawn, current) {
246
250
  break
247
251
  }
248
252
  if (typeof value !== 'number' || !isFinite(value)) return undefined
249
- return write.converter ? reverseConverter(write.converter, value) : value
253
+ return write.converter ? reverseConverter(write.converter, value, scripts ? { scripts } : undefined) : value
250
254
  }
251
255
 
252
256
  /**
package/motion.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createAudio } from './audio/audio.js'
19
- import { loadBundle, loadJson, fileUrl } from './bundle/bundle.js'
19
+ import { loadBundle, loadBytes, loadJson, loadScripts, fileUrl } from './bundle/bundle.js'
20
20
  import { loadFonts } from './bundle/bundle.fonts.js'
21
21
  import { createAnimation } from './lottie/lottie.js'
22
22
  import { createLists } from './list/list.js'
@@ -28,13 +28,19 @@ import { createFlow } from './motion/motion.flow.js'
28
28
  import { exists, layersOf } from './motion/motion.machine.js'
29
29
  import { isClick, keyFromEvent, keyNames, modifiersFromEvent, parseKey } from './motion/motion.keys.js'
30
30
  import { createNested } from './motion/motion.nested.js'
31
- import { flattenInputs, fromPairs, publicValues, slotValues, tintColors, writtenValue } from './motion/motion.values.js'
31
+ import { flattenInputs, fromPairs, publicValues, slotValues, sourceValue, tintColors, writtenValue } from './motion/motion.values.js'
32
32
  import { cellOf, gridOf, hasBlend, linkBands, variantIds } from './bands/bands.js'
33
33
  import { alignmentOf, fitName } from './player/player.fit.js'
34
34
  import { createPlayer } from './player/player.js'
35
35
  import { createPointer } from './pointer/pointer.js'
36
36
  import { createRaster } from './player/player.raster.js'
37
37
 
38
+ /**
39
+ * What a bundle without skins holds: nothing, and no skin code loaded.
40
+ * @type {import('./skin/skin.js').Skins}
41
+ */
42
+ const noSkins = { count: 0, update() {}, table: () => ({}), keyedBy: () => undefined, evaluate: () => ({}) }
43
+
38
44
  /** @import { Bundle } from './bundle/bundle.js' */
39
45
  /** @import { Media } from './player/player.js' */
40
46
 
@@ -47,8 +53,12 @@ import { createRaster } from './player/player.raster.js'
47
53
  * @property {string | number[]} [alignment] where it sits in the canvas, over the bundle's: a Rive alignment name such as `topLeft`, or `[x, y]` from -1 to 1
48
54
  * @property {'auto' | 'manual'} [keys] `auto` (default): the canvas can be given DOM focus (Tab, a click, `canvas.focus()`) and, while it has it, its keys go to the bundle's focus and keyboard listeners. `manual`: the canvas listens to no keys and the host routes them with `key`, `keyUp`, `focus`, `blur` and `click`
49
55
  * @property {boolean} [manual] a test hook: no clock of its own, time moves only by `advance`. Default false
56
+ * @property {import('./bundle/bundle.js').ScriptProvider} [scripts] `script/script.js` (the Luau VM and Rive host), or a function that returns it or a promise of it, for a bundle that has scripts to run: `() => import('.../script/script.js')`. A bundle without scripts never asks for it; without it, a bundle's script conditions do not pass and its script converters pass values through
50
57
  */
51
58
 
59
+ /** The values of a Motion with no bundle playing */
60
+ const NO_VALUES = {}
61
+
52
62
  /**
53
63
  * @typedef {'loaded' | 'state' | 'playback' | 'finished' | 'values' | 'event' | 'audio' | 'focus' | 'error'} MotionEvent
54
64
  */
@@ -74,6 +84,10 @@ export function createMotion(canvas, options) {
74
84
  let inputOf = {}
75
85
  /** Slot id → value for the current values, which the text overlays read */
76
86
  let slotTable = {}
87
+ /** The bundle's bound skins, deformed into their slots with the rest; the skin code is loaded only for a bundle that has some */
88
+ let skins = noSkins
89
+ /** @type {typeof import('./skin/skin.js').createSkins | null} */
90
+ let makeSkins = null
77
91
  /** Whether a write-back is in progress, whose own redraw must not write again */
78
92
  let writing = false
79
93
  let loadCount = 0
@@ -99,6 +113,19 @@ export function createMotion(canvas, options) {
99
113
  /** @type {Media | null} the mixed media of a bundle that plays through the mixer */
100
114
  let mixedMedia = null
101
115
  let isMixedShown = false
116
+ /** @type {import('./script/play/play.js').Scripts | null} the bundle's scripts, once loaded */
117
+ let scripts = null
118
+ /** @type {import('./script/play/play.objects.js').Objects | null} the bundle's scripted nodes, drawables and path effects, which run every frame */
119
+ let objects = null
120
+ /** Whether the scripted objects are running a frame, whose own writes redraw once, with the frame */
121
+ let isStepping = false
122
+ /** What the objects wrote since they last handed it over, and the triggers they fired */
123
+ const wrote = /** @type {Record<string, any>} */ ({})
124
+ const fired = /** @type {Record<string, boolean>} */ ({})
125
+ let hasWrites = false
126
+ let hasFires = false
127
+ /** @type {any[] | null} every vector animation drawing, once the bundle is loaded */
128
+ let vectorList = null
102
129
  let isPointerWatched = false
103
130
  let hasFocusNodes = false
104
131
  /** @type {Record<string, Media>} a layout band's mixed media, by the band's width and height (`320x180`); they share the stage with `mixedMedia` */
@@ -119,16 +146,18 @@ export function createMotion(canvas, options) {
119
146
  overlay: () => ({ values: slotTable, families, inputOf }),
120
147
  onFrame: (item, frame) => writeBack(item, frame),
121
148
  placement,
122
- artboardWidth: () => blended,
123
- artboardHeight: () => blendedHeight,
149
+ // an animation that holds its own artboard size (a state keying it) is not sized to the canvas, as in Rive
150
+ artboardWidth: (shown) => (holdsSize(shown) ? 0 : blended),
151
+ artboardHeight: (shown) => (holdsSize(shown) ? 0 : blendedHeight),
124
152
  tick: (seconds) => {
153
+ if (objects) objects.step(seconds)
125
154
  nested.tick(seconds)
126
155
  lists.tick(seconds)
127
156
  },
128
157
  prepare: (shown) => {
129
158
  if (nested.count && shown.vector) nested.apply(shown.vector.slots, shown.fps)
130
159
  },
131
- keepAlive: () => nested.count > 0 || lists.isAnimated,
160
+ keepAlive: () => nested.count > 0 || lists.isAnimated || (!!objects && (objects.clocked || objects.live)),
132
161
  onCue: (cue) => {
133
162
  audio.play(cue.sound)
134
163
  // a keyed event reaches the host as a fired one does
@@ -220,23 +249,29 @@ export function createMotion(canvas, options) {
220
249
  const attempt = ++loadCount
221
250
  if (start && (start.fit !== undefined || start.alignment !== undefined)) asked = { fit: start.fit !== undefined ? start.fit : asked.fit, alignment: start.alignment !== undefined ? start.alignment : asked.alignment }
222
251
  return loadBundle(url)
223
- .then((loaded) => Promise.all([ loaded, loadFonts(loaded), prepareAll(loaded), loadRows(loaded) ]))
224
- .then(([ loaded, loadedFamilies, prepared, rows ]) => {
252
+ .then((loaded) => Promise.all([ loaded, loadFonts(loaded), prepareAll(loaded), loadRows(loaded), loadScripts(loaded.manifest, options && options.scripts, (file) => loadBytes(fileUrl(loaded, file)), (message) => emit('error', message)), loadSkins(loaded) ]))
253
+ .then(([ loaded, loadedFamilies, prepared, rows, loadedScripts, skinModule ]) => {
225
254
  // a later load won
226
255
  if (attempt !== loadCount) return
227
256
  bundle = loaded
257
+ scripts = loadedScripts
258
+ if (objects) objects.destroy()
259
+ makeSkins = skinModule ? skinModule.createSkins : null
260
+ skins = makeSkins && loaded.manifest.data && loaded.manifest.data.skins ? makeSkins(loaded.manifest.data.skins) : noSkins
228
261
  families = loadedFamilies
229
262
  media = prepared.media
230
263
  mixedMedia = prepared.mixed
231
264
  mixedBands = prepared.bands
265
+ vectorList = null
232
266
  isMixedShown = false
233
267
  inputOf = inputsOfSlots(loaded.manifest)
234
268
  nested = createNested(loaded.manifest.nested, { write: nestedWrote, focus: focusAction })
235
- lists = createLists(loaded.manifest.lists, rows, { base: loaded.base, families: () => families, band: bandWidth, blended: () => blended, cell: () => cell, error: (message) => emit('error', message), focus: focusAction })
269
+ lists = createLists(loaded.manifest.lists, rows, { skins: makeSkins, base: loaded.base, families: () => families, band: bandWidth, blended: () => blended, cell: () => cell, error: (message) => emit('error', message), focus: focusAction })
236
270
  listVersion = lists.version
237
271
  grid = gridOf(loaded.manifest)
238
272
  band = chooseBand()
239
273
  audio.load(loaded.manifest.audio, (uri) => fileUrl(loaded, uri))
274
+ objects = startObjects(loaded)
240
275
  flow = createFlow(loaded.manifest, hooks())
241
276
  const tree = focusTree()
242
277
  focusing.setup(tree.nodes, tree.keys)
@@ -372,6 +407,25 @@ export function createMotion(canvas, options) {
372
407
  }
373
408
  }
374
409
 
410
+ /**
411
+ * The skin code, when the bundle has skins to deform, its lists' rows' too: a module of its own, fetched only then.
412
+ *
413
+ * @param {Bundle} loaded
414
+ * @returns {Promise<typeof import('./skin/skin.js') | null>}
415
+ */
416
+ function loadSkins(loaded) {
417
+ const data = loaded.manifest.data
418
+ let has = !!(data && data.skins && data.skins.length)
419
+ const listed = loaded.manifest.lists || []
420
+ for (let n = 0; n < listed.length && !has; n++) {
421
+ for (const name in listed[n].rows) {
422
+ const rowData = listed[n].rows[name].data
423
+ if (rowData && rowData.skins && rowData.skins.length) has = true
424
+ }
425
+ }
426
+ return has ? import('./skin/skin.js') : Promise.resolve(null)
427
+ }
428
+
375
429
  /**
376
430
  * The Lottie of every row artboard the bundle's lists draw.
377
431
  *
@@ -421,12 +475,19 @@ export function createMotion(canvas, options) {
421
475
  play(id, loops) {
422
476
  player.show(media[variantOf(id)], loops, settings.renderer === 'raster')
423
477
  },
478
+ get scripts() {
479
+ return scripts
480
+ },
424
481
  valuesChanged(values) {
482
+ if (scripts) scripts.changed(values)
425
483
  applySlots(values)
426
484
  lists.update(values)
427
485
  if (lists.version !== listVersion) refocus()
428
486
  if (lists.isPointerStale && flow) watchPointer(flow.pointerTargets())
429
- player.render()
487
+ if (!isStepping) {
488
+ player.redraw()
489
+ if (objects && objects.live) player.nudge()
490
+ }
430
491
  emit('values', publicValues(bundle ? bundle.manifest.data : null, values))
431
492
  },
432
493
  stateChanged: (state) => emit('state', state),
@@ -480,7 +541,7 @@ export function createMotion(canvas, options) {
480
541
  const write = writes[n]
481
542
  const drawn = item.vector.probe(write.sid, frame)
482
543
  if (drawn === undefined) continue
483
- const next = writtenValue(write, drawn, current[write.path])
544
+ const next = writtenValue(write, drawn, current[write.path], scripts)
484
545
  if (next === undefined) continue
485
546
  const previous = current[write.path]
486
547
  const moved = typeof next === 'number' && typeof previous === 'number' ? Math.abs(next - previous) > 0.0001 * Math.max(1, Math.abs(previous)) : next !== previous
@@ -639,18 +700,128 @@ export function createMotion(canvas, options) {
639
700
  */
640
701
  function applySlots(values) {
641
702
  if (!bundle) return
642
- const table = slotValues(bundle.manifest.data, values, (message) => emit('error', message))
703
+ const table = slotValues(bundle.manifest.data, values, (message) => emit('error', message), scripts)
643
704
  slotTable = table
644
- const drawn = []
645
- for (const id in media) drawn.push(media[id].vector)
646
- if (mixedMedia) drawn.push(mixedMedia.vector)
647
- for (const width in mixedBands) drawn.push(mixedBands[width].vector)
648
- for (let n = 0; n < drawn.length; n++) {
649
- const vector = drawn[n]
650
- if (!vector) continue
651
- for (const sid in vector.slots) delete vector.slots[sid]
652
- for (const sid in table) vector.slots[sid] = table[sid]
705
+ const data = bundle.manifest.data
706
+ const warn = (/** @type {string} */ message) => emit('error', message)
707
+ if (skins.count) skins.update((source) => sourceValue(source, values, data, warn, scripts))
708
+ for (const id in media) writeSlots(media[id].vector, table, id)
709
+ if (mixedMedia) writeSlots(mixedMedia.vector, table, '', mixedMedia)
710
+ for (const width in mixedBands) writeSlots(mixedBands[width].vector, table, '', mixedBands[width])
711
+ }
712
+
713
+ /**
714
+ * One vector animation's slots: the slot table, and the skins' paths for the animation.
715
+ *
716
+ * @param {any} vector
717
+ * @param {Record<string, any>} table
718
+ * @param {string} id the animation's id, which says what it keys in a skin
719
+ * @param {Media} [mixed] mixed media, whose skins take what each pose's animation keys in them
720
+ */
721
+ function writeSlots(vector, table, id, mixed) {
722
+ if (!vector) return
723
+ for (const sid in vector.slots) delete vector.slots[sid]
724
+ for (const sid in table) vector.slots[sid] = table[sid]
725
+ if (skins.count) {
726
+ const deformed = skins.table(id)
727
+ const mixer = mixed && mixed.mixed ? mixed.mixed.mixer : null
728
+ for (const sid in deformed) vector.slots[sid] = mixer ? mixer.mixed((animation) => skins.keyedBy(sid, animation), deformed[sid]) : deformed[sid]
653
729
  }
730
+ if (objects) putOutputs(vector.slots)
731
+ }
732
+
733
+ /** Every vector animation the bundle draws, listed once. */
734
+ function vectorsOf() {
735
+ if (vectorList) return vectorList
736
+ const list = []
737
+ for (const id in media) if (media[id].vector) list.push(media[id].vector)
738
+ if (mixedMedia && mixedMedia.vector) list.push(mixedMedia.vector)
739
+ for (const width in mixedBands) if (mixedBands[width].vector) list.push(mixedBands[width].vector)
740
+ vectorList = list
741
+ return list
742
+ }
743
+
744
+ /**
745
+ * What the scripted objects drew, into a slot table.
746
+ *
747
+ * @param {Record<string, any>} slots
748
+ */
749
+ function putOutputs(slots) {
750
+ const made = /** @type {NonNullable<typeof objects>} */ (objects)
751
+ for (let n = 0; n < made.sids.length; n++) {
752
+ const value = made.outputs[made.sids[n]]
753
+ if (value !== undefined) slots[made.sids[n]] = value
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Starts the bundle's scripted nodes, drawables and path effects, when the
759
+ * script engine came with it. What they write lands in the flow's values at
760
+ * once, for the script after them, and reaches the machine, the nested
761
+ * artboards and the slots together, once a frame.
762
+ *
763
+ * @param {Bundle} loaded
764
+ * @returns {import('./script/play/play.objects.js').Objects | null}
765
+ */
766
+ function startObjects(loaded) {
767
+ const list = loaded.manifest.scriptObjects
768
+ if (!scripts || !list || !list.length) return null
769
+ return scripts.objects(list, {
770
+ data: loaded.manifest.data,
771
+ values: () => (flow ? flow.values : NO_VALUES),
772
+ write: (path, value) => {
773
+ if (!flow) return
774
+ flow.values[path] = value
775
+ wrote[path] = value
776
+ hasWrites = true
777
+ },
778
+ fire: (path) => {
779
+ fired[path] = true
780
+ hasFires = true
781
+ },
782
+ commit: () => {
783
+ if (!flow || (!hasWrites && !hasFires)) return false
784
+ const isWritten = hasWrites
785
+ const isFired = hasFires
786
+ hasWrites = false
787
+ hasFires = false
788
+ isStepping = true
789
+ try {
790
+ if (isWritten) {
791
+ nested.takeInputs(wrote)
792
+ flow.changed()
793
+ }
794
+ if (isFired) {
795
+ nested.takeInputs(fired)
796
+ flow.takeInputs(fired)
797
+ }
798
+ } finally {
799
+ isStepping = false
800
+ for (const name in wrote) delete wrote[name]
801
+ for (const name in fired) delete fired[name]
802
+ }
803
+ return true
804
+ },
805
+ publish: () => {
806
+ const drawn = vectorsOf()
807
+ for (let n = 0; n < drawn.length; n++) putOutputs(drawn[n].slots)
808
+ },
809
+ probe: (sid) => {
810
+ const item = flow ? media[variantOf(flow.animation)] : null
811
+ return item && item.vector ? item.vector.probe(sid, player.frame) : undefined
812
+ },
813
+ families,
814
+ })
815
+ }
816
+
817
+ /**
818
+ * Whether media drawn in a layout band is not the band's size: the
819
+ * converter laid its animation out at the artboard size the animation holds.
820
+ *
821
+ * @param {Media} shown
822
+ */
823
+ function holdsSize(shown) {
824
+ return !!(band && shown && shown.width && shown.width !== band.width)
654
825
  }
655
826
 
656
827
  /** How the animation fits the canvas: what the caller asked for, else the bundle's, else contain and centred. */
@@ -868,6 +1039,11 @@ export function createMotion(canvas, options) {
868
1039
  focusing.setup([], [])
869
1040
  flow = null
870
1041
  bundle = null
1042
+ skins = noSkins
1043
+ makeSkins = null
1044
+ if (objects) objects.destroy()
1045
+ objects = null
1046
+ vectorList = null
871
1047
  media = {}
872
1048
  nested = createNested([])
873
1049
  lists = createLists([], {}, { base: '', band: bandWidth, error: () => {}, focus: () => {} })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@px-petals/roku.motion-web",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Motion for the web: plays a lottie-roku bundle on a 2D canvas, vector from its Lottie or from its raster sheets, with its state machine, live data, keys and text, for browsers that can't run Rive's own runtime.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -12,20 +12,12 @@
12
12
  "./*.js": "./*.js"
13
13
  },
14
14
  "files": [
15
- "motion.js",
16
- "audio",
17
- "bands",
18
- "bundle",
19
- "focus",
20
- "hit",
21
- "list",
22
- "lottie",
23
- "mixer",
24
- "motion",
25
- "player",
26
- "pointer",
15
+ "**/*",
27
16
  "!**/.tests",
28
- "!**/*.test.js"
17
+ "!**/*.test.js",
18
+ "!jsconfig*.json",
19
+ "!legacy",
20
+ "!.artifacts"
29
21
  ],
30
22
  "sideEffects": false,
31
23
  "browserslist": [
@@ -41,8 +33,10 @@
41
33
  "build:legacy": "node legacy/legacy.js",
42
34
  "legacy": "node .tests/legacy/legacy.js",
43
35
  "parity": "node .tests/parity/parity.js",
44
- "test": "node --test \".tests/*.test.js\" \"audio/.tests/*.test.js\" \"bands/.tests/*.test.js\" \"hit/.tests/*.test.js\" \"focus/.tests/*.test.js\" \"player/.tests/*.test.js\" \"pointer/.tests/*.test.js\" \"lottie/.tests/*.test.js\" \"list/.tests/*.test.js\" \"mixer/.tests/*.test.js\" \"motion/.tests/*.test.js\" \".tests/browser/*.test.js\"",
45
- "typecheck": "tsc -p jsconfig.json"
36
+ "test": "node --test \".tests/*.test.js\" \"audio/.tests/*.test.js\" \"bands/.tests/*.test.js\" \"hit/.tests/*.test.js\" \"focus/.tests/*.test.js\" \"player/.tests/*.test.js\" \"pointer/.tests/*.test.js\" \"lottie/.tests/*.test.js\" \"list/.tests/*.test.js\" \"mixer/.tests/*.test.js\" \"skin/.tests/*.test.js\" \"motion/.tests/*.test.js\" \"script/luau/.tests/*.test.js\" \"script/luau/.tests/reference/*.test.js\" \"script/rive/.tests/*.test.js\" \"script/play/.tests/*.test.js\" \".tests/browser/*.test.js\"",
37
+ "typecheck": "tsc -p jsconfig.json",
38
+ "luau:fetch": "node script/luau/.tests/reference/reference.fetch.js",
39
+ "luau:fixtures": "node script/luau/.tests/reference/reference.regen.js"
46
40
  },
47
41
  "repository": {
48
42
  "type": "git",
package/player/player.js CHANGED
@@ -71,8 +71,8 @@ const SETTLE_PASSES = 8
71
71
  * @param {(layer: number) => void} options.onFinished
72
72
  * @param {() => Overlay} options.overlay
73
73
  * @param {() => { fit: string, alignment: [number, number] }} [options.placement] how the animation fits the canvas, read on every frame
74
- * @param {() => number} [options.artboardWidth] the artboard's width when it is not the media's own (a layout blended between two bands), else 0; read on every frame
75
- * @param {() => number} [options.artboardHeight] the artboard's height when it is not the media's own (a layout sized to the canvas), else 0; read on every frame
74
+ * @param {(media: Media) => number} [options.artboardWidth] the artboard's width when it is not the media's own (a layout blended between two bands), else 0; read on every frame
75
+ * @param {(media: Media) => number} [options.artboardHeight] the artboard's height when it is not the media's own (a layout sized to the canvas), else 0; read on every frame
76
76
  * @param {() => void} [options.onTick] time has passed on a mixed media
77
77
  * @param {() => boolean} [options.isPinned] the animation must stay vector: pointer listeners hit-test its drawn shapes
78
78
  * @param {(cue: Cue) => void} options.onCue an audio cue the timeline passed
@@ -287,13 +287,16 @@ export function createPlayer(canvas, options) {
287
287
  /** The animation's space onto the canvas's, as the bundle's fit and alignment say. */
288
288
  function fitMatrix() {
289
289
  // between two layout bands that blend the artboard is as big as the canvas, which only a vector picture can be drawn at
290
- const blended = !useRaster && options.artboardWidth ? options.artboardWidth() : 0
291
- const blendedHeight = !useRaster && options.artboardHeight ? options.artboardHeight() : 0
290
+ const shown = /** @type {Media} */ (media)
291
+ const blended = !useRaster && options.artboardWidth ? options.artboardWidth(shown) : 0
292
+ const blendedHeight = !useRaster && options.artboardHeight ? options.artboardHeight(shown) : 0
292
293
  const width = blended || (/** @type {Media} */ (media)).width || canvas.width
293
294
  const height = blendedHeight || (/** @type {Media} */ (media)).height || canvas.height
294
295
  const placement = options.placement ? options.placement() : { fit: 'contain', alignment: /** @type {[number, number]} */ ([ 0, 0 ]) }
295
296
  const ratio = canvas.clientWidth > 0 ? canvas.width / canvas.clientWidth : 1
296
- return fitInto(placement.fit, placement.alignment, width, height, canvas.width, canvas.height, ratio)
297
+ // an artboard `layout` does not size to the canvas (it holds its own size) draws at the layout's scale, as Rive's does
298
+ const isHeld = placement.fit === 'layout' && !blended && !blendedHeight
299
+ return fitInto(isHeld ? 'none' : placement.fit, placement.alignment, width, height, canvas.width, canvas.height, ratio)
297
300
  }
298
301
 
299
302
  function render() {
@@ -371,7 +374,24 @@ export function createPlayer(canvas, options) {
371
374
  wake,
372
375
  setControl,
373
376
  render,
377
+ /** Shows changed values: on the frame already coming while it plays, else at once, so values set many times a frame draw once */
378
+ redraw() {
379
+ // a finished animation keeps drawing while something beside it runs (scripts, nested machines)
380
+ const isDrawing = playback === 'playing' || (playback === 'finished' && !!options.keepAlive && options.keepAlive())
381
+ const isFrameComing = !!frameHandle && isDrawing
382
+ if (!isFrameComing) render()
383
+ },
374
384
  resize,
385
+ /** Steps once more, though the animation has finished, for something beside it that has work (a script that reads a value that changed) */
386
+ nudge() {
387
+ if (!media || frameHandle || options.manual || playback !== 'finished') return
388
+ last = now()
389
+ frameHandle = raf(tick)
390
+ },
391
+ /** The frame of the vector animation showing: where its slots' properties are read */
392
+ get frame() {
393
+ return media && media.vector ? media.vector.inPoint + elapsed * media.fps : 0
394
+ },
375
395
  /** With `manual`, moves time on by these seconds and draws. */
376
396
  advance(/** @type {number} */ seconds) {
377
397
  if (options.manual) step(seconds)