@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/bands/bands.js ADDED
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Layout bands that blend. Where the converter says a band's layout can be
3
+ * interpolated up to the next band (`lerp`: nothing wraps, clamps or appears
4
+ * between their widths), every laid-out box's position and size is a straight
5
+ * line in the width, so a canvas between the two bands draws the mix of
6
+ * their variants, weighted `t = (w - w0) / (w1 - w0)`.
7
+ *
8
+ * Rive's `layout` fit sizes the artboard to the whole canvas, so a layout
9
+ * can depend on the height too, and the converter then writes a grid: a row
10
+ * of the same bands for each height band. Inside a cell of the grid where
11
+ * nothing reflows (`cell`) every box is affine in both the width and the
12
+ * height, so the canvas draws the bilinear mix of the cell's four corners,
13
+ * weighted (1 - u)(1 - v), u(1 - v), (1 - u)v and uv, `u` being how far the
14
+ * width is across the cell and `v` the height. `cellOf` finds the cell.
15
+ *
16
+ * `linkBands` does that by giving each corner's variant of an animation, per
17
+ * property it shares with its neighbours', an evaluator that blends their
18
+ * values by two live weights. The animation is then drawn as always (alone,
19
+ * or as the pose mixer's carrier or source), and at `u = v = 0` is exactly
20
+ * the corner's own. Properties tagged `rid` match between bands the way they
21
+ * match between the animations of one artboard.
22
+ */
23
+
24
+ import { evaluator } from '../lottie/lottie.props.js'
25
+ import { mixValues } from '../mixer/mixer.mix.js'
26
+
27
+ /** @import { Evaluator } from '../lottie/lottie.props.js' */
28
+
29
+ /** No slots: a property's keyframes, with the drawn animation's own slots going over the result */
30
+ const NO_SLOTS = {}
31
+
32
+ /**
33
+ * @typedef {object} Blend
34
+ * @property {any} band the band the canvas has room for
35
+ * @property {any | null} next the band after it, when the canvas is inside a segment that blends
36
+ * @property {number} t next's weight, 0 to 1; 0 without a next
37
+ */
38
+
39
+ /**
40
+ * Where a canvas width falls among the bands: the widest band it has room
41
+ * for (the narrowest when it has room for none), and how far it is toward
42
+ * the next, when that segment can be interpolated.
43
+ *
44
+ * @param {any[] | undefined} bands a bundle's `bands`, narrowest first
45
+ * @param {number} width the canvas's width in CSS pixels
46
+ * @returns {Blend | null} null without bands
47
+ */
48
+ export function blendOf(bands, width) {
49
+ if (!bands || !bands.length) return null
50
+ let at = 0
51
+ for (let n = 1; n < bands.length; n++) if (bands[n].width <= width) at = n
52
+
53
+ const band = bands[at]
54
+ const next = bands[at + 1]
55
+ if (!band.lerp || !next || width <= band.width) return { band, next: null, t: 0 }
56
+ return { band, next, t: Math.min(1, (width - band.width) / (next.width - band.width)) }
57
+ }
58
+
59
+ /**
60
+ * A band of the layout grid, at one width and height.
61
+ *
62
+ * @typedef {object} Corner
63
+ * @property {number} width
64
+ * @property {number} height
65
+ * @property {Record<string, string> | undefined} animations the animation that stands for each of the bundle's own here; none at the artboard's own size
66
+ * @property {boolean} lerp the segment to the next, wider corner in its row blends
67
+ * @property {boolean} rise the segment to the next, taller corner in its column blends
68
+ * @property {boolean} cell the whole rectangle up to the diagonal corner blends bilinearly
69
+ */
70
+
71
+ /**
72
+ * The layout grid of a bundle: its bands as one row, or the rows of its
73
+ * `grid` when the layout depends on the height.
74
+ *
75
+ * @typedef {object} Grid
76
+ * @property {Corner[][]} rows shortest first, each row narrowest first; every row holds the same widths
77
+ * @property {[number, number] | null} range the heights the layout was solved over, when the converter says
78
+ */
79
+
80
+ /**
81
+ * @param {any} manifest
82
+ * @returns {Grid | null} null without bands
83
+ */
84
+ export function gridOf(manifest) {
85
+ const bands = manifest.bands
86
+ if (!bands || !bands.length) return null
87
+
88
+ const range = manifest.heightRange && manifest.heightRange.length === 2 ? /** @type {[number, number]} */ ([ manifest.heightRange[0], manifest.heightRange[1] ]) : null
89
+ const listed = manifest.grid && manifest.grid.rows
90
+ if (listed && listed.length) {
91
+ const rows = []
92
+ for (let j = 0; j < listed.length; j++) {
93
+ const row = []
94
+ for (let i = 0; i < listed[j].bands.length; i++) row.push(cornerOf(listed[j].bands[i], listed[j].height))
95
+ rows.push(row)
96
+ }
97
+ return { rows, range }
98
+ }
99
+
100
+ const row = []
101
+ for (let i = 0; i < bands.length; i++) row.push(cornerOf(bands[i], bands[i].height || 0))
102
+ return { rows: [ row ], range }
103
+ }
104
+
105
+ /**
106
+ * @param {any} band
107
+ * @param {number} height
108
+ * @returns {Corner}
109
+ */
110
+ function cornerOf(band, height) {
111
+ return { width: band.width, height, animations: band.animations, lerp: !!band.lerp, rise: !!band.rise, cell: !!band.cell }
112
+ }
113
+
114
+ /**
115
+ * Where a canvas falls in the layout grid.
116
+ *
117
+ * @typedef {object} Cell
118
+ * @property {Corner} corner the corner the canvas has room for: the widest, tallest band that fits, else the narrowest, shortest
119
+ * @property {Corner|null} right the next, wider corner, when the canvas is inside a segment that blends in the width
120
+ * @property {Corner|null} up the next, taller corner, when it is inside one that blends in the height
121
+ * @property {Corner|null} diag the corner across, when it is inside a cell that blends in both
122
+ * @property {number} u how far across to `right`, 0 to 1; 0 without one
123
+ * @property {number} v how far up to `up`, 0 to 1; 0 without one
124
+ * @property {number} width the artboard's width: the canvas's where the layout is exact for it, else 0 for the picture's own
125
+ * @property {number} height the artboard's height, likewise
126
+ */
127
+
128
+ /**
129
+ * The cell of the grid for a canvas size: the corner the canvas has room for,
130
+ * the corners it blends toward, and the weights, where the converter says the
131
+ * segments or cell can be interpolated. A canvas past the grid is the edge
132
+ * corner, scaled by the fit. Where the layout does not depend on the height
133
+ * (one row) and the converter says what range it checked, the artboard is the
134
+ * canvas's height within that range, as Rive sizes it.
135
+ *
136
+ * @param {Grid} grid
137
+ * @param {number} width the canvas's width in CSS pixels
138
+ * @param {number} height its height
139
+ * @returns {Cell}
140
+ */
141
+ export function cellOf(grid, width, height) {
142
+ const rows = grid.rows
143
+ let j = 0
144
+ for (let n = 1; n < rows.length; n++) if (rows[n][0].height <= height) j = n
145
+ const row = rows[j]
146
+ let i = 0
147
+ for (let n = 1; n < row.length; n++) if (row[n].width <= width) i = n
148
+
149
+ const corner = row[i]
150
+ const right = row[i + 1] || null
151
+ const up = rows[j + 1] ? rows[j + 1][i] : null
152
+ const isU = !!right && corner.lerp && width > corner.width
153
+ const isV = !!up && corner.rise && height > corner.height
154
+
155
+ // both ways only inside a cell the converter checked; a cell that reflows plays its corner
156
+ const isBoth = isU && isV
157
+ const canU = isU && (!isBoth || corner.cell)
158
+ const canV = isV && (!isBoth || corner.cell)
159
+ const u = canU ? Math.min(1, (width - corner.width) / (/** @type {Corner} */ (right).width - corner.width)) : 0
160
+ const v = canV ? Math.min(1, (height - corner.height) / (/** @type {Corner} */ (up).height - corner.height)) : 0
161
+
162
+ let artboardHeight = 0
163
+ if (canV) artboardHeight = height
164
+ else if (rows.length === 1 && grid.range) artboardHeight = Math.max(grid.range[0], Math.min(grid.range[1], height))
165
+ return {
166
+ corner,
167
+ right: canU ? right : null,
168
+ up: canV ? up : null,
169
+ diag: canU && canV ? rows[j + 1][i + 1] : null,
170
+ u,
171
+ v,
172
+ width: canU ? width : 0,
173
+ height: artboardHeight,
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Whether any band of a bundle blends into a neighbour.
179
+ *
180
+ * @param {any} manifest
181
+ * @returns {boolean}
182
+ */
183
+ export function hasBlend(manifest) {
184
+ const bands = manifest.bands || []
185
+ for (let n = 0; n < bands.length - 1; n++) if (bands[n].lerp) return true
186
+ const rows = (manifest.grid && manifest.grid.rows) || []
187
+ for (let j = 0; j < rows.length; j++) {
188
+ for (let i = 0; i < rows[j].bands.length; i++) if (rows[j].bands[i].lerp || rows[j].bands[i].rise) return true
189
+ }
190
+ return false
191
+ }
192
+
193
+ /**
194
+ * Links every blending corner's variants to its neighbours'. Call it with all
195
+ * the bundle's parsed Lotties loaded and none yet drawn or mixed.
196
+ *
197
+ * @param {any} manifest
198
+ * @param {Record<string, any>} lotties parsed Lottie JSON by animation id, changed in place
199
+ * @param {{ t: number, v?: number }} live the weights toward the next, wider corner (`t`) and the next, taller one (`v`); set them before a draw
200
+ */
201
+ export function linkBands(manifest, lotties, live) {
202
+ const grid = gridOf(manifest)
203
+ if (!grid) return
204
+ const ids = baseIds(manifest)
205
+
206
+ /** @param {Corner | undefined} from @param {boolean} on @returns {Corner | null} */
207
+ const neighbour = (from, on) => (on && from ? from : null)
208
+
209
+ // every target's values are read before any is linked, since a target links onward itself
210
+ /** @type {Record<string, Record<string, { property: any, at: Evaluator }>>} */
211
+ const targets = {}
212
+ /** @type {{ corner: Corner, right: Corner | null, up: Corner | null, diag: Corner | null }[]} */
213
+ const links = []
214
+ const rows = grid.rows
215
+ for (let j = 0; j < rows.length; j++) {
216
+ for (let i = 0; i < rows[j].length; i++) {
217
+ const corner = rows[j][i]
218
+ const right = neighbour(rows[j][i + 1], corner.lerp || corner.cell)
219
+ const up = neighbour(rows[j + 1] && rows[j + 1][i], corner.rise || corner.cell)
220
+ const diag = neighbour(rows[j + 1] && rows[j + 1][i + 1], corner.cell)
221
+ if (!right && !up) continue
222
+ links.push({ corner, right, up, diag })
223
+ for (let k = 0; k < ids.length; k++) {
224
+ const others = [ right, up, diag ]
225
+ for (let m = 0; m < others.length; m++) {
226
+ const other = others[m]
227
+ if (!other) continue
228
+ const id = variantId(other, ids[k])
229
+ if (lotties[id] && !targets[id]) targets[id] = collect(lotties[id])
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ for (let n = 0; n < links.length; n++) {
236
+ const entry = links[n]
237
+ for (let k = 0; k < ids.length; k++) {
238
+ const from = lotties[variantId(entry.corner, ids[k])]
239
+ if (!from) continue
240
+ const at = (/** @type {Corner | null} */ other) => (other ? targets[variantId(other, ids[k])] : undefined)
241
+ link(from, { right: at(entry.right), up: at(entry.up), diag: at(entry.diag) }, live)
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @typedef {Record<string, { property: any, at: Evaluator }>} Targets
248
+ */
249
+
250
+ /**
251
+ * @param {any} lottie a corner's variant
252
+ * @param {{ right?: Targets, up?: Targets, diag?: Targets }} to the neighbours', by `rid`
253
+ * @param {{ t: number, v?: number }} live
254
+ */
255
+ function link(lottie, to, live) {
256
+ visit(lottie, (property) => {
257
+ const right = differing(property, to.right)
258
+ const up = differing(property, to.up)
259
+ const diag = differing(property, to.diag)
260
+ if (!right && !up && !diag) return
261
+
262
+ const own = evaluator(property, NO_SLOTS)
263
+ property.mx = (/** @type {number} */ frame) => {
264
+ const value = own(frame)
265
+ const u = live.t
266
+ const v = live.v || 0
267
+ if (u > 0 && v > 0) {
268
+ // bilinear: along the bottom edge and the top one, then between them
269
+ const across = right ? mixValues(value, right(frame), u) : value
270
+ const lift = up ? up(frame) : value
271
+ const top = diag ? mixValues(lift, diag(frame), u) : lift
272
+ return mixValues(across, top, v)
273
+ }
274
+ if (u > 0) return right ? mixValues(value, right(frame), u) : value
275
+ if (v > 0) return up ? mixValues(value, up(frame), v) : value
276
+ return value
277
+ }
278
+ })
279
+ }
280
+
281
+ /**
282
+ * A neighbour's evaluator for a property, when its values differ from the property's.
283
+ *
284
+ * @param {any} property
285
+ * @param {Targets | undefined} targets
286
+ * @returns {Evaluator | null}
287
+ */
288
+ function differing(property, targets) {
289
+ const other = targets && targets[property.rid]
290
+ return other && !isSame(property, other.property) ? other.at : null
291
+ }
292
+
293
+ /**
294
+ * Whether two properties hold the same values, which need no blending.
295
+ *
296
+ * @param {any} a
297
+ * @param {any} b
298
+ * @returns {boolean}
299
+ */
300
+ function isSame(a, b) {
301
+ return a.a === b.a && JSON.stringify(a.k) === JSON.stringify(b.k)
302
+ }
303
+
304
+ /**
305
+ * Every tagged property of a Lottie by its `rid`; the first of a repeated one wins.
306
+ *
307
+ * @param {any} lottie
308
+ * @returns {Record<string, { property: any, at: Evaluator }>}
309
+ */
310
+ function collect(lottie) {
311
+ /** @type {Record<string, { property: any, at: Evaluator }>} */
312
+ const found = {}
313
+ visit(lottie, (property) => {
314
+ if (!found[property.rid]) found[property.rid] = { property, at: evaluator(property, NO_SLOTS) }
315
+ })
316
+ return found
317
+ }
318
+
319
+ /**
320
+ * Calls back for every tagged property under a node, in assets as in layers.
321
+ *
322
+ * @param {any} node
323
+ * @param {(property: any) => void} found
324
+ */
325
+ function visit(node, found) {
326
+ if (node === null || typeof node !== 'object') return
327
+ if (Array.isArray(node)) {
328
+ for (let n = 0; n < node.length; n++) visit(node[n], found)
329
+ return
330
+ }
331
+ if (typeof node.rid === 'string' && 'k' in node) found(node)
332
+ for (const key in node) {
333
+ if (key === 'k' || key === 'rid') continue
334
+ visit(node[key], found)
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Every band the manifest lists, those of the authored height and the grid's.
340
+ *
341
+ * @param {any} manifest
342
+ * @returns {any[]}
343
+ */
344
+ function everyBand(manifest) {
345
+ const found = (manifest.bands || []).slice()
346
+ const rows = (manifest.grid && manifest.grid.rows) || []
347
+ for (let j = 0; j < rows.length; j++) for (let i = 0; i < rows[j].bands.length; i++) found.push(rows[j].bands[i])
348
+ return found
349
+ }
350
+
351
+ /**
352
+ * The animations that are a layout band's variant of another, not an animation of the bundle's own.
353
+ *
354
+ * @param {any} manifest
355
+ * @returns {Record<string, boolean>}
356
+ */
357
+ export function variantIds(manifest) {
358
+ /** @type {Record<string, boolean>} */
359
+ const found = {}
360
+ const bands = everyBand(manifest)
361
+ for (let n = 0; n < bands.length; n++) {
362
+ for (const id in bands[n].animations || {}) found[bands[n].animations[id]] = true
363
+ }
364
+ return found
365
+ }
366
+
367
+ /**
368
+ * The bundle's own animations: those no band lists as its variant.
369
+ *
370
+ * @param {any} manifest
371
+ * @returns {string[]}
372
+ */
373
+ function baseIds(manifest) {
374
+ const isVariant = variantIds(manifest)
375
+ const ids = []
376
+ for (const id in manifest.animations) if (!isVariant[id]) ids.push(id)
377
+ return ids
378
+ }
379
+
380
+ /**
381
+ * An animation's variant in a band: the animation itself in the band at the
382
+ * artboard's own size.
383
+ *
384
+ * @param {{ animations?: Record<string, string> }} band
385
+ * @param {string} id
386
+ * @returns {string}
387
+ */
388
+ function variantId(band, id) {
389
+ return (band.animations && band.animations[id]) || id
390
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The fonts a bundle's text overlays use, registered under a family name of
3
+ * their own so they never shadow the page's. FontFace where the browser has
4
+ * it, which says when the font is ready; an @font-face rule elsewhere, which
5
+ * the canvas picks up once the browser has fetched it.
6
+ */
7
+
8
+ import { fileUrl } from './bundle.js'
9
+
10
+ /** @import { Bundle } from './bundle.js' */
11
+
12
+ /** Families registered so far, by URL, so a bundle loaded twice registers once */
13
+ /** @type {Record<string, string>} */
14
+ const registered = {}
15
+
16
+ /**
17
+ * @param {Bundle} bundle
18
+ * @returns {Promise<Record<string, string>>} font name → the family to draw with
19
+ */
20
+ export function loadFonts(bundle) {
21
+ /** @type {Record<string, string>} */
22
+ const families = {}
23
+ const pending = []
24
+ const fonts = bundle.manifest.fonts || {}
25
+ for (const name in fonts) {
26
+ const url = fileUrl(bundle, fonts[name])
27
+ const family = registered[url] || 'motion-' + Object.keys(registered).length + '-' + name.replace(/[^\w-]+/g, '_')
28
+ families[name] = family
29
+ if (registered[url]) continue
30
+
31
+ registered[url] = family
32
+ pending.push(register(family, url))
33
+ }
34
+ return Promise.all(pending).then(() => families)
35
+ }
36
+
37
+ /**
38
+ * @param {string} family
39
+ * @param {string} url
40
+ * @returns {Promise<void>}
41
+ */
42
+ function register(family, url) {
43
+ const FontFaceClass = /** @type {any} */ (window).FontFace
44
+ const fontSet = /** @type {any} */ (document).fonts
45
+ if (FontFaceClass && fontSet) {
46
+ const face = new FontFaceClass(family, 'url("' + url + '")')
47
+ // a font that fails to load leaves its text in the fallback font
48
+ return face.load().then(() => fontSet.add(face), () => undefined)
49
+ }
50
+
51
+ const style = document.createElement('style')
52
+ style.appendChild(document.createTextNode('@font-face { font-family: "' + family + '"; src: url("' + url + '"); }'))
53
+ document.head.appendChild(style)
54
+ return Promise.resolve()
55
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A bundle over HTTP: motion.json, then the files it names, each resolved
3
+ * against motion.json's own folder. XMLHttpRequest rather than fetch, which
4
+ * some of the WebViews this runs in predate.
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} Bundle
9
+ * @property {any} manifest motion.json
10
+ * @property {string} base motion.json's folder, ending in "/"
11
+ */
12
+
13
+ /**
14
+ * @param {string} url motion.json's URL
15
+ * @returns {Promise<Bundle>}
16
+ */
17
+ export function loadBundle(url) {
18
+ return loadJson(url).then((manifest) => {
19
+ const isReadable = manifest && typeof manifest.motion === 'number' && manifest.animations
20
+ if (!isReadable) throw new Error(url + ' is not a Motion bundle')
21
+ // a file stretched to hold a nested artboard's animation (for a player that cannot run the machine) loops and ends where its own keys do
22
+ for (const id in manifest.animations) {
23
+ const entry = manifest.animations[id]
24
+ if (entry && entry.own > 0 && entry.own < entry.frames) entry.frames = entry.own
25
+ }
26
+ return { manifest, base: url.replace(/[^/]*$/, '') }
27
+ })
28
+ }
29
+
30
+ /**
31
+ * @param {Bundle} bundle
32
+ * @param {string} file relative to motion.json
33
+ * @returns {string}
34
+ */
35
+ export function fileUrl(bundle, file) {
36
+ return bundle.base + file.split('/').map(encodeURIComponent).join('/')
37
+ }
38
+
39
+ /**
40
+ * @param {string} url
41
+ * @returns {Promise<any>}
42
+ */
43
+ export function loadJson(url) {
44
+ return new Promise((resolve, reject) => {
45
+ const request = new XMLHttpRequest()
46
+ request.open('GET', url, true)
47
+ request.onload = () => {
48
+ const isOk = request.status >= 200 && request.status < 300
49
+ if (!isOk) {
50
+ reject(new Error('cannot load ' + url + ' (HTTP ' + request.status + ')'))
51
+ return
52
+ }
53
+ try {
54
+ resolve(JSON.parse(request.responseText))
55
+ } catch (error) {
56
+ reject(new Error(url + ' is not JSON: ' + /** @type {Error} */ (error).message))
57
+ }
58
+ }
59
+ request.onerror = () => reject(new Error('cannot load ' + url))
60
+ request.send()
61
+ })
62
+ }
63
+
64
+ /**
65
+ * @param {string} url
66
+ * @returns {Promise<HTMLImageElement>}
67
+ */
68
+ export function loadImage(url) {
69
+ return new Promise((resolve, reject) => {
70
+ const image = new Image()
71
+ image.onload = () => resolve(image)
72
+ image.onerror = () => reject(new Error('cannot load ' + url))
73
+ image.src = url
74
+ })
75
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Directional focus, as Rive moves it for the arrow keys and the
3
+ * up/down/left/right focus actions: from the focused node's centre to the
4
+ * stop in that direction with the lowest score, where a stop is in the
5
+ * direction when its centre is further that way, and
6
+ *
7
+ * score = distance along the direction + weight * distance across it
8
+ *
9
+ * The weight is 2 for up and down and 36 for left and right, fitted to what
10
+ * `@rive-app/canvas` chooses (the parity scorecard's `focus-*` fixtures and the
11
+ * tests here hold it); a tie goes to the stop earlier in Tab order.
12
+ */
13
+
14
+ /** How much a unit across the direction costs, against a unit along it */
15
+ const ACROSS = { up: 2, down: 2, left: 36, right: 36 }
16
+
17
+ /**
18
+ * @param {string} direction up, down, left or right
19
+ * @param {{ x: number, y: number }} from the focused node's centre
20
+ * @param {string[]} stops the candidates, in Tab order
21
+ * @param {(rid: string) => ({ x: number, y: number } | null)} centerOf null for a node with no geometry drawn
22
+ * @returns {string | null} the stop to focus, or null when none lies that way
23
+ */
24
+ export function pickInDirection(direction, from, stops, centerOf) {
25
+ const weight = ACROSS[/** @type {keyof typeof ACROSS} */ (direction)]
26
+ if (weight === undefined) return null
27
+ const isVertical = direction === 'up' || direction === 'down'
28
+ const sign = direction === 'down' || direction === 'right' ? 1 : -1
29
+
30
+ /** @type {string | null} */
31
+ let best = null
32
+ let bestScore = Infinity
33
+ for (let n = 0; n < stops.length; n++) {
34
+ const center = centerOf(stops[n])
35
+ if (!center) continue
36
+ const along = (isVertical ? center.y - from.y : center.x - from.x) * sign
37
+ const across = Math.abs(isVertical ? center.x - from.x : center.y - from.y)
38
+ if (along <= 0) continue
39
+ const score = along + weight * across
40
+ if (score < bestScore) {
41
+ bestScore = score
42
+ best = stops[n]
43
+ }
44
+ }
45
+ return best
46
+ }