@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.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/audio/audio.cues.js +27 -0
  3. package/audio/audio.js +259 -0
  4. package/bands/bands.js +392 -0
  5. package/bundle/bundle.fonts.js +55 -0
  6. package/bundle/bundle.js +131 -0
  7. package/focus/focus.direction.js +46 -0
  8. package/focus/focus.dom.js +158 -0
  9. package/focus/focus.js +277 -0
  10. package/focus/focus.tree.js +146 -0
  11. package/hit/hit.geometry.js +113 -0
  12. package/hit/hit.js +129 -0
  13. package/list/list.flex.js +181 -0
  14. package/list/list.items.js +80 -0
  15. package/list/list.js +518 -0
  16. package/lottie/lottie.js +124 -0
  17. package/lottie/lottie.layers.js +416 -0
  18. package/lottie/lottie.paint.js +238 -0
  19. package/lottie/lottie.paths.js +474 -0
  20. package/lottie/lottie.props.js +265 -0
  21. package/lottie/lottie.shapes.js +259 -0
  22. package/mixer/mixer.js +307 -0
  23. package/mixer/mixer.mix.js +107 -0
  24. package/mixer/mixer.stage.js +431 -0
  25. package/motion/motion.convert.js +404 -0
  26. package/motion/motion.flow.js +421 -0
  27. package/motion/motion.keys.js +150 -0
  28. package/motion/motion.machine.js +382 -0
  29. package/motion/motion.nested.js +345 -0
  30. package/motion/motion.values.js +368 -0
  31. package/motion.js +1155 -0
  32. package/package.json +47 -4
  33. package/player/overlay/overlay.js +325 -0
  34. package/player/overlay/overlay.layout.js +208 -0
  35. package/player/player.fit.js +101 -0
  36. package/player/player.js +421 -0
  37. package/player/player.raster.js +127 -0
  38. package/pointer/pointer.js +292 -0
  39. package/readme.md +345 -0
  40. package/script/luau/lib/base/base.js +202 -0
  41. package/script/luau/lib/base/base.table.js +189 -0
  42. package/script/luau/lib/lib.args.js +152 -0
  43. package/script/luau/lib/lib.js +23 -0
  44. package/script/luau/lib/math/math.bit32.js +144 -0
  45. package/script/luau/lib/math/math.js +288 -0
  46. package/script/luau/lib/math/math.vector.js +125 -0
  47. package/script/luau/lib/string/string.format.js +143 -0
  48. package/script/luau/lib/string/string.js +160 -0
  49. package/script/luau/lib/string/string.pattern.js +505 -0
  50. package/script/luau/load/load.js +362 -0
  51. package/script/luau/load/load.ops.js +79 -0
  52. package/script/luau/luau.convert.js +259 -0
  53. package/script/luau/luau.js +132 -0
  54. package/script/luau/luau.md +111 -0
  55. package/script/luau/vm/vm.js +167 -0
  56. package/script/luau/vm/vm.meta.js +401 -0
  57. package/script/luau/vm/vm.num.js +236 -0
  58. package/script/luau/vm/vm.run.js +842 -0
  59. package/script/luau/vm/vm.value.js +406 -0
  60. package/script/play/play.js +199 -0
  61. package/script/play/play.objects.js +295 -0
  62. package/script/play/play.scope.js +91 -0
  63. package/script/rive/api/api.statics.js +31 -0
  64. package/script/rive/api/api.type.js +188 -0
  65. package/script/rive/api/artboard/artboard.js +89 -0
  66. package/script/rive/api/assets/assets.js +72 -0
  67. package/script/rive/api/data/data.value.js +99 -0
  68. package/script/rive/api/draw/draw.contours.js +163 -0
  69. package/script/rive/api/draw/draw.measure.js +196 -0
  70. package/script/rive/api/draw/draw.paint.js +235 -0
  71. package/script/rive/api/draw/draw.path.js +153 -0
  72. package/script/rive/api/draw/draw.renderer.js +117 -0
  73. package/script/rive/api/math/math.color.js +96 -0
  74. package/script/rive/api/math/math.mat2d.js +180 -0
  75. package/script/rive/api/math/math.vector.js +121 -0
  76. package/script/rive/api/media/media.audio.js +113 -0
  77. package/script/rive/api/media/media.draw.js +73 -0
  78. package/script/rive/api/media/media.image.js +61 -0
  79. package/script/rive/api/media/media.mesh.js +50 -0
  80. package/script/rive/api/pointer/pointer.event.js +64 -0
  81. package/script/rive/api/pointer/pointer.route.js +69 -0
  82. package/script/rive/api/text/text.font.js +129 -0
  83. package/script/rive/api/text/text.js +278 -0
  84. package/script/rive/api/text/text.layout.js +259 -0
  85. package/script/rive/context/context.js +76 -0
  86. package/script/rive/context/context.memory.js +196 -0
  87. package/script/rive/context/context.viewmodel.js +302 -0
  88. package/script/rive/context/lists/lists.js +160 -0
  89. package/script/rive/context/lists/lists.values.js +281 -0
  90. package/script/rive/host/host.globals.js +44 -0
  91. package/script/rive/host/host.js +533 -0
  92. package/script/rive/host/host.node.js +45 -0
  93. package/script/rive/rive.js +23 -0
  94. package/script/rive/rive.md +180 -0
  95. package/script/script.js +11 -0
  96. package/skin/skin.deform.js +514 -0
  97. package/skin/skin.ik.js +260 -0
  98. package/skin/skin.js +297 -0
  99. package/README.md +0 -3
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The canvas and its clock: fits the animation into the canvas, steps time
3
+ * with requestAnimationFrame (a timer where there is none), and draws each
4
+ * frame as vector or from raster sheets, with its text overlays on top.
5
+ *
6
+ * Vector drawing costs more on a weak device than a sheet's drawImage. With
7
+ * `adaptive`, an animation whose frames take too long moves to its sheets,
8
+ * when it has them.
9
+ *
10
+ * A bundle with layers, blends or cross-fades is shown as a mixed media: the
11
+ * clock then belongs to its stage, which says what every layer plays, and the
12
+ * mixer draws the poses together (mixer/). Such media draw vector only.
13
+ */
14
+
15
+ import { dueCues } from '../audio/audio.cues.js'
16
+ import { fitMatrix as fitInto } from './player.fit.js'
17
+ import { drawOverlays, mixOverlays } from './overlay/overlay.js'
18
+
19
+ /** @import { Animation } from '../lottie/lottie.js' */
20
+ /** @import { Cue } from '../audio/audio.cues.js' */
21
+ /** @import { RasterAnimation } from './player.raster.js' */
22
+ /** @import { Matrix } from '../lottie/lottie.paths.js' */
23
+ /** @import { Mixer } from '../mixer/mixer.js' */
24
+ /** @import { createStage } from '../mixer/mixer.stage.js' */
25
+
26
+ /** Vector frames measured before deciding whether to keep drawing them */
27
+ const SAMPLE_FRAMES = 20
28
+ /** A vector frame slower than this, at the median, moves to the sheets */
29
+ const SLOW_FRAME_MS = 20
30
+
31
+ /**
32
+ * @typedef {object} Media
33
+ * @property {Animation | null} vector
34
+ * @property {RasterAnimation | null} raster
35
+ * @property {number} width the animation's own size
36
+ * @property {number} height
37
+ * @property {number} fps the Lottie's frame rate
38
+ * @property {number} frames its length in frames
39
+ * @property {any[]} texts its text overlays
40
+ * @property {Cue[]} cues its audio cues, in frame order
41
+ * @property {() => { sid: string, rgb: number[], alpha: number }[]} tints live tint colours, for its sheets
42
+ * @property {Mixed | null} [mixed] set when the stage and mixer play it
43
+ */
44
+
45
+ /** Times a finished mixed media applies its poses when its inputs move: Rive keeps applying a blend until its weights stop mattering */
46
+ const SETTLE_PASSES = 8
47
+
48
+ /**
49
+ * @typedef {object} Mixed
50
+ * @property {Mixer} mixer
51
+ * @property {ReturnType<typeof createStage>} stage
52
+ * @property {() => Record<string, any>} values the machine's inputs, which blends read
53
+ * @property {(id: string) => any[]} texts an animation's text overlays
54
+ * @property {(id: string) => number} fps
55
+ * @property {(id: string) => Cue[]} cues an animation's audio cues
56
+ * @property {(id: string) => Media | null} item an animation's own media, whose properties a write-back reads
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} Overlay
61
+ * @property {Record<string, any>} values
62
+ * @property {Record<string, string>} families
63
+ * @property {Record<string, string>} inputOf
64
+ */
65
+
66
+ /**
67
+ * @param {HTMLCanvasElement} canvas
68
+ * @param {object} options
69
+ * @param {boolean} options.adaptive
70
+ * @param {(playback: string) => void} options.onPlayback
71
+ * @param {(layer: number) => void} options.onFinished
72
+ * @param {() => Overlay} options.overlay
73
+ * @param {() => { fit: string, alignment: [number, number] }} [options.placement] how the animation fits the canvas, 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
+ * @param {() => void} [options.onTick] time has passed on a mixed media
77
+ * @param {() => boolean} [options.isPinned] the animation must stay vector: pointer listeners hit-test its drawn shapes
78
+ * @param {(cue: Cue) => void} options.onCue an audio cue the timeline passed
79
+ * @param {(media: Media, frame: number) => void} [options.onFrame] after a vector frame is drawn, with the animation's frame
80
+ * @param {(seconds: number) => void} [options.tick] moves what runs beside the animation (nested machines) on by a frame
81
+ * @param {(media: Media) => void} [options.prepare] called before each frame is drawn
82
+ * @param {() => boolean} [options.keepAlive] whether to keep stepping after the animation has finished
83
+ * @param {boolean} [options.manual] a test hook: no clock of its own; time moves only by `advance`
84
+ */
85
+ export function createPlayer(canvas, options) {
86
+ const ctx = canvas.getContext('2d')
87
+ if (!ctx) throw new Error('this browser has no 2D canvas')
88
+
89
+ /** @type {Media | null} */
90
+ let media = null
91
+ let useRaster = false
92
+ let loops = false
93
+ let control = 'play'
94
+ let playback = 'none'
95
+ let elapsed = 0
96
+ /** Frame up to which audio cues have sounded; -1 before the first, which is at frame 0 */
97
+ let heard = -1
98
+ let last = 0
99
+ let frameHandle = 0
100
+ /** @type {number[]} */
101
+ let timings = []
102
+ let lastDrawMs = 0
103
+
104
+ const raf = window.requestAnimationFrame ? (/** @type {FrameRequestCallback} */ f) => window.requestAnimationFrame(f) : (/** @type {FrameRequestCallback} */ f) => window.setTimeout(() => f(Date.now()), 16)
105
+ const caf = window.cancelAnimationFrame ? (/** @type {number} */ h) => window.cancelAnimationFrame(h) : (/** @type {number} */ h) => window.clearTimeout(h)
106
+ const now = () => (window.performance && window.performance.now ? window.performance.now() : Date.now())
107
+
108
+ /** @param {string} next */
109
+ function setPlayback(next) {
110
+ if (playback === next) return
111
+ playback = next
112
+ options.onPlayback(next)
113
+ }
114
+
115
+ /**
116
+ * @param {Media} next
117
+ * @param {boolean} nextLoops
118
+ * @param {boolean} preferRaster
119
+ */
120
+ function show(next, nextLoops, preferRaster) {
121
+ media = next
122
+ loops = nextLoops
123
+ useRaster = !next.mixed && ((preferRaster && !!next.raster) || !next.vector)
124
+ elapsed = 0
125
+ heard = -1
126
+ timings = []
127
+ if (control === 'stop') {
128
+ render()
129
+ setPlayback('stopped')
130
+ return
131
+ }
132
+ setPlayback(control === 'pause' ? 'paused' : 'playing')
133
+ render()
134
+ schedule()
135
+ }
136
+
137
+ /**
138
+ * Swaps in another version of what is showing, such as another layout
139
+ * band, at the same moment.
140
+ *
141
+ * @param {Media} next
142
+ */
143
+ function replace(next) {
144
+ if (!media) return
145
+ media = next
146
+ useRaster = (useRaster && !!next.raster) || !next.vector
147
+ render()
148
+ }
149
+
150
+ /** @param {string} next play, loop, pause or stop */
151
+ function setControl(next) {
152
+ control = next
153
+ if (next === 'stop') {
154
+ elapsed = 0
155
+ if (media && media.mixed) media.mixed.stage.rewind()
156
+ render()
157
+ setPlayback('stopped')
158
+ return
159
+ }
160
+ if (next === 'pause') {
161
+ setPlayback('paused')
162
+ return
163
+ }
164
+ if (next === 'loop') {
165
+ loops = true
166
+ if (media && media.mixed) media.mixed.stage.setLoops(true)
167
+ }
168
+ // play after the end starts over, as Roku's re-sent play does
169
+ if (playback === 'finished' || playback === 'stopped') {
170
+ elapsed = 0
171
+ if (media && media.mixed) media.mixed.stage.rewind()
172
+ heard = -1
173
+ }
174
+ setPlayback('playing')
175
+ schedule()
176
+ }
177
+
178
+ function schedule() {
179
+ if (frameHandle || !media) return
180
+ if (options.manual) return
181
+ last = now()
182
+ frameHandle = raf(tick)
183
+ }
184
+
185
+ /**
186
+ * A mixed media's frame: the stage moves on, the machine and what runs
187
+ * beside it (nested machines) hear of it, and a layer that ran out is reported.
188
+ *
189
+ * @param {number} seconds
190
+ */
191
+ function stepMixed(seconds) {
192
+ const mixed = /** @type {Mixed} */ (/** @type {Media} */ (media).mixed)
193
+ const done = mixed.stage.advance(seconds)
194
+ const swept = mixed.stage.sweeps(mixed.values())
195
+ for (let n = 0; n < swept.length; n++) {
196
+ const cues = mixed.cues(swept[n].animation)
197
+ if (!cues.length) continue
198
+ const fps = mixed.fps(swept[n].animation)
199
+ const due = dueCues(cues, swept[n].from * fps, swept[n].to * fps)
200
+ for (let k = 0; k < due.length; k++) options.onCue(due[k])
201
+ }
202
+
203
+ if (options.tick) options.tick(seconds)
204
+ if (options.onTick) options.onTick()
205
+ render()
206
+ for (let n = 0; n < done.length; n++) options.onFinished(done[n])
207
+
208
+ if (!media) return
209
+ const isBeside = !!options.keepAlive && options.keepAlive()
210
+ if (playback === 'playing' && mixed.stage.isIdle()) setPlayback('finished')
211
+ // a finished stage still has nested machines to move
212
+ const isRunning = playback === 'playing' || (playback === 'finished' && isBeside)
213
+ if (isRunning && !frameHandle && !options.manual) frameHandle = raf(tick)
214
+ }
215
+
216
+ /** Something entered a state while the clock was stopped at the end: run again. */
217
+ function wake() {
218
+ if (!media || !media.mixed || control === 'stop' || control === 'pause') return
219
+ setPlayback('playing')
220
+ schedule()
221
+ }
222
+
223
+ function tick() {
224
+ frameHandle = 0
225
+ const time = now()
226
+ const seconds = (time - last) / 1000
227
+ last = time
228
+ step(seconds)
229
+ }
230
+
231
+ /**
232
+ * Moves time on and draws.
233
+ *
234
+ * @param {number} seconds
235
+ */
236
+ function step(seconds) {
237
+ const isBesideFinished = playback === 'finished' && !!options.keepAlive && options.keepAlive()
238
+ if (!media || (playback !== 'playing' && !isBesideFinished)) return
239
+ if (media.mixed) {
240
+ stepMixed(seconds)
241
+ return
242
+ }
243
+
244
+ if (options.tick) options.tick(seconds)
245
+ if (isBesideFinished) {
246
+ // what runs beside a finished animation keeps going
247
+ render()
248
+ if (!options.manual) frameHandle = raf(tick)
249
+ return
250
+ }
251
+
252
+ elapsed += seconds
253
+ const duration = media.frames / media.fps
254
+ const isPastEnd = elapsed >= duration
255
+ if (isPastEnd && !loops) {
256
+ sound(media.frames)
257
+ elapsed = Math.max(0, duration - 1 / media.fps)
258
+ render()
259
+ setPlayback('finished')
260
+ options.onFinished(0)
261
+ if (!options.manual && options.keepAlive && options.keepAlive()) frameHandle = raf(tick)
262
+ return
263
+ }
264
+ if (isPastEnd) {
265
+ sound(media.frames)
266
+ elapsed %= duration
267
+ heard = -1
268
+ }
269
+ sound(elapsed * media.fps)
270
+
271
+ render()
272
+ if (!options.manual) frameHandle = raf(tick)
273
+ }
274
+
275
+ /**
276
+ * Sounds the audio cues between where the last tick left off and a frame.
277
+ *
278
+ * @param {number} frame
279
+ */
280
+ function sound(frame) {
281
+ if (!media) return
282
+ const due = dueCues(media.cues, heard, frame)
283
+ heard = frame
284
+ for (let n = 0; n < due.length; n++) options.onCue(due[n])
285
+ }
286
+
287
+ /** The animation's space onto the canvas's, as the bundle's fit and alignment say. */
288
+ function fitMatrix() {
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 shown = /** @type {Media} */ (media)
291
+ const blended = !useRaster && options.artboardWidth ? options.artboardWidth(shown) : 0
292
+ const blendedHeight = !useRaster && options.artboardHeight ? options.artboardHeight(shown) : 0
293
+ const width = blended || (/** @type {Media} */ (media)).width || canvas.width
294
+ const height = blendedHeight || (/** @type {Media} */ (media)).height || canvas.height
295
+ const placement = options.placement ? options.placement() : { fit: 'contain', alignment: /** @type {[number, number]} */ ([ 0, 0 ]) }
296
+ const ratio = canvas.clientWidth > 0 ? canvas.width / canvas.clientWidth : 1
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)
300
+ }
301
+
302
+ function render() {
303
+ if (!media || !ctx) return
304
+ const matrix = fitMatrix()
305
+ if (options.prepare) options.prepare(media)
306
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
307
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
308
+
309
+ if (media.mixed) {
310
+ const mixed = media.mixed
311
+ const started = now()
312
+ const poses = mixed.stage.poses(mixed.values())
313
+ mixed.mixer.draw(ctx, poses, matrix, mixed.stage.frame, playback === 'finished' ? SETTLE_PASSES : 1)
314
+ lastDrawMs = now() - started
315
+
316
+ const lead = mixed.stage.lead()
317
+ const overlay = options.overlay()
318
+ if (poses.length > 1 && mixed.texts(poses[0].animation).length) {
319
+ // several animations at once: their texts mix as their shapes do
320
+ const shown = poses.map((pose) => ({ texts: mixed.texts(pose.animation), frame: pose.time * mixed.fps(pose.animation), weight: pose.weight }))
321
+ drawOverlays(ctx, mixOverlays(shown), 0, overlay.values, overlay.families, matrix, overlay.inputOf)
322
+ } else if (lead) {
323
+ drawOverlays(ctx, mixed.texts(lead.animation), lead.time * mixed.fps(lead.animation), overlay.values, overlay.families, matrix, overlay.inputOf)
324
+ }
325
+ // a write-back reads the lead animation's own property, not the mix
326
+ const leadItem = lead ? mixed.item(lead.animation) : null
327
+ if (options.onFrame && lead && leadItem && leadItem.vector) options.onFrame(leadItem, leadItem.vector.inPoint + lead.time * mixed.fps(lead.animation))
328
+ return
329
+ }
330
+
331
+ if (useRaster && media.raster) {
332
+ media.raster.draw(ctx, elapsed * media.raster.fps, matrix, media.tints())
333
+ } else if (media.vector) {
334
+ const started = now()
335
+ media.vector.draw(ctx, media.vector.inPoint + elapsed * media.fps, matrix)
336
+ measure(now() - started)
337
+ if (options.onFrame) options.onFrame(media, media.vector.inPoint + elapsed * media.fps)
338
+ }
339
+
340
+ const overlay = options.overlay()
341
+ drawOverlays(ctx, media.texts, elapsed * media.fps, overlay.values, overlay.families, matrix, overlay.inputOf)
342
+ }
343
+
344
+ /**
345
+ * Moves a slow vector animation to its sheets, once enough frames are timed.
346
+ *
347
+ * @param {number} ms
348
+ */
349
+ function measure(ms) {
350
+ if (!options.adaptive || !media || !media.raster || timings.length >= SAMPLE_FRAMES) return
351
+ if (options.isPinned && options.isPinned()) return
352
+ timings.push(ms)
353
+ if (timings.length < SAMPLE_FRAMES) return
354
+ const sorted = timings.slice().sort((a, b) => a - b)
355
+ if (sorted[Math.floor(sorted.length / 2)] > SLOW_FRAME_MS) useRaster = true
356
+ }
357
+
358
+ /** Matches the canvas's pixels to its displayed size. */
359
+ function resize() {
360
+ const ratio = window.devicePixelRatio || 1
361
+ const width = Math.round(canvas.clientWidth * ratio)
362
+ const height = Math.round(canvas.clientHeight * ratio)
363
+ const hasSize = width > 0 && height > 0
364
+ if (hasSize && (canvas.width !== width || canvas.height !== height)) {
365
+ canvas.width = width
366
+ canvas.height = height
367
+ }
368
+ render()
369
+ }
370
+
371
+ return {
372
+ show,
373
+ replace,
374
+ wake,
375
+ setControl,
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
+ },
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
+ },
395
+ /** With `manual`, moves time on by these seconds and draws. */
396
+ advance(/** @type {number} */ seconds) {
397
+ if (options.manual) step(seconds)
398
+ },
399
+ destroy() {
400
+ if (frameHandle) caf(frameHandle)
401
+ frameHandle = 0
402
+ media = null
403
+ },
404
+ get playback() {
405
+ return playback
406
+ },
407
+ /** Where the last vector frame drew the shapes pointer listeners target; null when it drew none */
408
+ get hits() {
409
+ if (!media || useRaster) return null
410
+ const drawn = media.mixed ? media.mixed.mixer.animation : media.vector
411
+ return drawn ? drawn.hits : null
412
+ },
413
+ get renderer() {
414
+ return useRaster ? 'raster' : 'vector'
415
+ },
416
+ /** How long the last mixed frame took to draw, in ms */
417
+ get drawMs() {
418
+ return lastDrawMs
419
+ },
420
+ }
421
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * An animation's raster sheets, as lottie-roku renders them: frames packed
3
+ * row by row into one or more sheets, plus a white coverage mask per colour
4
+ * slot, laid out the same, which is tinted with the slot's live colour. A
5
+ * `source-in` fill over the mask is what Roku's blendColor does to it.
6
+ */
7
+
8
+ import { fileUrl, loadImage } from '../bundle/bundle.js'
9
+
10
+ /** @import { Bundle } from '../bundle/bundle.js' */
11
+ /** @import { Matrix } from '../lottie/lottie.paths.js' */
12
+
13
+ /**
14
+ * @typedef {object} RasterAnimation
15
+ * @property {number} width the animation's own size, which frames are drawn at
16
+ * @property {number} height
17
+ * @property {number} fps the sheets' frame rate
18
+ * @property {number} frames how many frames the sheets hold
19
+ * @property {Promise<void>} ready
20
+ * @property {(ctx: CanvasRenderingContext2D, frame: number, matrix: Matrix, tints: { sid: string, rgb: number[], alpha: number }[]) => void} draw
21
+ */
22
+
23
+ /**
24
+ * @param {any} animation one of motion.json's animations
25
+ * @param {Bundle} bundle
26
+ * @returns {RasterAnimation}
27
+ */
28
+ export function createRaster(animation, bundle) {
29
+ const raster = animation.raster
30
+ const sheets = /** @type {{ uri: string, columns: number, frameCount: number }[]} */ (raster.sheets || [])
31
+ /** @type {HTMLImageElement[]} */
32
+ const images = []
33
+ /** @type {Record<string, HTMLImageElement[]>} */
34
+ const masks = {}
35
+
36
+ const pending = sheets.map((sheet, n) => loadImage(fileUrl(bundle, sheet.uri)).then((image) => {
37
+ images[n] = image
38
+ }))
39
+ for (let t = 0; t < (raster.tints || []).length; t++) {
40
+ const tint = raster.tints[t]
41
+ masks[tint.sid] = []
42
+ for (let n = 0; n < tint.sheets.length; n++) {
43
+ pending.push(loadImage(fileUrl(bundle, tint.sheets[n])).then((image) => {
44
+ masks[tint.sid][n] = image
45
+ }))
46
+ }
47
+ }
48
+
49
+ let scratch = /** @type {CanvasRenderingContext2D | null} */ (null)
50
+ const frames = sheets.reduce((sum, sheet) => sum + sheet.frameCount, 0)
51
+
52
+ return {
53
+ width: animation.width,
54
+ height: animation.height,
55
+ fps: raster.fps,
56
+ frames,
57
+ ready: Promise.all(pending).then(() => undefined),
58
+ draw(ctx, frame, matrix, tints) {
59
+ const cell = locate(sheets, Math.max(0, Math.min(frames - 1, Math.floor(frame))))
60
+ if (!cell || !images[cell.sheet]) return
61
+
62
+ ctx.save()
63
+ ctx.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5])
64
+ const source = [ cell.column * raster.frameWidth, cell.row * raster.frameHeight, raster.frameWidth, raster.frameHeight ]
65
+ ctx.drawImage(images[cell.sheet], source[0], source[1], source[2], source[3], 0, 0, animation.width, animation.height)
66
+
67
+ for (let n = 0; n < tints.length; n++) {
68
+ const mask = masks[tints[n].sid] && masks[tints[n].sid][cell.sheet]
69
+ if (!mask) continue
70
+ scratch = scratch || newScratch()
71
+ if (!scratch) break
72
+ drawTinted(ctx, scratch, mask, source, tints[n], animation)
73
+ }
74
+ ctx.restore()
75
+ },
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Which sheet holds a frame, and where in it.
81
+ *
82
+ * @param {any[]} sheets
83
+ * @param {number} frame
84
+ */
85
+ function locate(sheets, frame) {
86
+ let before = 0
87
+ for (let n = 0; n < sheets.length; n++) {
88
+ const sheet = sheets[n]
89
+ if (frame < before + sheet.frameCount) {
90
+ const index = frame - before
91
+ return { sheet: n, column: index % sheet.columns, row: Math.floor(index / sheet.columns) }
92
+ }
93
+ before += sheet.frameCount
94
+ }
95
+ return null
96
+ }
97
+
98
+ /**
99
+ * A mask's frame filled with the tint's colour, then drawn over the frame.
100
+ *
101
+ * @param {CanvasRenderingContext2D} ctx
102
+ * @param {CanvasRenderingContext2D} scratch
103
+ * @param {HTMLImageElement} mask
104
+ * @param {number[]} source the frame's rectangle in the sheet
105
+ * @param {{ rgb: number[], alpha: number }} tint
106
+ * @param {{ width: number, height: number }} size
107
+ */
108
+ function drawTinted(ctx, scratch, mask, source, tint, size) {
109
+ const canvas = scratch.canvas
110
+ if (canvas.width !== source[2] || canvas.height !== source[3]) {
111
+ canvas.width = source[2]
112
+ canvas.height = source[3]
113
+ }
114
+ scratch.globalCompositeOperation = 'source-over'
115
+ scratch.clearRect(0, 0, canvas.width, canvas.height)
116
+ scratch.drawImage(mask, source[0], source[1], source[2], source[3], 0, 0, source[2], source[3])
117
+ scratch.globalCompositeOperation = 'source-in'
118
+ const channel = (/** @type {number} */ value) => Math.round(Math.max(0, Math.min(1, value)) * 255)
119
+ scratch.fillStyle = 'rgba(' + channel(tint.rgb[0]) + ',' + channel(tint.rgb[1]) + ',' + channel(tint.rgb[2]) + ',' + Math.max(0, Math.min(1, tint.alpha)) + ')'
120
+ scratch.fillRect(0, 0, canvas.width, canvas.height)
121
+ ctx.drawImage(canvas, 0, 0, size.width, size.height)
122
+ }
123
+
124
+ /** @returns {CanvasRenderingContext2D | null} */
125
+ function newScratch() {
126
+ return document.createElement('canvas').getContext('2d')
127
+ }