@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.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Lottie layers onto a 2D canvas: parenting, precomps with time remapping,
3
+ * solids, images, shapes, masks, track mattes and blend modes. Text layers
4
+ * are left out: lottie-roku turns them into overlays, which Motion draws.
5
+ *
6
+ * A layer's properties run on its own clock, the composition frame less its
7
+ * start time (`st`); its in and out points (`ip`, `op`) are composition
8
+ * frames, as lottie-web reads them.
9
+ */
10
+
11
+ import { drawDocument } from '../player/overlay/overlay.js'
12
+ import { drawOperations } from './lottie.paint.js'
13
+ import { fromLottie, IDENTITY, multiply, trace, transformPath } from './lottie.paths.js'
14
+ import { evaluator } from './lottie.props.js'
15
+ import { compileItems, compileTransform, runGroup, scalar } from './lottie.shapes.js'
16
+
17
+ /** @import { Matrix } from './lottie.paths.js' */
18
+ /** @import { Slots } from './lottie.props.js' */
19
+
20
+ /** Lottie blend modes, by `bm`, as canvas composite operations */
21
+ const BLENDS = [ 'source-over', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity' ]
22
+
23
+ /**
24
+ * @typedef {object} Context
25
+ * @property {Record<string, any>} assets by id: precomp layer lists and image entries
26
+ * @property {Record<string, HTMLImageElement>} images by asset id
27
+ * @property {number} fps
28
+ * @property {Slots} slots
29
+ * @property {import('../hit/hit.js').Hits} [hits] records the geometry of the groups pointer listeners target
30
+ * @property {(() => Record<string, string>) | null} [families] bundle font name → registered family, for text a precomp holds
31
+ * @property {string} [scope] while a nested machine's layer draws, `<its nid>/`, which names the groups it records
32
+ */
33
+
34
+ /**
35
+ * @param {any[]} layers
36
+ * @param {Context} context
37
+ * @returns {any[]} compiled layers, in the file's order (top first)
38
+ */
39
+ export function compileLayers(layers, context) {
40
+ const compiled = []
41
+ for (let n = 0; n < (layers || []).length; n++) {
42
+ const layer = layers[n]
43
+ compiled.push(compileLayer(layer, context))
44
+ }
45
+ return compiled
46
+ }
47
+
48
+ /**
49
+ * @param {any} layer
50
+ * @param {Context} context
51
+ */
52
+ function compileLayer(layer, context) {
53
+ const slots = context.slots
54
+ const asset = layer.refId ? context.assets[layer.refId] : null
55
+ return {
56
+ ty: layer.ty,
57
+ ind: layer.ind,
58
+ parent: layer.parent,
59
+ hidden: !!layer.hd,
60
+ ip: layer.ip || 0,
61
+ op: layer.op === undefined ? Infinity : layer.op,
62
+ st: layer.st || 0,
63
+ sr: layer.sr || 1,
64
+ isMatte: layer.td === 1,
65
+ matte: layer.tt || 0,
66
+ blend: BLENDS[layer.bm || 0] || 'source-over',
67
+ transform: compileTransform(layer.ks, slots),
68
+ shapes: layer.ty === 4 ? compileItems(layer.shapes, slots) : null,
69
+ precomp: layer.ty === 0 && asset && asset.layers ? compileLayers(asset.layers, context) : null,
70
+ // a nested artboard's machine moves its precomp's time through the slot its `nid` names
71
+ // a text layer inside a precomp: a nested artboard's text a live value fills
72
+ text: layer.ty === 5 && layer.t && layer.t.d && layer.t.d.k && layer.t.d.k[0] ? { sid: typeof layer.t.d.sid === 'string' ? layer.t.d.sid : null, doc: layer.t.d.k[0].s } : null,
73
+ nid: typeof layer.nid === 'string' ? layer.nid : null,
74
+ timeRemap: layer.tm ? evaluator(typeof layer.nid === 'string' ? mergeSid(layer.tm, layer.nid) : layer.tm, slots, 0) : null,
75
+ // an artboard laid out to its place: its size is a property, which a layout band mixes
76
+ // a nested artboard that does not clip draws past its own edge
77
+ unclipped: layer.nc === 1,
78
+ size: layer.wh ? evaluator(layer.wh, slots, [ 0, 0 ]) : null,
79
+ width: layer.w || (asset && asset.w) || 0,
80
+ height: layer.h || (asset && asset.h) || 0,
81
+ image: layer.ty === 2 && layer.refId ? layer.refId : null,
82
+ // a data-bound image: the slot table says which asset to draw, anchored at the same fraction of it
83
+ imageSlot: layer.ty === 2 && typeof layer.isid === 'string' ? layer.isid : null,
84
+ origin: Array.isArray(layer.origin) ? layer.origin : null,
85
+ anchor: layer.ks && layer.ks.a && layer.ks.a.a !== 1 && Array.isArray(layer.ks.a.k) ? layer.ks.a.k : null,
86
+ solid: layer.ty === 1 ? { color: layer.sc || '#000000', width: layer.sw || 0, height: layer.sh || 0 } : null,
87
+ masks: compileMasks(layer, slots),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * A copy of a property that reads a slot first.
93
+ *
94
+ * @param {any} property
95
+ * @param {string} sid
96
+ * @returns {any}
97
+ */
98
+ function mergeSid(property, sid) {
99
+ /** @type {Record<string, any>} */
100
+ const copy = {}
101
+ for (const key in property) copy[key] = property[key]
102
+ copy.sid = sid
103
+ return copy
104
+ }
105
+
106
+ /**
107
+ * @param {any} layer
108
+ * @param {Slots} slots
109
+ */
110
+ function compileMasks(layer, slots) {
111
+ if (!layer.hasMask || !Array.isArray(layer.masksProperties)) return []
112
+
113
+ const masks = []
114
+ for (let n = 0; n < layer.masksProperties.length; n++) {
115
+ const mask = layer.masksProperties[n]
116
+ if (mask.mode === 'n') continue
117
+ masks.push({ mode: mask.mode || 'a', inverted: !!mask.inv, path: evaluator(mask.pt, slots, { v: [], i: [], o: [], c: true }) })
118
+ }
119
+ return masks
120
+ }
121
+
122
+ // Drawing
123
+ // ■■■■■■■
124
+
125
+ /**
126
+ * Draws a list of layers at a composition frame.
127
+ *
128
+ * @param {CanvasRenderingContext2D} ctx
129
+ * @param {any[]} layers compiled, top first
130
+ * @param {Context} context
131
+ * @param {number} frame
132
+ * @param {Matrix} matrix the composition's space onto the canvas
133
+ * @param {number} opacity
134
+ */
135
+ export function drawLayers(ctx, layers, context, frame, matrix, opacity) {
136
+ const worlds = layerMatrices(layers, frame)
137
+ for (let n = layers.length - 1; n >= 0; n--) {
138
+ const layer = layers[n]
139
+ const isVisible = !layer.hidden && !layer.isMatte && frame >= layer.ip && frame < layer.op
140
+ if (!isVisible) continue
141
+
142
+ const matte = layer.matte && n > 0 && layers[n - 1].isMatte ? layers[n - 1] : null
143
+ if (matte) {
144
+ drawMatted(ctx, layer, matte, context, frame, multiply(matrix, worlds[n].matrix), multiply(matrix, worlds[n - 1].matrix), opacity * worlds[n].opacity)
145
+ } else {
146
+ drawLayer(ctx, layer, context, frame, multiply(matrix, worlds[n].matrix), opacity * worlds[n].opacity)
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Each layer's matrix within the composition, through its parents. A parent
153
+ * passes on its transform, not its opacity.
154
+ *
155
+ * @param {any[]} layers
156
+ * @param {number} frame
157
+ * @returns {{ matrix: Matrix, opacity: number }[]}
158
+ */
159
+ function layerMatrices(layers, frame) {
160
+ /** @type {Record<string, number>} a layer's `ind` → its position */
161
+ const position = {}
162
+ for (let n = 0; n < layers.length; n++) if (layers[n].ind !== undefined) position[layers[n].ind] = n
163
+
164
+ // each layer's own transform, once: parents are shared
165
+ const own = layers.map((layer) => layer.transform(frame - layer.st))
166
+ const parentOf = (/** @type {any} */ layer) => (layer.parent !== undefined && position[layer.parent] !== undefined ? position[layer.parent] : -1)
167
+
168
+ return layers.map((layer, n) => {
169
+ let matrix = own[n].matrix
170
+ let parent = parentOf(layer)
171
+ // a parent chain is short; the guard only stops a cycle
172
+ for (let depth = 0; parent >= 0 && depth < 32; depth++) {
173
+ matrix = multiply(own[parent].matrix, matrix)
174
+ parent = parentOf(layers[parent])
175
+ }
176
+ return { matrix, opacity: own[n].opacity }
177
+ })
178
+ }
179
+
180
+ /**
181
+ * @param {CanvasRenderingContext2D} ctx
182
+ * @param {any} layer
183
+ * @param {Context} context
184
+ * @param {number} frame the composition's
185
+ * @param {Matrix} matrix the layer's space onto the canvas
186
+ * @param {number} opacity
187
+ */
188
+ function drawLayer(ctx, layer, context, frame, matrix, opacity) {
189
+ if (opacity <= 0) return
190
+ const local = frame - layer.st
191
+
192
+ ctx.save()
193
+ ctx.globalCompositeOperation = layer.blend
194
+ if (layer.masks.length) clipToMasks(ctx, layer.masks, local, matrix)
195
+
196
+ if (layer.shapes) {
197
+ const sink = context.hits && context.hits.active ? { hits: context.hits, matrix, prefix: context.scope || '' } : null
198
+ const run = runGroup(layer.shapes, local, IDENTITY, 1, sink)
199
+ drawOperations(ctx, run.operations, matrix, opacity, local)
200
+ } else if (layer.text) {
201
+ const live = layer.text.sid && typeof context.slots[layer.text.sid] === 'string' ? context.slots[layer.text.sid] : null
202
+ ctx.globalAlpha = opacity
203
+ drawDocument(ctx, layer.text.doc, live, context.families ? context.families() : {}, matrix)
204
+ } else if (layer.precomp) {
205
+ drawPrecomp(ctx, layer, context, local, matrix, opacity)
206
+ } else if (layer.solid) {
207
+ setTransform(ctx, matrix)
208
+ ctx.globalAlpha = opacity
209
+ ctx.fillStyle = layer.solid.color
210
+ ctx.fillRect(0, 0, layer.solid.width, layer.solid.height)
211
+ } else if (layer.image && context.images[imageOf(layer, context)]) {
212
+ const id = imageOf(layer, context)
213
+ const image = context.images[id]
214
+ setTransform(ctx, matrix)
215
+ ctx.globalAlpha = opacity
216
+ const asset = context.assets[id] || {}
217
+ const width = asset.w || image.width
218
+ const height = asset.h || image.height
219
+ // the layer's anchor is a fraction of the image it was authored with, so an image of another size moves to keep it
220
+ const shiftX = layer.origin && layer.anchor ? layer.anchor[0] - layer.origin[0] * width : 0
221
+ const shiftY = layer.origin && layer.anchor ? layer.anchor[1] - layer.origin[1] * height : 0
222
+ ctx.drawImage(image, shiftX, shiftY, width, height)
223
+ }
224
+ ctx.restore()
225
+ }
226
+
227
+ /**
228
+ * The asset an image layer draws: its slot's, when the slot table names one this animation holds.
229
+ *
230
+ * @param {any} layer
231
+ * @param {Context} context
232
+ */
233
+ function imageOf(layer, context) {
234
+ const live = layer.imageSlot ? context.slots[layer.imageSlot] : undefined
235
+ return typeof live === 'string' && context.images[live] ? live : layer.image
236
+ }
237
+
238
+ /**
239
+ * A precomp's own layers, on its own clock: the layer's time remap (seconds)
240
+ * when it has one, else the layer's time slowed by its stretch. Its
241
+ * width and height clip it.
242
+ *
243
+ * @param {CanvasRenderingContext2D} ctx
244
+ * @param {any} layer
245
+ * @param {Context} context
246
+ * @param {number} local the layer's own frame
247
+ * @param {Matrix} matrix
248
+ * @param {number} opacity
249
+ */
250
+ function drawPrecomp(ctx, layer, context, local, matrix, opacity) {
251
+ const inner = layer.timeRemap ? scalar(layer.timeRemap(local)) * context.fps : local / layer.sr
252
+ const size = layer.size ? layer.size(local) : null
253
+ const width = size ? size[0] : layer.width
254
+ const height = size ? size[1] : layer.height
255
+ if (width && height && !layer.unclipped) {
256
+ setTransform(ctx, matrix)
257
+ ctx.beginPath()
258
+ ctx.rect(0, 0, width, height)
259
+ ctx.clip()
260
+ }
261
+ // a nested machine's shapes are named under the layer's `nid`, since its artboard numbers its objects from 0 too
262
+ const outer = context.scope
263
+ if (layer.nid) context.scope = layer.nid + '/'
264
+ drawLayers(ctx, layer.precomp, context, inner, matrix, opacity)
265
+ context.scope = outer
266
+ }
267
+
268
+ /**
269
+ * Clips to a layer's masks: added masks union, subtracted ones cut out
270
+ * (an even-odd clip with a frame around everything), intersected ones each
271
+ * clip again.
272
+ *
273
+ * @param {CanvasRenderingContext2D} ctx
274
+ * @param {any[]} masks
275
+ * @param {number} local
276
+ * @param {Matrix} matrix
277
+ */
278
+ function clipToMasks(ctx, masks, local, matrix) {
279
+ setTransform(ctx, matrix)
280
+ const path = (/** @type {any} */ mask) => {
281
+ const value = mask.path(local)
282
+ return fromLottie(Array.isArray(value) ? value[0] : value)
283
+ }
284
+ const huge = () => {
285
+ ctx.moveTo(-1e5, -1e5)
286
+ ctx.lineTo(1e5, -1e5)
287
+ ctx.lineTo(1e5, 1e5)
288
+ ctx.lineTo(-1e5, 1e5)
289
+ ctx.closePath()
290
+ }
291
+
292
+ const added = masks.filter((mask) => mask.mode === 'a' && !mask.inverted)
293
+ if (added.length) {
294
+ ctx.beginPath()
295
+ for (let n = 0; n < added.length; n++) trace(ctx, transformPath(path(added[n]), IDENTITY))
296
+ ctx.clip()
297
+ }
298
+ for (let n = 0; n < masks.length; n++) {
299
+ const mask = masks[n]
300
+ const cutsOut = mask.mode === 's' || (mask.mode === 'a' && mask.inverted)
301
+ if (!cutsOut && mask.mode !== 'i') continue
302
+ ctx.beginPath()
303
+ if (cutsOut) huge()
304
+ trace(ctx, path(mask))
305
+ ctx.clip(cutsOut ? 'evenodd' : 'nonzero')
306
+ }
307
+ }
308
+
309
+ /**
310
+ * A layer through its track matte: both drawn on scratch canvases the size
311
+ * of the target, the content kept where the matte is opaque (alpha) or
312
+ * clear (inverted). Luma mattes are read as alpha mattes.
313
+ *
314
+ * @param {CanvasRenderingContext2D} ctx
315
+ * @param {any} layer
316
+ * @param {any} matte
317
+ * @param {Context} context
318
+ * @param {number} frame
319
+ * @param {Matrix} matrix
320
+ * @param {Matrix} matteMatrix
321
+ * @param {number} opacity
322
+ */
323
+ function drawMatted(ctx, layer, matte, context, frame, matrix, matteMatrix, opacity) {
324
+ const width = ctx.canvas.width
325
+ const height = ctx.canvas.height
326
+ const content = scratch(width, height)
327
+ const mask = scratch(width, height)
328
+ if (!content || !mask) {
329
+ drawLayer(ctx, layer, context, frame, matrix, opacity)
330
+ return
331
+ }
332
+
333
+ drawLayer(content, layer, context, frame, matrix, opacity)
334
+ drawLayer(mask, matte, context, frame, matteMatrix, 1)
335
+ content.globalCompositeOperation = layer.matte === 2 || layer.matte === 4 ? 'destination-out' : 'destination-in'
336
+ content.setTransform(1, 0, 0, 1, 0, 0)
337
+ content.drawImage(mask.canvas, 0, 0)
338
+
339
+ ctx.save()
340
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
341
+ ctx.drawImage(content.canvas, 0, 0)
342
+ ctx.restore()
343
+ }
344
+
345
+ /**
346
+ * @param {number} width
347
+ * @param {number} height
348
+ * @returns {CanvasRenderingContext2D | null}
349
+ */
350
+ function scratch(width, height) {
351
+ const canvas = document.createElement('canvas')
352
+ canvas.width = width
353
+ canvas.height = height
354
+ return canvas.getContext('2d')
355
+ }
356
+
357
+ /**
358
+ * @param {CanvasRenderingContext2D} ctx
359
+ * @param {Matrix} m
360
+ */
361
+ function setTransform(ctx, m) {
362
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
363
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Draw operations onto a 2D canvas: fills, strokes, gradients and dashes,
3
+ * each in its own group's space so stroke widths and gradient points come
4
+ * out as authored. Merged paths in intersect mode (`mm` 4, how rive2lottie
5
+ * writes clipping) draw as their first part clipped by the rest.
6
+ */
7
+
8
+ import { multiply, trace, transformPath } from './lottie.paths.js'
9
+ import { scalar } from './lottie.shapes.js'
10
+
11
+ /** @import { Matrix } from './lottie.paths.js' */
12
+ /** @import { Entry, Operation } from './lottie.shapes.js' */
13
+
14
+ /**
15
+ * Draws a layer's operations, bottom first.
16
+ *
17
+ * @param {CanvasRenderingContext2D} ctx
18
+ * @param {Operation[]} operations top first, as runGroup returns them
19
+ * @param {Matrix} layer the layer's space onto the canvas
20
+ * @param {number} opacity the layer's
21
+ * @param {number} frame
22
+ */
23
+ export function drawOperations(ctx, operations, layer, opacity, frame) {
24
+ for (let n = operations.length - 1; n >= 0; n--) {
25
+ const operation = operations[n]
26
+ const alpha = opacity * operation.opacity * (scalar(operation.paint.opacity(frame)) / 100)
27
+ if (alpha <= 0 || !operation.entries.length) continue
28
+
29
+ const m = multiply(layer, operation.matrix)
30
+ ctx.save()
31
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
32
+ ctx.globalAlpha = Math.min(1, alpha)
33
+ paint(ctx, operation, frame)
34
+ ctx.restore()
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {CanvasRenderingContext2D} ctx
40
+ * @param {Operation} operation
41
+ * @param {number} frame
42
+ */
43
+ function paint(ctx, operation, frame) {
44
+ const style = operation.paint
45
+ const fill = style.isGradient ? gradient(ctx, style.gradient, frame) : color(style.color(frame))
46
+ if (style.isStroke) strokeStyle(ctx, style, fill, frame)
47
+ else ctx.fillStyle = fill
48
+
49
+ const plain = []
50
+ for (let n = 0; n < operation.entries.length; n++) {
51
+ const entry = operation.entries[n]
52
+ if (entry.holder.merge) drawMerged(ctx, entry, style)
53
+ else plain.push(entry)
54
+ }
55
+ if (!plain.length) return
56
+
57
+ ctx.beginPath()
58
+ for (let n = 0; n < plain.length; n++) traceEntry(ctx, plain[n])
59
+ if (style.isStroke) ctx.stroke()
60
+ else ctx.fill(style.rule)
61
+ }
62
+
63
+ /**
64
+ * @param {CanvasRenderingContext2D} ctx
65
+ * @param {Entry} entry
66
+ */
67
+ function traceEntry(ctx, entry) {
68
+ const paths = entry.holder.paths
69
+ for (let k = 0; k < paths.length; k++) trace(ctx, transformPath(paths[k], entry.matrix))
70
+ }
71
+
72
+ /**
73
+ * A merge: intersect clips the first part by each of the rest; the other
74
+ * modes draw every part as one path, which is exactly `mm` 1 (merge) and
75
+ * close to add, and what lottie-web's canvas renderer does for them too.
76
+ *
77
+ * @param {CanvasRenderingContext2D} ctx
78
+ * @param {Entry} entry
79
+ * @param {any} style
80
+ */
81
+ function drawMerged(ctx, entry, style) {
82
+ const merge = /** @type {{ mode: number, parts: Entry[] }} */ (entry.holder.merge)
83
+ const parts = merge.parts.map((part) => ({ holder: part.holder, matrix: multiply(entry.matrix, part.matrix) }))
84
+
85
+ ctx.save()
86
+ if (merge.mode === 4) {
87
+ for (let n = 1; n < parts.length; n++) {
88
+ ctx.beginPath()
89
+ traceEntry(ctx, parts[n])
90
+ ctx.clip()
91
+ }
92
+ ctx.beginPath()
93
+ traceEntry(ctx, parts[0])
94
+ } else {
95
+ ctx.beginPath()
96
+ for (let n = 0; n < parts.length; n++) traceEntry(ctx, parts[n])
97
+ }
98
+ if (style.isStroke) ctx.stroke()
99
+ else ctx.fill(merge.mode === 5 || merge.mode === 3 ? 'evenodd' : style.rule)
100
+ ctx.restore()
101
+ }
102
+
103
+ /**
104
+ * @param {CanvasRenderingContext2D} ctx
105
+ * @param {any} style
106
+ * @param {any} fill
107
+ * @param {number} frame
108
+ */
109
+ function strokeStyle(ctx, style, fill, frame) {
110
+ ctx.strokeStyle = fill
111
+ ctx.lineWidth = scalar(style.width(frame))
112
+ ctx.lineCap = style.cap
113
+ ctx.lineJoin = style.join
114
+ ctx.miterLimit = style.miter
115
+
116
+ // older browsers have no line dashes; their strokes stay solid
117
+ if (!style.dashes.length || typeof ctx.setLineDash !== 'function') return
118
+ const lengths = []
119
+ let offset = 0
120
+ for (let n = 0; n < style.dashes.length; n++) {
121
+ const dash = style.dashes[n]
122
+ const value = scalar(dash.value(frame))
123
+ if (dash.kind === 'o') offset = value
124
+ else lengths.push(Math.max(0, value))
125
+ }
126
+ ctx.setLineDash(lengths)
127
+ ctx.lineDashOffset = offset
128
+ }
129
+
130
+ /**
131
+ * A Lottie colour, `[r, g, b]` or `[r, g, b, a]` in 0-1, as CSS.
132
+ *
133
+ * @param {number[]} rgba
134
+ * @returns {string}
135
+ */
136
+ export function color(rgba) {
137
+ const channel = (/** @type {number} */ value) => Math.round(Math.max(0, Math.min(1, value)) * 255)
138
+ const alpha = rgba.length > 3 ? Math.max(0, Math.min(1, rgba[3])) : 1
139
+ return 'rgba(' + channel(rgba[0]) + ',' + channel(rgba[1]) + ',' + channel(rgba[2]) + ',' + alpha + ')'
140
+ }
141
+
142
+ /**
143
+ * A linear or radial gradient in the paint's group space. A radial
144
+ * gradient's highlight moves its focal point, as lottie-web's does.
145
+ *
146
+ * @param {CanvasRenderingContext2D} ctx
147
+ * @param {any} spec
148
+ * @param {number} frame
149
+ */
150
+ function gradient(ctx, spec, frame) {
151
+ const s = spec.start(frame)
152
+ const e = spec.end(frame)
153
+ let canvasGradient
154
+ if (spec.type === 2) {
155
+ const dx = e[0] - s[0]
156
+ const dy = e[1] - s[1]
157
+ const radius = Math.sqrt(dx * dx + dy * dy)
158
+ const highlight = Math.max(-0.99, Math.min(0.99, scalar(spec.highlight(frame)) / 100))
159
+ const angle = Math.atan2(dy, dx) + (scalar(spec.angle(frame)) * Math.PI) / 180
160
+ const fx = s[0] + Math.cos(angle) * radius * highlight
161
+ const fy = s[1] + Math.sin(angle) * radius * highlight
162
+ canvasGradient = ctx.createRadialGradient(fx, fy, 0, s[0], s[1], radius)
163
+ } else {
164
+ canvasGradient = ctx.createLinearGradient(s[0], s[1], e[0], e[1])
165
+ }
166
+
167
+ const stops = colorStops(spec.stops(frame), spec.count)
168
+ for (let n = 0; n < stops.length; n++) canvasGradient.addColorStop(stops[n].offset, stops[n].color)
169
+ return canvasGradient
170
+ }
171
+
172
+ /**
173
+ * Lottie's gradient data: `count` colour stops `[offset, r, g, b]`, then
174
+ * optional opacity stops `[offset, alpha]`. Each colour stop takes the
175
+ * opacity interpolated at its offset.
176
+ *
177
+ * @param {number[]} data
178
+ * @param {number} count
179
+ * @returns {{ offset: number, color: string }[]}
180
+ */
181
+ export function colorStops(data, count) {
182
+ const alphas = []
183
+ for (let n = count * 4; n + 1 < data.length; n += 2) alphas.push([ data[n], data[n + 1] ])
184
+
185
+ const stops = []
186
+ for (let n = 0; n < count; n++) {
187
+ const offset = Math.max(0, Math.min(1, data[n * 4]))
188
+ const alpha = alphas.length ? alphaAt(alphas, offset) : 1
189
+ stops.push({ offset, color: color([ data[n * 4 + 1], data[n * 4 + 2], data[n * 4 + 3], alpha ]) })
190
+ }
191
+ return stops
192
+ }
193
+
194
+ /**
195
+ * @param {number[][]} alphas `[offset, alpha]`, in offset order
196
+ * @param {number} offset
197
+ */
198
+ function alphaAt(alphas, offset) {
199
+ if (offset <= alphas[0][0]) return alphas[0][1]
200
+ for (let n = 1; n < alphas.length; n++) {
201
+ if (offset <= alphas[n][0]) {
202
+ const [ a, va ] = alphas[n - 1]
203
+ const [ b, vb ] = alphas[n]
204
+ return b > a ? va + ((vb - va) * (offset - a)) / (b - a) : vb
205
+ }
206
+ }
207
+ return alphas[alphas.length - 1][1]
208
+ }