@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,364 @@
1
+ /**
2
+ * Live data: view-model values to slot values in Lottie units (as Roku's
3
+ * Motion.Task patches them, bundle.bs), to tint colours for raster sheets
4
+ * (motion.tint.bs), and from the string pairs a host such as maestro-web-sdk
5
+ * holds, typed by the bundle's own property declarations.
6
+ */
7
+
8
+ import { applyConverter, reverseConverter } from './motion.convert.js'
9
+
10
+ /**
11
+ * A bundle's `data` block: the view model's properties (nested ones by dotted
12
+ * path, enums with their `values`, images with their `images`), its slots,
13
+ * the write-backs (`writes`) and instances.
14
+ * @typedef {{ properties?: any[], slots?: any[], writes?: any[], instances?: Record<string, any>, defaultInstance?: string }} Data
15
+ */
16
+
17
+ /**
18
+ * A colour as a caller gives it: an ARGB number (as Rive and JSON hold it),
19
+ * `#RRGGBB`, `#AARRGGBB`, `0x`-prefixed, or `[r, g, b, a]` in 0-1.
20
+ *
21
+ * @param {any} value
22
+ * @returns {number[] | null} `[r, g, b, a]` in 0-1
23
+ */
24
+ export function rgba(value) {
25
+ if (Array.isArray(value)) return value
26
+ if (typeof value === 'number' && isFinite(value)) {
27
+ // above 2^31 a colour isn't safe for bit operations, so it's split by division
28
+ let whole = Math.floor(value)
29
+ const channels = []
30
+ for (let n = 0; n < 4; n++) {
31
+ channels.unshift((whole % 256) / 255)
32
+ whole = Math.floor(whole / 256)
33
+ }
34
+ return [ channels[1], channels[2], channels[3], channels[0] ]
35
+ }
36
+ if (typeof value === 'string') {
37
+ const hex = value.replace(/^#|^0x/i, '')
38
+ const isHex = /^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(hex)
39
+ if (!isHex) return null
40
+ const byte = (/** @type {number} */ at) => parseInt(hex.slice(at, at + 2), 16) / 255
41
+ return hex.length === 6 ? [ byte(0), byte(2), byte(4), 1 ] : [ byte(2), byte(4), byte(6), byte(0) ]
42
+ }
43
+ return null
44
+ }
45
+
46
+ /**
47
+ * A view-model value in a slot's Lottie units: colours as `[r, g, b, 1]`,
48
+ * alphas and percents ×100, radians to degrees.
49
+ *
50
+ * @param {any} value
51
+ * @param {string} conversion color, alpha, percent, degrees, number, text or image
52
+ * @param {any} [property] the view-model property the value is of: an enum names its values, an image its assets
53
+ */
54
+ export function convert(value, conversion, property) {
55
+ if (conversion === 'color' || conversion === 'alpha') {
56
+ const colour = rgba(value)
57
+ if (!colour) return undefined
58
+ return conversion === 'alpha' ? colour[3] * 100 : [ colour[0], colour[1], colour[2], 1 ]
59
+ }
60
+ if (conversion === 'text') return property && property.values && typeof value === 'number' && property.values[value] !== undefined ? property.values[value] : value
61
+ if (conversion === 'image') return assetId(value, property)
62
+ if (typeof value !== 'number') return undefined
63
+ if (conversion === 'percent') return value * 100
64
+ if (conversion === 'degrees') return (value * 180) / Math.PI
65
+ return value
66
+ }
67
+
68
+ /**
69
+ * The Lottie asset an image value picks: by the asset's name, by its Lottie
70
+ * id, or by its index in the file.
71
+ *
72
+ * @param {any} value
73
+ * @param {any} [property]
74
+ */
75
+ function assetId(value, property) {
76
+ const images = (property && property.images) || {}
77
+ if (typeof value === 'string') {
78
+ if (images[value]) return images[value]
79
+ return /^image_\d+$/.test(value) ? value : undefined
80
+ }
81
+ return typeof value === 'number' ? 'image_' + value : undefined
82
+ }
83
+
84
+ /**
85
+ * The slot table for a set of values: slot id → Lottie value, for every slot
86
+ * some input fills. A 2-D slot keeps its base value on an axis no source fills.
87
+ *
88
+ * @param {Data} data
89
+ * @param {Record<string, any>} values
90
+ * @param {(message: string) => void} [warn] a converter this player does not know
91
+ * @returns {Record<string, any>}
92
+ */
93
+ export function slotValues(data, values, warn) {
94
+ /** @type {Record<string, any>} */
95
+ const table = {}
96
+ const slots = (data && data.slots) || []
97
+ for (let n = 0; n < slots.length; n++) {
98
+ const slot = slots[n]
99
+ const value = slotValue(slot, values, data, warn)
100
+ if (value !== undefined) table[slot.sid] = value
101
+ }
102
+ return table
103
+ }
104
+
105
+ /**
106
+ * A view-model property's declaration, by its dotted path.
107
+ *
108
+ * @param {Data} data
109
+ * @param {string} path
110
+ * @returns {any}
111
+ */
112
+ export function propertyOf(data, path) {
113
+ const properties = (data && data.properties) || []
114
+ for (let n = 0; n < properties.length; n++) if (properties[n].name === path) return properties[n]
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * What a source reads: its view-model value, through its converter.
120
+ *
121
+ * @param {any} source
122
+ * @param {Record<string, any>} values
123
+ * @param {Data} data
124
+ * @param {(message: string) => void} [warn]
125
+ */
126
+ function sourceValue(source, values, data, warn) {
127
+ const raw = values[source.path]
128
+ if (raw === undefined || !source.converter) return raw
129
+ const property = propertyOf(data, source.path)
130
+ return applyConverter(source.converter, raw, { read: (path) => values[path], names: property && property.values, warn })
131
+ }
132
+
133
+ /**
134
+ * @param {any} slot
135
+ * @param {Record<string, any>} values
136
+ * @param {Data} data
137
+ * @param {(message: string) => void} [warn]
138
+ */
139
+ function slotValue(slot, values, data, warn) {
140
+ const sources = slot.sources || []
141
+ if (!sources.length) return undefined
142
+
143
+ const isPair = slot.type === 'Position' || slot.type === 'Vector'
144
+ if (!isPair) {
145
+ const raw = sourceValue(sources[0], values, data, warn)
146
+ return raw === undefined ? undefined : convert(raw, slot.conversion, propertyOf(data, sources[0].path))
147
+ }
148
+
149
+ const pair = Array.isArray(slot.base) ? slot.base.slice() : [ 0, 0 ]
150
+ let found = false
151
+ for (let n = 0; n < sources.length; n++) {
152
+ const raw = sourceValue(sources[n], values, data, warn)
153
+ if (raw === undefined) continue
154
+ const converted = convert(raw, slot.conversion)
155
+ if (converted === undefined) continue
156
+ pair[sources[n].axis || 0] = converted
157
+ found = true
158
+ }
159
+ return found ? pair : undefined
160
+ }
161
+
162
+ /**
163
+ * Inputs with nested objects flattened to dotted paths: `{ player: { score: 3 } }`
164
+ * is `{ 'player.score': 3 }`. Arrays (a list's items) are values, not paths.
165
+ *
166
+ * @param {Record<string, any>} inputs
167
+ * @returns {Record<string, any>}
168
+ */
169
+ export function flattenInputs(inputs) {
170
+ /** @type {Record<string, any>} */
171
+ const result = {}
172
+ const visit = (/** @type {Record<string, any>} */ object, /** @type {string} */ prefix) => {
173
+ for (const key in object) {
174
+ const value = object[key]
175
+ const isGroup = typeof value === 'object' && value !== null && !Array.isArray(value)
176
+ if (isGroup) visit(value, prefix + key + '.')
177
+ else result[prefix + key] = value
178
+ }
179
+ }
180
+ visit(inputs || {}, '')
181
+ return result
182
+ }
183
+
184
+ /**
185
+ * An input as the flow holds it: an enum given by name becomes its index, so
186
+ * the machine's numeric guards compare it.
187
+ *
188
+ * @param {Data} data
189
+ * @param {string} name the input's dotted path
190
+ * @param {any} value
191
+ * @returns {any}
192
+ */
193
+ export function inputValue(data, name, value) {
194
+ const property = propertyOf(data, name)
195
+ if (!property || !property.values || typeof value !== 'string') return value
196
+ const index = property.values.indexOf(value)
197
+ if (index >= 0) return index
198
+ return isFinite(Number(value)) && value !== '' ? Number(value) : value
199
+ }
200
+
201
+ /**
202
+ * The values a host sees: an enum as the name of its value.
203
+ *
204
+ * @param {Data} data
205
+ * @param {Record<string, any>} values
206
+ * @returns {Record<string, any>}
207
+ */
208
+ export function publicValues(data, values) {
209
+ /** @type {Record<string, any>} */
210
+ const result = {}
211
+ for (const name in values) {
212
+ const property = propertyOf(data, name)
213
+ const named = property && property.values && typeof values[name] === 'number' ? property.values[values[name]] : undefined
214
+ result[name] = named === undefined ? values[name] : named
215
+ }
216
+ return result
217
+ }
218
+
219
+ /**
220
+ * A property the animation draws, back in the view model's units.
221
+ *
222
+ * @param {any} write a `data.writes` entry
223
+ * @param {any} drawn the Lottie property's value
224
+ * @param {any} current the view-model value now, whose alpha a colour keeps
225
+ */
226
+ export function writtenValue(write, drawn, current) {
227
+ const isPair = write.type === 'Position' || write.type === 'Vector'
228
+ let value = isPair && Array.isArray(drawn) ? drawn[write.axis || 0] : drawn
229
+ switch (write.conversion) {
230
+ case 'color': {
231
+ if (!Array.isArray(value)) return undefined
232
+ const colour = rgba(current)
233
+ const alpha = colour ? Math.round(colour[3] * 255) : 255
234
+ const channel = (/** @type {number} */ at) => Math.round(value[at] * 255)
235
+ return alpha * 16777216 + channel(0) * 65536 + channel(1) * 256 + channel(2)
236
+ }
237
+ case 'percent':
238
+ value = value / 100
239
+ break
240
+ case 'degrees':
241
+ value = (value * Math.PI) / 180
242
+ break
243
+ case 'alpha':
244
+ return undefined
245
+ default:
246
+ break
247
+ }
248
+ if (typeof value !== 'number' || !isFinite(value)) return undefined
249
+ return write.converter ? reverseConverter(write.converter, value) : value
250
+ }
251
+
252
+ /**
253
+ * Each tint's live colour and opacity, for raster sheets: the slot's input
254
+ * when it has one, else the tint's own.
255
+ *
256
+ * @param {any[]} tints a raster entry's `tints`
257
+ * @param {Data} data
258
+ * @param {Record<string, any>} values
259
+ * @returns {{ sid: string, rgb: number[], alpha: number }[]}
260
+ */
261
+ export function tintColors(tints, data, values) {
262
+ const result = []
263
+ for (let n = 0; n < (tints || []).length; n++) {
264
+ const tint = tints[n]
265
+ const live = rgba(slotInput(data, tint.sid, values))
266
+ const liveAlpha = tint.alphaSid ? alphaOf(data, tint.alphaSid, values) : undefined
267
+ result.push({ sid: tint.sid, rgb: live ? live.slice(0, 3) : tint.color, alpha: typeof liveAlpha === 'number' ? liveAlpha : tint.alpha })
268
+ }
269
+ return result
270
+ }
271
+
272
+ /**
273
+ * @param {Data} data
274
+ * @param {string} sid
275
+ * @param {Record<string, any>} values
276
+ */
277
+ function slotInput(data, sid, values) {
278
+ const slot = findSlot(data, sid)
279
+ return slot ? values[slot.sources[0].path] : undefined
280
+ }
281
+
282
+ /**
283
+ * A Rive colour carries its alpha; a Rive opacity is 0-1; a Lottie one 0-100.
284
+ *
285
+ * @param {Data} data
286
+ * @param {string} sid
287
+ * @param {Record<string, any>} values
288
+ */
289
+ function alphaOf(data, sid, values) {
290
+ const slot = findSlot(data, sid)
291
+ if (!slot) return undefined
292
+ const raw = values[slot.sources[0].path]
293
+ if (raw === undefined) return undefined
294
+ if (slot.conversion === 'alpha') {
295
+ const colour = rgba(raw)
296
+ return colour && colour.length > 3 ? colour[3] : undefined
297
+ }
298
+ if (typeof raw !== 'number') return undefined
299
+ return slot.conversion === 'percent' ? raw : raw / 100
300
+ }
301
+
302
+ /**
303
+ * @param {Data} data
304
+ * @param {string} sid
305
+ */
306
+ export function findSlot(data, sid) {
307
+ const slots = (data && data.slots) || []
308
+ for (let n = 0; n < slots.length; n++) {
309
+ const slot = slots[n]
310
+ if (slot.sid === sid && slot.sources && slot.sources.length) return slot
311
+ }
312
+ return null
313
+ }
314
+
315
+ /**
316
+ * Values from `{ key, value }` string pairs, typed by the view model's own
317
+ * property declarations, so a colour named anything reads as a colour. A key
318
+ * the bundle doesn't declare is kept as given.
319
+ *
320
+ * @param {Data} data
321
+ * @param {{ key: string, value: string }[]} pairs
322
+ * @returns {Record<string, any>}
323
+ */
324
+ export function fromPairs(data, pairs) {
325
+ /** @type {Record<string, string>} */
326
+ const types = {}
327
+ const properties = (data && data.properties) || []
328
+ for (let n = 0; n < properties.length; n++) types[properties[n].name] = properties[n].type
329
+
330
+ /** @type {Record<string, any>} */
331
+ const values = {}
332
+ for (let n = 0; n < (pairs || []).length; n++) {
333
+ const { key, value } = pairs[n]
334
+ if (!key) continue
335
+ values[key] = typed(value, types[key])
336
+ }
337
+ return values
338
+ }
339
+
340
+ /**
341
+ * A list as the JSON text of its items; text that is not a list is an empty one.
342
+ *
343
+ * @param {string} value
344
+ */
345
+ function jsonList(value) {
346
+ try {
347
+ const items = JSON.parse(value)
348
+ return Array.isArray(items) ? items : []
349
+ } catch {
350
+ return []
351
+ }
352
+ }
353
+
354
+ /**
355
+ * @param {string} value
356
+ * @param {string} [type]
357
+ */
358
+ function typed(value, type) {
359
+ if (type === 'number') return Number(value)
360
+ if (type === 'boolean') return value === 'true'
361
+ if (type === 'list') return jsonList(value)
362
+ if (type === 'color') return isFinite(Number(value)) && value !== '' ? Number(value) : value
363
+ return value
364
+ }