@px-petals/roku.motion-web 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/audio/audio.cues.js +27 -0
- package/audio/audio.js +259 -0
- package/bands/bands.js +390 -0
- package/bundle/bundle.fonts.js +55 -0
- package/bundle/bundle.js +75 -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 +507 -0
- package/lottie/lottie.js +124 -0
- package/lottie/lottie.layers.js +363 -0
- package/lottie/lottie.paint.js +208 -0
- package/lottie/lottie.paths.js +459 -0
- package/lottie/lottie.props.js +255 -0
- package/lottie/lottie.shapes.js +258 -0
- package/mixer/mixer.js +284 -0
- package/mixer/mixer.mix.js +90 -0
- package/mixer/mixer.stage.js +431 -0
- package/motion/motion.convert.js +373 -0
- package/motion/motion.flow.js +403 -0
- package/motion/motion.keys.js +150 -0
- package/motion/motion.machine.js +367 -0
- package/motion/motion.nested.js +345 -0
- package/motion/motion.values.js +364 -0
- package/motion.js +979 -0
- package/package.json +53 -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 +401 -0
- package/player/player.raster.js +127 -0
- package/pointer/pointer.js +292 -0
- package/readme.md +270 -0
- package/README.md +0 -3
package/player/player.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
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 {() => 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
|
|
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 blended = !useRaster && options.artboardWidth ? options.artboardWidth() : 0
|
|
291
|
+
const blendedHeight = !useRaster && options.artboardHeight ? options.artboardHeight() : 0
|
|
292
|
+
const width = blended || (/** @type {Media} */ (media)).width || canvas.width
|
|
293
|
+
const height = blendedHeight || (/** @type {Media} */ (media)).height || canvas.height
|
|
294
|
+
const placement = options.placement ? options.placement() : { fit: 'contain', alignment: /** @type {[number, number]} */ ([ 0, 0 ]) }
|
|
295
|
+
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
|
+
}
|
|
298
|
+
|
|
299
|
+
function render() {
|
|
300
|
+
if (!media || !ctx) return
|
|
301
|
+
const matrix = fitMatrix()
|
|
302
|
+
if (options.prepare) options.prepare(media)
|
|
303
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
304
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
305
|
+
|
|
306
|
+
if (media.mixed) {
|
|
307
|
+
const mixed = media.mixed
|
|
308
|
+
const started = now()
|
|
309
|
+
const poses = mixed.stage.poses(mixed.values())
|
|
310
|
+
mixed.mixer.draw(ctx, poses, matrix, mixed.stage.frame, playback === 'finished' ? SETTLE_PASSES : 1)
|
|
311
|
+
lastDrawMs = now() - started
|
|
312
|
+
|
|
313
|
+
const lead = mixed.stage.lead()
|
|
314
|
+
const overlay = options.overlay()
|
|
315
|
+
if (poses.length > 1 && mixed.texts(poses[0].animation).length) {
|
|
316
|
+
// several animations at once: their texts mix as their shapes do
|
|
317
|
+
const shown = poses.map((pose) => ({ texts: mixed.texts(pose.animation), frame: pose.time * mixed.fps(pose.animation), weight: pose.weight }))
|
|
318
|
+
drawOverlays(ctx, mixOverlays(shown), 0, overlay.values, overlay.families, matrix, overlay.inputOf)
|
|
319
|
+
} else if (lead) {
|
|
320
|
+
drawOverlays(ctx, mixed.texts(lead.animation), lead.time * mixed.fps(lead.animation), overlay.values, overlay.families, matrix, overlay.inputOf)
|
|
321
|
+
}
|
|
322
|
+
// a write-back reads the lead animation's own property, not the mix
|
|
323
|
+
const leadItem = lead ? mixed.item(lead.animation) : null
|
|
324
|
+
if (options.onFrame && lead && leadItem && leadItem.vector) options.onFrame(leadItem, leadItem.vector.inPoint + lead.time * mixed.fps(lead.animation))
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (useRaster && media.raster) {
|
|
329
|
+
media.raster.draw(ctx, elapsed * media.raster.fps, matrix, media.tints())
|
|
330
|
+
} else if (media.vector) {
|
|
331
|
+
const started = now()
|
|
332
|
+
media.vector.draw(ctx, media.vector.inPoint + elapsed * media.fps, matrix)
|
|
333
|
+
measure(now() - started)
|
|
334
|
+
if (options.onFrame) options.onFrame(media, media.vector.inPoint + elapsed * media.fps)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const overlay = options.overlay()
|
|
338
|
+
drawOverlays(ctx, media.texts, elapsed * media.fps, overlay.values, overlay.families, matrix, overlay.inputOf)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Moves a slow vector animation to its sheets, once enough frames are timed.
|
|
343
|
+
*
|
|
344
|
+
* @param {number} ms
|
|
345
|
+
*/
|
|
346
|
+
function measure(ms) {
|
|
347
|
+
if (!options.adaptive || !media || !media.raster || timings.length >= SAMPLE_FRAMES) return
|
|
348
|
+
if (options.isPinned && options.isPinned()) return
|
|
349
|
+
timings.push(ms)
|
|
350
|
+
if (timings.length < SAMPLE_FRAMES) return
|
|
351
|
+
const sorted = timings.slice().sort((a, b) => a - b)
|
|
352
|
+
if (sorted[Math.floor(sorted.length / 2)] > SLOW_FRAME_MS) useRaster = true
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Matches the canvas's pixels to its displayed size. */
|
|
356
|
+
function resize() {
|
|
357
|
+
const ratio = window.devicePixelRatio || 1
|
|
358
|
+
const width = Math.round(canvas.clientWidth * ratio)
|
|
359
|
+
const height = Math.round(canvas.clientHeight * ratio)
|
|
360
|
+
const hasSize = width > 0 && height > 0
|
|
361
|
+
if (hasSize && (canvas.width !== width || canvas.height !== height)) {
|
|
362
|
+
canvas.width = width
|
|
363
|
+
canvas.height = height
|
|
364
|
+
}
|
|
365
|
+
render()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
show,
|
|
370
|
+
replace,
|
|
371
|
+
wake,
|
|
372
|
+
setControl,
|
|
373
|
+
render,
|
|
374
|
+
resize,
|
|
375
|
+
/** With `manual`, moves time on by these seconds and draws. */
|
|
376
|
+
advance(/** @type {number} */ seconds) {
|
|
377
|
+
if (options.manual) step(seconds)
|
|
378
|
+
},
|
|
379
|
+
destroy() {
|
|
380
|
+
if (frameHandle) caf(frameHandle)
|
|
381
|
+
frameHandle = 0
|
|
382
|
+
media = null
|
|
383
|
+
},
|
|
384
|
+
get playback() {
|
|
385
|
+
return playback
|
|
386
|
+
},
|
|
387
|
+
/** Where the last vector frame drew the shapes pointer listeners target; null when it drew none */
|
|
388
|
+
get hits() {
|
|
389
|
+
if (!media || useRaster) return null
|
|
390
|
+
const drawn = media.mixed ? media.mixed.mixer.animation : media.vector
|
|
391
|
+
return drawn ? drawn.hits : null
|
|
392
|
+
},
|
|
393
|
+
get renderer() {
|
|
394
|
+
return useRaster ? 'raster' : 'vector'
|
|
395
|
+
},
|
|
396
|
+
/** How long the last mixed frame took to draw, in ms */
|
|
397
|
+
get drawMs() {
|
|
398
|
+
return lastDrawMs
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -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
|
+
}
|