@px-petals/roku.motion-web 0.0.0-stage → 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.
- package/LICENSE +201 -0
- package/audio/audio.cues.js +27 -0
- package/audio/audio.js +259 -0
- package/bands/bands.js +392 -0
- package/bundle/bundle.fonts.js +55 -0
- package/bundle/bundle.js +131 -0
- package/focus/focus.direction.js +46 -0
- package/focus/focus.dom.js +158 -0
- package/focus/focus.js +277 -0
- package/focus/focus.tree.js +146 -0
- package/hit/hit.geometry.js +113 -0
- package/hit/hit.js +129 -0
- package/list/list.flex.js +181 -0
- package/list/list.items.js +80 -0
- package/list/list.js +518 -0
- package/lottie/lottie.js +124 -0
- package/lottie/lottie.layers.js +416 -0
- package/lottie/lottie.paint.js +238 -0
- package/lottie/lottie.paths.js +474 -0
- package/lottie/lottie.props.js +265 -0
- package/lottie/lottie.shapes.js +259 -0
- package/mixer/mixer.js +307 -0
- package/mixer/mixer.mix.js +107 -0
- package/mixer/mixer.stage.js +431 -0
- package/motion/motion.convert.js +404 -0
- package/motion/motion.flow.js +421 -0
- package/motion/motion.keys.js +150 -0
- package/motion/motion.machine.js +382 -0
- package/motion/motion.nested.js +345 -0
- package/motion/motion.values.js +368 -0
- package/motion.js +1155 -0
- package/package.json +47 -4
- package/player/overlay/overlay.js +325 -0
- package/player/overlay/overlay.layout.js +208 -0
- package/player/player.fit.js +101 -0
- package/player/player.js +421 -0
- package/player/player.raster.js +127 -0
- package/pointer/pointer.js +292 -0
- package/readme.md +345 -0
- package/script/luau/lib/base/base.js +202 -0
- package/script/luau/lib/base/base.table.js +189 -0
- package/script/luau/lib/lib.args.js +152 -0
- package/script/luau/lib/lib.js +23 -0
- package/script/luau/lib/math/math.bit32.js +144 -0
- package/script/luau/lib/math/math.js +288 -0
- package/script/luau/lib/math/math.vector.js +125 -0
- package/script/luau/lib/string/string.format.js +143 -0
- package/script/luau/lib/string/string.js +160 -0
- package/script/luau/lib/string/string.pattern.js +505 -0
- package/script/luau/load/load.js +362 -0
- package/script/luau/load/load.ops.js +79 -0
- package/script/luau/luau.convert.js +259 -0
- package/script/luau/luau.js +132 -0
- package/script/luau/luau.md +111 -0
- package/script/luau/vm/vm.js +167 -0
- package/script/luau/vm/vm.meta.js +401 -0
- package/script/luau/vm/vm.num.js +236 -0
- package/script/luau/vm/vm.run.js +842 -0
- package/script/luau/vm/vm.value.js +406 -0
- package/script/play/play.js +199 -0
- package/script/play/play.objects.js +295 -0
- package/script/play/play.scope.js +91 -0
- package/script/rive/api/api.statics.js +31 -0
- package/script/rive/api/api.type.js +188 -0
- package/script/rive/api/artboard/artboard.js +89 -0
- package/script/rive/api/assets/assets.js +72 -0
- package/script/rive/api/data/data.value.js +99 -0
- package/script/rive/api/draw/draw.contours.js +163 -0
- package/script/rive/api/draw/draw.measure.js +196 -0
- package/script/rive/api/draw/draw.paint.js +235 -0
- package/script/rive/api/draw/draw.path.js +153 -0
- package/script/rive/api/draw/draw.renderer.js +117 -0
- package/script/rive/api/math/math.color.js +96 -0
- package/script/rive/api/math/math.mat2d.js +180 -0
- package/script/rive/api/math/math.vector.js +121 -0
- package/script/rive/api/media/media.audio.js +113 -0
- package/script/rive/api/media/media.draw.js +73 -0
- package/script/rive/api/media/media.image.js +61 -0
- package/script/rive/api/media/media.mesh.js +50 -0
- package/script/rive/api/pointer/pointer.event.js +64 -0
- package/script/rive/api/pointer/pointer.route.js +69 -0
- package/script/rive/api/text/text.font.js +129 -0
- package/script/rive/api/text/text.js +278 -0
- package/script/rive/api/text/text.layout.js +259 -0
- package/script/rive/context/context.js +76 -0
- package/script/rive/context/context.memory.js +196 -0
- package/script/rive/context/context.viewmodel.js +302 -0
- package/script/rive/context/lists/lists.js +160 -0
- package/script/rive/context/lists/lists.values.js +281 -0
- package/script/rive/host/host.globals.js +44 -0
- package/script/rive/host/host.js +533 -0
- package/script/rive/host/host.node.js +45 -0
- package/script/rive/rive.js +23 -0
- package/script/rive/rive.md +180 -0
- package/script/script.js +11 -0
- package/skin/skin.deform.js +514 -0
- package/skin/skin.ik.js +260 -0
- package/skin/skin.js +297 -0
- package/README.md +0 -3
package/motion.js
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion for the web: plays a lottie-roku bundle on a 2D canvas, for
|
|
3
|
+
* browsers that can't run Rive's own runtime (no WebGL2 or WebAssembly).
|
|
4
|
+
* Each animation draws as vector from its Lottie, or from its raster sheets
|
|
5
|
+
* where vector is too slow or asked against; a bundle's state machine,
|
|
6
|
+
* live data, keys and text work as on Roku (runtime/roku).
|
|
7
|
+
*
|
|
8
|
+
* const motion = createMotion(canvas)
|
|
9
|
+
* motion.on('event', (name) => …)
|
|
10
|
+
* await motion.load('https://…/bundle/motion.json', { inputs: { score: 3 } })
|
|
11
|
+
* motion.setInputs({ score: 4 })
|
|
12
|
+
* motion.key('enter')
|
|
13
|
+
*
|
|
14
|
+
* A bundle whose machine has layers, blend states, cross-fades or exit times
|
|
15
|
+
* plays through the pose mixer (mixer/) instead of one animation at a time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createAudio } from './audio/audio.js'
|
|
19
|
+
import { loadBundle, loadBytes, loadJson, loadScripts, fileUrl } from './bundle/bundle.js'
|
|
20
|
+
import { loadFonts } from './bundle/bundle.fonts.js'
|
|
21
|
+
import { createAnimation } from './lottie/lottie.js'
|
|
22
|
+
import { createLists } from './list/list.js'
|
|
23
|
+
import { createMixer } from './mixer/mixer.js'
|
|
24
|
+
import { createStage } from './mixer/mixer.stage.js'
|
|
25
|
+
import { createFocus, PHASES } from './focus/focus.js'
|
|
26
|
+
import { createDom } from './focus/focus.dom.js'
|
|
27
|
+
import { createFlow } from './motion/motion.flow.js'
|
|
28
|
+
import { exists, layersOf } from './motion/motion.machine.js'
|
|
29
|
+
import { isClick, keyFromEvent, keyNames, modifiersFromEvent, parseKey } from './motion/motion.keys.js'
|
|
30
|
+
import { createNested } from './motion/motion.nested.js'
|
|
31
|
+
import { flattenInputs, fromPairs, publicValues, slotValues, sourceValue, tintColors, writtenValue } from './motion/motion.values.js'
|
|
32
|
+
import { cellOf, gridOf, hasBlend, linkBands, variantIds } from './bands/bands.js'
|
|
33
|
+
import { alignmentOf, fitName } from './player/player.fit.js'
|
|
34
|
+
import { createPlayer } from './player/player.js'
|
|
35
|
+
import { createPointer } from './pointer/pointer.js'
|
|
36
|
+
import { createRaster } from './player/player.raster.js'
|
|
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
|
+
|
|
44
|
+
/** @import { Bundle } from './bundle/bundle.js' */
|
|
45
|
+
/** @import { Media } from './player/player.js' */
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} MotionOptions
|
|
49
|
+
* @property {'auto' | 'vector' | 'raster'} [renderer] `auto` (default) draws vector, moving a slow animation to its sheets
|
|
50
|
+
* @property {boolean} [clicks] a tap or click on the canvas clicks the animation, and on a listener's target clicks it; default true. Other pointer listeners always answer
|
|
51
|
+
* @property {boolean} [muted] play no sound; `setMuted` changes it later. Default false
|
|
52
|
+
* @property {string} [fit] how the animation fits the canvas, over the bundle's: contain, cover, fill, fitWidth, fitHeight, scaleDown, none or layout
|
|
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
|
|
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`
|
|
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
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** The values of a Motion with no bundle playing */
|
|
60
|
+
const NO_VALUES = {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {'loaded' | 'state' | 'playback' | 'finished' | 'values' | 'event' | 'audio' | 'focus' | 'error'} MotionEvent
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {HTMLCanvasElement} canvas
|
|
68
|
+
* @param {MotionOptions} [options]
|
|
69
|
+
*/
|
|
70
|
+
export function createMotion(canvas, options) {
|
|
71
|
+
const settings = { renderer: (options && options.renderer) || 'auto', clicks: !options || options.clicks !== false, muted: !!(options && options.muted), keys: options && options.keys === 'manual' ? 'manual' : 'auto', manual: !!(options && options.manual) }
|
|
72
|
+
/** @type {Record<string, Function[]>} */
|
|
73
|
+
const listeners = {}
|
|
74
|
+
|
|
75
|
+
/** @type {Bundle | null} */
|
|
76
|
+
let bundle = null
|
|
77
|
+
/** @type {ReturnType<typeof createFlow> | null} */
|
|
78
|
+
let flow = null
|
|
79
|
+
/** @type {Record<string, Media>} */
|
|
80
|
+
let media = {}
|
|
81
|
+
/** @type {Record<string, string>} */
|
|
82
|
+
let families = {}
|
|
83
|
+
/** @type {Record<string, string>} */
|
|
84
|
+
let inputOf = {}
|
|
85
|
+
/** Slot id → value for the current values, which the text overlays read */
|
|
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
|
|
91
|
+
/** Whether a write-back is in progress, whose own redraw must not write again */
|
|
92
|
+
let writing = false
|
|
93
|
+
let loadCount = 0
|
|
94
|
+
/** @type {{ fit?: string, alignment?: any }} what the caller asked for, over the bundle's */
|
|
95
|
+
let asked = { fit: options && options.fit, alignment: options && options.alignment }
|
|
96
|
+
/** @type {any} the layout band playing */
|
|
97
|
+
let band = null
|
|
98
|
+
/** The weights toward the next, wider layout band (`t`) and the next, taller one (`v`), which every band that blends into them draws in; 0 outside a blending segment */
|
|
99
|
+
const live = { t: 0, v: 0 }
|
|
100
|
+
/** The artboard width a blending segment draws at, which is the canvas's; 0 when the band plays at its own */
|
|
101
|
+
let blended = 0
|
|
102
|
+
/** The artboard height likewise: the canvas's where the layout is exact for it, else 0 */
|
|
103
|
+
let blendedHeight = 0
|
|
104
|
+
/** @type {import('./bands/bands.js').Grid | null} the layout bands as a grid, when the artboard is responsive */
|
|
105
|
+
let grid = null
|
|
106
|
+
/** @type {import('./bands/bands.js').Cell | null} where the canvas falls in the grid, while the fit is layout */
|
|
107
|
+
let cell = null
|
|
108
|
+
let nested = createNested([])
|
|
109
|
+
/** The lists of the bundle: a row drawn for each item of a view-model list */
|
|
110
|
+
let lists = createLists([], {}, { base: '', band: bandWidth, error: (message) => emit('error', message), focus: focusAction })
|
|
111
|
+
/** The `version` of the lists' rows the focus tree was built for */
|
|
112
|
+
let listVersion = 0
|
|
113
|
+
/** @type {Media | null} the mixed media of a bundle that plays through the mixer */
|
|
114
|
+
let mixedMedia = null
|
|
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
|
|
129
|
+
let isPointerWatched = false
|
|
130
|
+
let hasFocusNodes = false
|
|
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` */
|
|
132
|
+
let mixedBands = {}
|
|
133
|
+
|
|
134
|
+
const player = createPlayer(canvas, {
|
|
135
|
+
adaptive: settings.renderer === 'auto',
|
|
136
|
+
manual: settings.manual,
|
|
137
|
+
onPlayback: (playback) => emit('playback', playback),
|
|
138
|
+
onFinished: (layer) => {
|
|
139
|
+
if (layer === 0) emit('finished')
|
|
140
|
+
if (flow) flow.finished(layer)
|
|
141
|
+
},
|
|
142
|
+
onTick: () => {
|
|
143
|
+
if (flow) flow.tick()
|
|
144
|
+
},
|
|
145
|
+
isPinned: () => isPointerWatched || hasFocusNodes || lists.count > 0,
|
|
146
|
+
overlay: () => ({ values: slotTable, families, inputOf }),
|
|
147
|
+
onFrame: (item, frame) => writeBack(item, frame),
|
|
148
|
+
placement,
|
|
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),
|
|
152
|
+
tick: (seconds) => {
|
|
153
|
+
if (objects) objects.step(seconds)
|
|
154
|
+
nested.tick(seconds)
|
|
155
|
+
lists.tick(seconds)
|
|
156
|
+
},
|
|
157
|
+
prepare: (shown) => {
|
|
158
|
+
if (nested.count && shown.vector) nested.apply(shown.vector.slots, shown.fps)
|
|
159
|
+
},
|
|
160
|
+
keepAlive: () => nested.count > 0 || lists.isAnimated || (!!objects && (objects.clocked || objects.live)),
|
|
161
|
+
onCue: (cue) => {
|
|
162
|
+
audio.play(cue.sound)
|
|
163
|
+
// a keyed event reaches the host as a fired one does
|
|
164
|
+
emit('event', cue.event)
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
const audio = createAudio({
|
|
168
|
+
muted: settings.muted,
|
|
169
|
+
onError: (message) => emit('error', message),
|
|
170
|
+
onPlay: (sound) => emit('audio', sound),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Rive's focus: which target of the graphic the keys belong to. It is not DOM focus, which Motion never takes
|
|
174
|
+
// A node of a nested artboard is `<sid>/<rid>`, a list row's `row.<id>/<rid>`: the machine that owns it runs its listeners and actions
|
|
175
|
+
const focusing = createFocus({
|
|
176
|
+
fire: (type, rid) => {
|
|
177
|
+
const at = rid.lastIndexOf('/')
|
|
178
|
+
if (at < 0) {
|
|
179
|
+
if (flow) flow.focusEvent(type, rid)
|
|
180
|
+
} else if (rid.indexOf('row.') === 0) {
|
|
181
|
+
lists.focusEvent(type, rid.slice(0, at), rid.slice(at + 1))
|
|
182
|
+
} else {
|
|
183
|
+
nested.focusEvent(type, rid.slice(0, at), rid.slice(at + 1))
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
run: (actions, scope) => {
|
|
187
|
+
if (!scope) {
|
|
188
|
+
if (flow) flow.act(actions)
|
|
189
|
+
} else if (scope.indexOf('row.') === 0) {
|
|
190
|
+
lists.act(scope, actions)
|
|
191
|
+
} else {
|
|
192
|
+
nested.act(scope, actions)
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
centerOf: (rid) => {
|
|
196
|
+
if (rid.indexOf('row.') === 0) return lists.center(rid)
|
|
197
|
+
const hits = player.hits
|
|
198
|
+
return hits ? hits.center(rid) : null
|
|
199
|
+
},
|
|
200
|
+
changed: (rid) => emit('focus', rid),
|
|
201
|
+
})
|
|
202
|
+
const dom = createDom(canvas, { focus: focusing })
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {MotionEvent} name
|
|
206
|
+
* @param {any} [detail]
|
|
207
|
+
*/
|
|
208
|
+
function emit(name, detail) {
|
|
209
|
+
const list = (listeners[name] || []).slice()
|
|
210
|
+
for (let n = 0; n < list.length; n++) list[n](detail)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// a click on the canvas is the listeners with no target of their own; the ones with a target hear the pointer (pointer/)
|
|
214
|
+
const onClick = () => {
|
|
215
|
+
if (flow) flow.click(true)
|
|
216
|
+
}
|
|
217
|
+
if (settings.clicks) canvas.addEventListener('click', onClick)
|
|
218
|
+
// the shapes the host's listeners and those of its list rows watch: a row's are named `<row>|<target>`
|
|
219
|
+
const hitsOf = {
|
|
220
|
+
test: (/** @type {string} */ name, /** @type {number} */ x, /** @type {number} */ y) => {
|
|
221
|
+
if (name.indexOf('|') > 0) return lists.hit(name, x, y)
|
|
222
|
+
const hits = player.hits
|
|
223
|
+
return !!hits && hits.test(name, x, y)
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
const pointer = createPointer(canvas, {
|
|
227
|
+
hits: () => /** @type {any} */ (hitsOf),
|
|
228
|
+
fire: (type, target) => {
|
|
229
|
+
if (type !== 'Click' || settings.clicks) {
|
|
230
|
+
if (!nested.pointer(type, target) && !lists.pointer(type, target) && flow) flow.pointer(type, target)
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
clicks: settings.clicks,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Loads a bundle and starts it.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} url motion.json
|
|
240
|
+
* @param {object} [start]
|
|
241
|
+
* @param {Record<string, any>} [start.inputs] values to start with, over the view model's defaults
|
|
242
|
+
* @param {string} [start.state] a state to start in instead of the machine's own
|
|
243
|
+
* @param {Record<string, string>} [start.transitions] without a machine, the animation each one's end plays next
|
|
244
|
+
* @param {string} [start.fit] how the animation fits the canvas, over the bundle's
|
|
245
|
+
* @param {string | number[]} [start.alignment] where it sits in the canvas, over the bundle's
|
|
246
|
+
* @returns {Promise<void>}
|
|
247
|
+
*/
|
|
248
|
+
function load(url, start) {
|
|
249
|
+
const attempt = ++loadCount
|
|
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 }
|
|
251
|
+
return loadBundle(url)
|
|
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 ]) => {
|
|
254
|
+
// a later load won
|
|
255
|
+
if (attempt !== loadCount) return
|
|
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
|
|
261
|
+
families = loadedFamilies
|
|
262
|
+
media = prepared.media
|
|
263
|
+
mixedMedia = prepared.mixed
|
|
264
|
+
mixedBands = prepared.bands
|
|
265
|
+
vectorList = null
|
|
266
|
+
isMixedShown = false
|
|
267
|
+
inputOf = inputsOfSlots(loaded.manifest)
|
|
268
|
+
nested = createNested(loaded.manifest.nested, { write: nestedWrote, 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 })
|
|
270
|
+
listVersion = lists.version
|
|
271
|
+
grid = gridOf(loaded.manifest)
|
|
272
|
+
band = chooseBand()
|
|
273
|
+
audio.load(loaded.manifest.audio, (uri) => fileUrl(loaded, uri))
|
|
274
|
+
objects = startObjects(loaded)
|
|
275
|
+
flow = createFlow(loaded.manifest, hooks())
|
|
276
|
+
const tree = focusTree()
|
|
277
|
+
focusing.setup(tree.nodes, tree.keys)
|
|
278
|
+
hasFocusNodes = focusing.hasNodes
|
|
279
|
+
dom.listen(settings.keys === 'auto' && (focusing.hasNodes || tree.keys.length > 0))
|
|
280
|
+
watchPointer(flow.pointerTargets())
|
|
281
|
+
flow.start(flattenInputs((start && start.inputs) || {}), start && start.state, start && start.transitions)
|
|
282
|
+
// what the nested machines focused as they started, now there is a tree
|
|
283
|
+
nested.flushFocus()
|
|
284
|
+
if (start && start.inputs) nested.takeInputs(start.inputs)
|
|
285
|
+
emit('loaded')
|
|
286
|
+
})
|
|
287
|
+
.catch((error) => {
|
|
288
|
+
emit('error', error.message)
|
|
289
|
+
throw error
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Every animation, ready to draw: its Lottie for vector, its sheets for
|
|
295
|
+
* raster, as the renderer setting needs them.
|
|
296
|
+
*
|
|
297
|
+
* @param {Bundle} loaded
|
|
298
|
+
* @returns {Promise<{ media: Record<string, Media>, mixed: Media | null, bands: Record<string, Media> }>}
|
|
299
|
+
*/
|
|
300
|
+
function prepareAll(loaded) {
|
|
301
|
+
const manifest = loaded.manifest
|
|
302
|
+
const isMixed = settings.renderer !== 'raster' && usesMixer(manifest)
|
|
303
|
+
// bands that blend are linked before anything draws, so their Lotties are read first and compiled after
|
|
304
|
+
const isBlended = settings.renderer !== 'raster' && hasBlend(manifest)
|
|
305
|
+
/** @type {Record<string, Media>} */
|
|
306
|
+
const prepared = {}
|
|
307
|
+
/** @type {Record<string, any>} */
|
|
308
|
+
const lotties = {}
|
|
309
|
+
const pending = []
|
|
310
|
+
for (const id in manifest.animations) {
|
|
311
|
+
const entry = manifest.animations[id]
|
|
312
|
+
const wantsVector = settings.renderer !== 'raster'
|
|
313
|
+
const wantsRaster = settings.renderer !== 'vector' && !!entry.raster
|
|
314
|
+
/** @type {Media} */
|
|
315
|
+
const item = {
|
|
316
|
+
vector: null,
|
|
317
|
+
raster: wantsRaster ? createRaster(entry, loaded) : null,
|
|
318
|
+
width: entry.width,
|
|
319
|
+
height: entry.height,
|
|
320
|
+
fps: entry.fps,
|
|
321
|
+
frames: entry.frames,
|
|
322
|
+
texts: entry.texts || [],
|
|
323
|
+
cues: (manifest.audio && manifest.audio.cues && manifest.audio.cues[id]) || [],
|
|
324
|
+
tints: () => (entry.raster && flow ? tintColors(entry.raster.tints, manifest.data, flow.values) : []),
|
|
325
|
+
}
|
|
326
|
+
prepared[id] = item
|
|
327
|
+
if (item.raster) pending.push(item.raster.ready)
|
|
328
|
+
if (isMixed || isBlended) {
|
|
329
|
+
// the mixer draws every animation from one of their structures; each is read, not compiled
|
|
330
|
+
pending.push(loadJson(fileUrl(loaded, entry.lottie)).then((lottie) => {
|
|
331
|
+
lotties[id] = lottie
|
|
332
|
+
}))
|
|
333
|
+
} else if (wantsVector) {
|
|
334
|
+
pending.push(loadJson(fileUrl(loaded, entry.lottie)).then((lottie) => {
|
|
335
|
+
item.vector = drawsRows(createAnimation(lottie, { base: loaded.base, families: () => families }))
|
|
336
|
+
return item.vector.ready
|
|
337
|
+
}))
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return Promise.all(pending).then(() => {
|
|
341
|
+
if (isBlended) linkBands(manifest, lotties, live)
|
|
342
|
+
if (!isMixed && isBlended) {
|
|
343
|
+
const ready = []
|
|
344
|
+
for (const id in lotties) {
|
|
345
|
+
prepared[id].vector = drawsRows(createAnimation(lotties[id], { base: loaded.base, families: () => families }))
|
|
346
|
+
ready.push(/** @type {Promise<any>} */ (prepared[id].vector.ready))
|
|
347
|
+
}
|
|
348
|
+
return Promise.all(ready).then(() => ({ media: prepared, mixed: null, bands: {} }))
|
|
349
|
+
}
|
|
350
|
+
if (!isMixed) return { media: prepared, mixed: null, bands: {} }
|
|
351
|
+
|
|
352
|
+
// every layout band mixes its own variants of the animations, on the one stage, so a band moves in step with the layers
|
|
353
|
+
const stage = createStage(manifest.animations)
|
|
354
|
+
/** @type {Record<string, Media>} */
|
|
355
|
+
const bands = {}
|
|
356
|
+
const listed = gridOf(manifest)
|
|
357
|
+
const rows = listed ? listed.rows : []
|
|
358
|
+
for (let j = 0; j < rows.length; j++) {
|
|
359
|
+
for (let i = 0; i < rows[j].length; i++) {
|
|
360
|
+
const corner = rows[j][i]
|
|
361
|
+
if (corner.animations) bands[bandKey(corner)] = mixedOf(manifest, prepared, lotties, loaded.base, stage, corner.animations)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { media: prepared, mixed: mixedOf(manifest, prepared, lotties, loaded.base, stage, null), bands }
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The media a mixed bundle shows: one picture of the carrier's size, drawn by the mixer from what the stage says is playing.
|
|
370
|
+
*
|
|
371
|
+
* @param {any} manifest
|
|
372
|
+
* @param {Record<string, Media>} items
|
|
373
|
+
* @param {Record<string, any>} lotties
|
|
374
|
+
* @param {string} base
|
|
375
|
+
* @param {ReturnType<typeof createStage>} stage
|
|
376
|
+
* @param {Record<string, string> | null} variants a layout band's variant of each animation; null for the file's own size
|
|
377
|
+
* @returns {Media}
|
|
378
|
+
*/
|
|
379
|
+
function mixedOf(manifest, items, lotties, base, stage, variants) {
|
|
380
|
+
/** @param {string} id the animation a layer names, or its variant in the band */
|
|
381
|
+
const picked = (id) => (variants && variants[id] ? variants[id] : id)
|
|
382
|
+
/** @type {Record<string, any>} */
|
|
383
|
+
const own = {}
|
|
384
|
+
const isVariant = variantIds(manifest)
|
|
385
|
+
for (const id in lotties) if (!isVariant[id]) own[id] = lotties[picked(id)]
|
|
386
|
+
const mixer = createMixer(own, { base, families: () => families })
|
|
387
|
+
const carrier = items[picked(mixer.carrier)]
|
|
388
|
+
return {
|
|
389
|
+
vector: drawsRows(mixer.animation),
|
|
390
|
+
raster: null,
|
|
391
|
+
width: carrier.width,
|
|
392
|
+
height: carrier.height,
|
|
393
|
+
fps: carrier.fps,
|
|
394
|
+
frames: Infinity,
|
|
395
|
+
cues: [],
|
|
396
|
+
texts: [],
|
|
397
|
+
tints: () => [],
|
|
398
|
+
mixed: {
|
|
399
|
+
mixer,
|
|
400
|
+
stage,
|
|
401
|
+
values: () => (flow ? flow.values : {}),
|
|
402
|
+
texts: (id) => (items[picked(id)] ? items[picked(id)].texts : []),
|
|
403
|
+
fps: (id) => (items[picked(id)] ? items[picked(id)].fps : 30),
|
|
404
|
+
cues: (id) => (items[picked(id)] ? items[picked(id)].cues : []),
|
|
405
|
+
item: (id) => items[picked(id)] || null,
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
}
|
|
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
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The Lottie of every row artboard the bundle's lists draw.
|
|
431
|
+
*
|
|
432
|
+
* @param {Bundle} loaded
|
|
433
|
+
* @returns {Promise<Record<string, any>>} by file name
|
|
434
|
+
*/
|
|
435
|
+
function loadRows(loaded) {
|
|
436
|
+
/** @type {Record<string, any>} */
|
|
437
|
+
const found = {}
|
|
438
|
+
const pending = []
|
|
439
|
+
const listed = loaded.manifest.lists || []
|
|
440
|
+
for (let n = 0; n < listed.length; n++) {
|
|
441
|
+
for (const name in listed[n].rows) {
|
|
442
|
+
const file = listed[n].rows[name].lottie
|
|
443
|
+
pending.push(loadJson(fileUrl(loaded, file)).then((lottie) => {
|
|
444
|
+
found[file] = lottie
|
|
445
|
+
}))
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return Promise.all(pending).then(() => found)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Has an animation draw the lists' rows after its own layers.
|
|
453
|
+
*
|
|
454
|
+
* @param {import('./lottie/lottie.js').Animation} animation
|
|
455
|
+
* @returns {import('./lottie/lottie.js').Animation}
|
|
456
|
+
*/
|
|
457
|
+
function drawsRows(animation) {
|
|
458
|
+
const draw = animation.draw
|
|
459
|
+
animation.draw = (ctx, frame, matrix) => {
|
|
460
|
+
draw(ctx, frame, matrix)
|
|
461
|
+
if (lists.count) lists.draw(ctx, matrix)
|
|
462
|
+
}
|
|
463
|
+
return animation
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The width of the layout band showing, 0 for the file's own size. */
|
|
467
|
+
function bandWidth() {
|
|
468
|
+
return band ? band.width : 0
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** What the flow calls back with. */
|
|
472
|
+
function hooks() {
|
|
473
|
+
/** @type {import('./motion/motion.flow.js').Hooks} */
|
|
474
|
+
const result = {
|
|
475
|
+
play(id, loops) {
|
|
476
|
+
player.show(media[variantOf(id)], loops, settings.renderer === 'raster')
|
|
477
|
+
},
|
|
478
|
+
get scripts() {
|
|
479
|
+
return scripts
|
|
480
|
+
},
|
|
481
|
+
valuesChanged(values) {
|
|
482
|
+
if (scripts) scripts.changed(values)
|
|
483
|
+
applySlots(values)
|
|
484
|
+
lists.update(values)
|
|
485
|
+
if (lists.version !== listVersion) refocus()
|
|
486
|
+
if (lists.isPointerStale && flow) watchPointer(flow.pointerTargets())
|
|
487
|
+
if (!isStepping) {
|
|
488
|
+
player.redraw()
|
|
489
|
+
if (objects && objects.live) player.nudge()
|
|
490
|
+
}
|
|
491
|
+
emit('values', publicValues(bundle ? bundle.manifest.data : null, values))
|
|
492
|
+
},
|
|
493
|
+
stateChanged: (state) => emit('state', state),
|
|
494
|
+
event: (name) => {
|
|
495
|
+
audio.fire(name)
|
|
496
|
+
emit('event', name)
|
|
497
|
+
},
|
|
498
|
+
error: (message) => emit('error', message),
|
|
499
|
+
focus: (action) => focusAction(action, ''),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const stage = mixedMedia && mixedMedia.mixed ? mixedMedia.mixed.stage : null
|
|
503
|
+
if (!stage || !mixedMedia) return result
|
|
504
|
+
|
|
505
|
+
// a bundle that plays through the mixer: the flow tells the stage what every layer enters
|
|
506
|
+
result.layer = (layer, state, fadeMs, ease) => {
|
|
507
|
+
const missing = unknownAnimation(state, media)
|
|
508
|
+
if (missing) {
|
|
509
|
+
emit('error', "unknown animation '" + missing + "'")
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
stage.enter(layer, state, fadeMs, ease)
|
|
513
|
+
if (isMixedShown) {
|
|
514
|
+
player.wake()
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
isMixedShown = true
|
|
518
|
+
player.show(mixedFor(), false, false)
|
|
519
|
+
}
|
|
520
|
+
result.elapsed = (layer) => stage.elapsedMs(layer)
|
|
521
|
+
return result
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Binds that write an animated property back into the view model: the
|
|
526
|
+
* property as drawn at this frame, in the view model's units, becomes its
|
|
527
|
+
* value when it has moved.
|
|
528
|
+
*
|
|
529
|
+
* @param {Media} item
|
|
530
|
+
* @param {number} frame
|
|
531
|
+
*/
|
|
532
|
+
function writeBack(item, frame) {
|
|
533
|
+
const writes = bundle && bundle.manifest.data && bundle.manifest.data.writes
|
|
534
|
+
if (!writes || !writes.length || !flow || writing || !item.vector) return
|
|
535
|
+
|
|
536
|
+
const current = flow.values
|
|
537
|
+
/** @type {Record<string, any>} */
|
|
538
|
+
const changed = {}
|
|
539
|
+
let any = false
|
|
540
|
+
for (let n = 0; n < writes.length; n++) {
|
|
541
|
+
const write = writes[n]
|
|
542
|
+
const drawn = item.vector.probe(write.sid, frame)
|
|
543
|
+
if (drawn === undefined) continue
|
|
544
|
+
const next = writtenValue(write, drawn, current[write.path], scripts)
|
|
545
|
+
if (next === undefined) continue
|
|
546
|
+
const previous = current[write.path]
|
|
547
|
+
const moved = typeof next === 'number' && typeof previous === 'number' ? Math.abs(next - previous) > 0.0001 * Math.max(1, Math.abs(previous)) : next !== previous
|
|
548
|
+
if (moved) {
|
|
549
|
+
changed[write.path] = next
|
|
550
|
+
any = true
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (!any) return
|
|
554
|
+
|
|
555
|
+
writing = true
|
|
556
|
+
try {
|
|
557
|
+
flow.takeInputs(changed)
|
|
558
|
+
} finally {
|
|
559
|
+
writing = false
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A nested artboard's machine set properties of the host's view model: the
|
|
566
|
+
* host takes them as it takes a bind's write-back, and the other nested
|
|
567
|
+
* artboards bound to them follow.
|
|
568
|
+
*
|
|
569
|
+
* @param {Record<string, any>} changes
|
|
570
|
+
*/
|
|
571
|
+
function nestedWrote(changes) {
|
|
572
|
+
if (!flow || writing) return
|
|
573
|
+
writing = true
|
|
574
|
+
try {
|
|
575
|
+
flow.takeInputs(changes)
|
|
576
|
+
nested.takeInputs(changes)
|
|
577
|
+
} finally {
|
|
578
|
+
writing = false
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A focus action a machine ran. Its FocusTarget names a node of the
|
|
584
|
+
* machine's own artboard, which `scope` (the machine's id, "" for the
|
|
585
|
+
* bundle's own) puts in the host's tree; a move or a clear is the tree's.
|
|
586
|
+
*
|
|
587
|
+
* @param {any} action
|
|
588
|
+
* @param {string} scope
|
|
589
|
+
*/
|
|
590
|
+
function focusAction(action, scope) {
|
|
591
|
+
if (action.type === 'FocusTarget') focusing.focus(scope ? scope + '/' + action.rid : action.rid)
|
|
592
|
+
else if (action.type === 'FocusMove') focusing.move(action.direction)
|
|
593
|
+
else focusing.clear()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The focus tree of the bundle as it is now: the host's nodes, a nested
|
|
598
|
+
* artboard's among them, and the rows' of each list where its place is,
|
|
599
|
+
* with every key binding that names one.
|
|
600
|
+
*
|
|
601
|
+
* @returns {{ nodes: any[], keys: any[] }}
|
|
602
|
+
*/
|
|
603
|
+
function focusTree() {
|
|
604
|
+
const manifest = bundle ? bundle.manifest : {}
|
|
605
|
+
const host = manifest.machine && manifest.machine.focus ? manifest.machine.focus.nodes : []
|
|
606
|
+
/** @type {any[]} */
|
|
607
|
+
const nodes = []
|
|
608
|
+
for (let n = 0; n < host.length; n++) {
|
|
609
|
+
nodes.push(host[n])
|
|
610
|
+
if (host[n].list) {
|
|
611
|
+
const rows = lists.nodes(host[n].list, host[n].rid)
|
|
612
|
+
for (let k = 0; k < rows.length; k++) nodes.push(rows[k])
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return { nodes, keys: (manifest.remoteKeys || []).concat(nested.bindings(), lists.bindings()) }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Rows came or went: the tree takes their nodes, and what their machines focused as they started. */
|
|
619
|
+
function refocus() {
|
|
620
|
+
listVersion = lists.version
|
|
621
|
+
const tree = focusTree()
|
|
622
|
+
focusing.retree(tree.nodes, tree.keys)
|
|
623
|
+
hasFocusNodes = focusing.hasNodes
|
|
624
|
+
lists.flushFocus()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* A press and release on a target, as the pointer makes them.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} target a `rid`, `<sid>/<rid>` in a nested artboard, `row.<id>/<rid>` in a list's row
|
|
631
|
+
*/
|
|
632
|
+
function tap(target) {
|
|
633
|
+
const types = [ 'PointerDown', 'PointerUp', 'Click' ]
|
|
634
|
+
// a row's targets are named `<row>|<rid>` by its list
|
|
635
|
+
const rowAt = target.indexOf('row.') === 0 ? target.indexOf('/') : -1
|
|
636
|
+
const row = rowAt < 0 ? target : target.slice(4, rowAt) + '|' + target.slice(rowAt + 1)
|
|
637
|
+
for (let n = 0; n < types.length; n++) {
|
|
638
|
+
if (nested.pointer(types[n], target) || lists.pointer(types[n], row)) continue
|
|
639
|
+
if (flow) flow.pointer(types[n], target)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* @param {string | KeyboardEvent} key
|
|
645
|
+
* @param {boolean} isUp
|
|
646
|
+
* @returns {boolean} whether the bundle used it
|
|
647
|
+
*/
|
|
648
|
+
function sendKey(key, isUp) {
|
|
649
|
+
if (!flow) return false
|
|
650
|
+
const name = typeof key === 'string' ? '' : keyFromEvent(key)
|
|
651
|
+
const parsed = typeof key === 'string' ? parseKey(key) : { names: keyNames(name), mods: modifiersFromEvent(key), key: name }
|
|
652
|
+
if (!parsed.key) return false
|
|
653
|
+
const repeat = typeof key !== 'string' && key.repeat
|
|
654
|
+
const used = focusing.press({ names: parsed.names, mods: parsed.mods, phase: isUp ? PHASES.up : repeat ? PHASES.repeat : PHASES.down })
|
|
655
|
+
if (!isUp && isClick(parsed.key)) {
|
|
656
|
+
if (!focusing.hasNodes) flow.click()
|
|
657
|
+
else if (focusing.current !== null) tap(focusing.current)
|
|
658
|
+
}
|
|
659
|
+
return used
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The shapes and groups pointer listeners target: every vector animation
|
|
664
|
+
* records their geometry as it draws, and the pointer answers on the canvas.
|
|
665
|
+
*
|
|
666
|
+
* @param {Record<string, string[]>} own the machine's `pointerTargets`
|
|
667
|
+
*/
|
|
668
|
+
function watchPointer(own) {
|
|
669
|
+
// the nested machines' shapes are named `<sid>/<rid>`, beside the bundle's own
|
|
670
|
+
/** @type {Record<string, string[]>} */
|
|
671
|
+
const targets = {}
|
|
672
|
+
for (const rid in own) targets[rid] = own[rid]
|
|
673
|
+
const inside = nested.pointerTargets()
|
|
674
|
+
for (const rid in inside) targets[rid] = inside[rid]
|
|
675
|
+
const rows = lists.pointerTargets()
|
|
676
|
+
for (const name in rows) targets[name] = rows[name]
|
|
677
|
+
const rids = []
|
|
678
|
+
for (const rid in targets) if (rid !== '' && rid.indexOf('|') < 0) rids.push(rid)
|
|
679
|
+
isPointerWatched = rids.length > 0
|
|
680
|
+
// the focus tree's nodes are watched too: the arrows move focus by where they were drawn
|
|
681
|
+
const nodes = focusing.nodes
|
|
682
|
+
// (a list row's are watched as the row draws)
|
|
683
|
+
for (let n = 0; n < nodes.length; n++) if (nodes[n].rid !== '' && nodes[n].rid.indexOf('row.') !== 0 && rids.indexOf(nodes[n].rid) < 0) rids.push(nodes[n].rid)
|
|
684
|
+
|
|
685
|
+
const drawn = []
|
|
686
|
+
for (const id in media) drawn.push(media[id].vector)
|
|
687
|
+
if (mixedMedia) drawn.push(mixedMedia.vector)
|
|
688
|
+
for (const width in mixedBands) drawn.push(mixedBands[width].vector)
|
|
689
|
+
for (let n = 0; n < drawn.length; n++) {
|
|
690
|
+
const vector = drawn[n]
|
|
691
|
+
if (vector) vector.hits.watch(rids)
|
|
692
|
+
}
|
|
693
|
+
pointer.listen(targets)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Live values into every vector animation's slots.
|
|
698
|
+
*
|
|
699
|
+
* @param {Record<string, any>} values
|
|
700
|
+
*/
|
|
701
|
+
function applySlots(values) {
|
|
702
|
+
if (!bundle) return
|
|
703
|
+
const table = slotValues(bundle.manifest.data, values, (message) => emit('error', message), scripts)
|
|
704
|
+
slotTable = table
|
|
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]
|
|
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)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** How the animation fits the canvas: what the caller asked for, else the bundle's, else contain and centred. */
|
|
828
|
+
function placement() {
|
|
829
|
+
const manifest = bundle ? bundle.manifest : {}
|
|
830
|
+
return {
|
|
831
|
+
fit: fitName(asked.fit !== undefined ? asked.fit : manifest.fit),
|
|
832
|
+
alignment: alignmentOf(asked.alignment !== undefined ? asked.alignment : manifest.alignment),
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The layout band for the canvas's size, when the artboard is responsive
|
|
838
|
+
* and fits as `layout`; otherwise the file's own size. Sets what a cell
|
|
839
|
+
* that blends draws: the weights toward the neighbouring bands, and the
|
|
840
|
+
* artboard size (the canvas's, as Rive's `layout` fit resizes the artboard
|
|
841
|
+
* to it).
|
|
842
|
+
*/
|
|
843
|
+
function chooseBand() {
|
|
844
|
+
live.t = 0
|
|
845
|
+
live.v = 0
|
|
846
|
+
blended = 0
|
|
847
|
+
blendedHeight = 0
|
|
848
|
+
cell = null
|
|
849
|
+
if (!bundle || !grid || placement().fit !== 'layout') return null
|
|
850
|
+
const ratio = window.devicePixelRatio || 1
|
|
851
|
+
const width = canvas.clientWidth > 0 ? canvas.clientWidth : canvas.width / ratio
|
|
852
|
+
const height = canvas.clientHeight > 0 ? canvas.clientHeight : canvas.height / ratio
|
|
853
|
+
cell = cellOf(grid, width, height)
|
|
854
|
+
live.t = cell.u
|
|
855
|
+
live.v = cell.v
|
|
856
|
+
blended = cell.width
|
|
857
|
+
blendedHeight = cell.height
|
|
858
|
+
return cell.corner
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* The animation that plays an animation's part in the layout band showing.
|
|
863
|
+
*
|
|
864
|
+
* @param {string} id
|
|
865
|
+
*/
|
|
866
|
+
function variantOf(id) {
|
|
867
|
+
const variant = band && band.animations ? band.animations[id] : null
|
|
868
|
+
return variant && media[variant] ? variant : id
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** The media a mixed bundle shows: the layout band's, else the file's own size. */
|
|
872
|
+
function mixedFor() {
|
|
873
|
+
const own = /** @type {Media} */ (mixedMedia)
|
|
874
|
+
return band && band.animations && mixedBands[bandKey(band)] ? mixedBands[bandKey(band)] : own
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Moves to the layout band the canvas now calls for, keeping the animation's time. */
|
|
878
|
+
function updateBand() {
|
|
879
|
+
const before = [ live.t, live.v, blended, blendedHeight ]
|
|
880
|
+
const next = chooseBand()
|
|
881
|
+
if (next === band) {
|
|
882
|
+
// inside a blending cell the picture moves with the size, and so does an artboard sized to the canvas
|
|
883
|
+
const isMoved = live.t !== before[0] || live.v !== before[1] || blended !== before[2] || blendedHeight !== before[3]
|
|
884
|
+
if (isMoved || live.t > 0 || live.v > 0) player.render()
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
band = next
|
|
888
|
+
if (isMixedShown) {
|
|
889
|
+
// the stage is shared, so layers, fades and blends carry on in the other band
|
|
890
|
+
player.replace(mixedFor())
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
if (flow && flow.animation && media[variantOf(flow.animation)]) player.replace(media[variantOf(flow.animation)])
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
load,
|
|
898
|
+
|
|
899
|
+
/** @param {Record<string, any>} inputs view-model values; an Event input set to true fires */
|
|
900
|
+
setInputs(inputs) {
|
|
901
|
+
nested.takeInputs(inputs)
|
|
902
|
+
if (flow) flow.takeInputs(flattenInputs(inputs))
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Values as `{ key, value }` strings, typed by the bundle's view model,
|
|
907
|
+
* as maestro-web-sdk's bindable properties arrive.
|
|
908
|
+
*
|
|
909
|
+
* @param {{ key: string, value: string }[]} pairs
|
|
910
|
+
*/
|
|
911
|
+
setProperties(pairs) {
|
|
912
|
+
if (!flow || !bundle) return
|
|
913
|
+
const inputs = fromPairs(bundle.manifest.data, pairs)
|
|
914
|
+
nested.takeInputs(inputs)
|
|
915
|
+
flow.takeInputs(inputs)
|
|
916
|
+
},
|
|
917
|
+
|
|
918
|
+
/** @param {string} name a machine state */
|
|
919
|
+
setState(name) {
|
|
920
|
+
if (flow) flow.jumpTo(name)
|
|
921
|
+
},
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* A key press for the bundle: a Rive key name (`enter`, `up`, `tab`), a
|
|
925
|
+
* Roku remote name (`OK`, `back`), either with modifiers in front
|
|
926
|
+
* (`shift+tab`), or a DOM keyboard event. It goes to the focused target's
|
|
927
|
+
* keyboard listeners and those above it; Tab and the arrows move focus.
|
|
928
|
+
* Enter and OK also click. This is how a host that routes the remote itself
|
|
929
|
+
* drives focus (`keys: 'manual'`); it works with `auto` too.
|
|
930
|
+
*
|
|
931
|
+
* @param {string | KeyboardEvent} key
|
|
932
|
+
* @returns {boolean} whether the bundle used it: false for a Tab off the end of the focus tree, say, so the host can move on
|
|
933
|
+
*/
|
|
934
|
+
key(key) {
|
|
935
|
+
return sendKey(key, false)
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* The release of a key, for keyboard listeners that answer it.
|
|
940
|
+
*
|
|
941
|
+
* @param {string | KeyboardEvent} key
|
|
942
|
+
*/
|
|
943
|
+
keyUp(key) {
|
|
944
|
+
sendKey(key, true)
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Moves Rive's focus: to a target by its `rid` or name (`motion.focused`
|
|
949
|
+
* reports them), or `'next'`, `'previous'`, `'up'`, `'down'`, `'left'` or
|
|
950
|
+
* `'right'`. It does not touch DOM focus.
|
|
951
|
+
*
|
|
952
|
+
* @param {string} target
|
|
953
|
+
* @returns {boolean} whether focus moved
|
|
954
|
+
*/
|
|
955
|
+
focus(target) {
|
|
956
|
+
if (target === 'next' || target === 'previous' || target === 'up' || target === 'down' || target === 'left' || target === 'right') return focusing.move(target)
|
|
957
|
+
return focusing.focus(target)
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
/** Takes Rive's focus off every target; the Blur listeners run. */
|
|
961
|
+
blur() {
|
|
962
|
+
focusing.clear()
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
/** The `rid` of the target that has Rive's focus, or null */
|
|
966
|
+
get focused() {
|
|
967
|
+
return focusing.current
|
|
968
|
+
},
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* A click from the host: without a target, every Click listener answers, as
|
|
972
|
+
* for OK; with one (its `rid` or name), a press and release on it.
|
|
973
|
+
*
|
|
974
|
+
* @param {string} [target]
|
|
975
|
+
*/
|
|
976
|
+
click(target) {
|
|
977
|
+
if (!flow) return
|
|
978
|
+
if (target === undefined) {
|
|
979
|
+
flow.click()
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
const rid = focusing.resolve(target)
|
|
983
|
+
tap(rid === null ? target : rid)
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
/** @param {'play' | 'loop' | 'pause' | 'stop'} command */
|
|
987
|
+
control(command) {
|
|
988
|
+
player.setControl(command)
|
|
989
|
+
if (command === 'pause') audio.pause()
|
|
990
|
+
else if (command === 'stop') audio.stop()
|
|
991
|
+
else audio.resume()
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
/** @param {boolean} flag silences what is playing, and plays nothing until unmuted */
|
|
995
|
+
setMuted(flag) {
|
|
996
|
+
audio.setMuted(!!flag)
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
/** With the `manual` option, moves time on by these seconds and draws. */
|
|
1000
|
+
advance(/** @type {number} */ seconds) {
|
|
1001
|
+
player.advance(seconds)
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Changes how the animation fits the canvas, over the bundle's.
|
|
1006
|
+
*
|
|
1007
|
+
* @param {{ fit?: string, alignment?: string | number[] }} where
|
|
1008
|
+
*/
|
|
1009
|
+
place(where) {
|
|
1010
|
+
asked = { fit: where.fit !== undefined ? where.fit : asked.fit, alignment: where.alignment !== undefined ? where.alignment : asked.alignment }
|
|
1011
|
+
updateBand()
|
|
1012
|
+
player.resize()
|
|
1013
|
+
},
|
|
1014
|
+
|
|
1015
|
+
/** Call when the canvas's displayed size changes. */
|
|
1016
|
+
resize() {
|
|
1017
|
+
player.resize()
|
|
1018
|
+
updateBand()
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* @param {MotionEvent} name
|
|
1023
|
+
* @param {Function} listener
|
|
1024
|
+
* @returns {() => void} removes the listener
|
|
1025
|
+
*/
|
|
1026
|
+
on(name, listener) {
|
|
1027
|
+
listeners[name] = (listeners[name] || []).concat([ listener ])
|
|
1028
|
+
return () => {
|
|
1029
|
+
listeners[name] = (listeners[name] || []).filter((one) => one !== listener)
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
destroy() {
|
|
1034
|
+
player.destroy()
|
|
1035
|
+
audio.destroy()
|
|
1036
|
+
canvas.removeEventListener('click', onClick)
|
|
1037
|
+
pointer.destroy()
|
|
1038
|
+
dom.destroy()
|
|
1039
|
+
focusing.setup([], [])
|
|
1040
|
+
flow = null
|
|
1041
|
+
bundle = null
|
|
1042
|
+
skins = noSkins
|
|
1043
|
+
makeSkins = null
|
|
1044
|
+
if (objects) objects.destroy()
|
|
1045
|
+
objects = null
|
|
1046
|
+
vectorList = null
|
|
1047
|
+
media = {}
|
|
1048
|
+
nested = createNested([])
|
|
1049
|
+
lists = createLists([], {}, { base: '', band: bandWidth, error: () => {}, focus: () => {} })
|
|
1050
|
+
listVersion = 0
|
|
1051
|
+
mixedMedia = null
|
|
1052
|
+
mixedBands = {}
|
|
1053
|
+
grid = null
|
|
1054
|
+
cell = null
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
get state() {
|
|
1058
|
+
return flow ? flow.state : ''
|
|
1059
|
+
},
|
|
1060
|
+
get values() {
|
|
1061
|
+
return flow ? publicValues(bundle ? bundle.manifest.data : null, flow.values) : {}
|
|
1062
|
+
},
|
|
1063
|
+
get playback() {
|
|
1064
|
+
return player.playback
|
|
1065
|
+
},
|
|
1066
|
+
get muted() {
|
|
1067
|
+
return audio.muted
|
|
1068
|
+
},
|
|
1069
|
+
get renderer() {
|
|
1070
|
+
return player.renderer
|
|
1071
|
+
},
|
|
1072
|
+
/** The width of the layout band showing, or 0 when the artboard is not responsive or does not fit as `layout` */
|
|
1073
|
+
get band() {
|
|
1074
|
+
return band ? band.width : 0
|
|
1075
|
+
},
|
|
1076
|
+
/**
|
|
1077
|
+
* The artboard's size when it is the canvas's (Rive's `layout` fit sizes
|
|
1078
|
+
* it to the canvas, within what the converter solved), else 0 for the
|
|
1079
|
+
* picture's own, and the weights toward the neighbouring layout bands.
|
|
1080
|
+
*/
|
|
1081
|
+
get artboard() {
|
|
1082
|
+
return { width: blended, height: blendedHeight, u: live.t, v: live.v }
|
|
1083
|
+
},
|
|
1084
|
+
/** How the animation fits the canvas now: `{ fit, alignment }` */
|
|
1085
|
+
get placement() {
|
|
1086
|
+
return placement()
|
|
1087
|
+
},
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Whether a bundle needs the mixer: its machine has layers, a blend state, a
|
|
1093
|
+
* cross-fading transition, one that waits for an exact exit time, a state that plays backwards or bounces or at another speed, or animations
|
|
1094
|
+
* that key different properties (`holds`), so one leaves what another set.
|
|
1095
|
+
*
|
|
1096
|
+
* @param {any} manifest
|
|
1097
|
+
* @returns {boolean}
|
|
1098
|
+
*/
|
|
1099
|
+
export function usesMixer(manifest) {
|
|
1100
|
+
if (!exists(manifest.machine)) return false
|
|
1101
|
+
if (manifest.machine.holds === true) return true
|
|
1102
|
+
const layers = layersOf(manifest.machine)
|
|
1103
|
+
if (layers.length > 1) return true
|
|
1104
|
+
|
|
1105
|
+
const states = layers[0].states
|
|
1106
|
+
for (let n = 0; n < states.length; n++) {
|
|
1107
|
+
if (states[n].blend || states[n].mode || (states[n].speed && states[n].speed !== 1)) return true
|
|
1108
|
+
const list = states[n].transitions || []
|
|
1109
|
+
for (let k = 0; k < list.length; k++) if (list[k].durationMs > 0 || list[k].exitTime) return true
|
|
1110
|
+
}
|
|
1111
|
+
return false
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* What a layout band's mixed media is kept under.
|
|
1116
|
+
*
|
|
1117
|
+
* @param {{ width: number, height: number }} corner
|
|
1118
|
+
* @returns {string}
|
|
1119
|
+
*/
|
|
1120
|
+
function bandKey(corner) {
|
|
1121
|
+
return corner.width + 'x' + corner.height
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The first animation a state names that the bundle lacks.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {any} state
|
|
1128
|
+
* @param {Record<string, Media>} items
|
|
1129
|
+
* @returns {string} "" when it has them all
|
|
1130
|
+
*/
|
|
1131
|
+
function unknownAnimation(state, items) {
|
|
1132
|
+
const ids = []
|
|
1133
|
+
if (state.blend) for (let n = 0; n < state.blend.animations.length; n++) ids.push(state.blend.animations[n].animation)
|
|
1134
|
+
else ids.push(state.animation)
|
|
1135
|
+
for (let n = 0; n < ids.length; n++) if (!items[ids[n]]) return ids[n]
|
|
1136
|
+
return ''
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Each slot that fills a text overlay → the input it reads, as Roku's
|
|
1141
|
+
* Motion.Task resolves them (bundle.bs resolveTexts).
|
|
1142
|
+
*
|
|
1143
|
+
* @param {any} manifest
|
|
1144
|
+
*/
|
|
1145
|
+
function inputsOfSlots(manifest) {
|
|
1146
|
+
/** @type {Record<string, string>} */
|
|
1147
|
+
const result = {}
|
|
1148
|
+
const slots = (manifest.data && manifest.data.slots) || []
|
|
1149
|
+
for (let n = 0; n < slots.length; n++) {
|
|
1150
|
+
const slot = slots[n]
|
|
1151
|
+
// the overlay reads the slot's converted value, so a converter shows in the text
|
|
1152
|
+
if (slot.sources && slot.sources.length) result[slot.sid] = slot.sid
|
|
1153
|
+
}
|
|
1154
|
+
return result
|
|
1155
|
+
}
|