@kieransimkin/dance-rudiments 0.3.0 → 0.4.1

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,625 @@
1
+ const MAX_JSON_BYTES = 64 * 1024 * 1024;
2
+ const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
3
+ const MAX_VALUES = 8388608;
4
+ const MAX_CLIPS = 10000;
5
+ const MIN_PIP = -2147483648;
6
+ const MAX_PIP = 2147483647;
7
+ const ID = /^[a-z][a-z0-9_]{0,127}$/;
8
+ const SHA = /^[a-f0-9]{64}$/;
9
+ const TYPE = /^syn:\/\/type\/[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
10
+ const PATH = /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*)*$/;
11
+ const RESERVED_PATHS = new Set(["manifest.json", "readme.md", "license.txt", "third_party_notices.md", "sources.json"]);
12
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*))?(?:\+[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)?$/;
13
+ function object(value, label) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ throw Error(`${label} must be an object`);
16
+ return value;
17
+ }
18
+ function fields(value, required, optional, label) {
19
+ const allowed = new Set([...required, ...optional]);
20
+ if (required.some(key => !Object.prototype.hasOwnProperty.call(value, key)) ||
21
+ Object.keys(value).some(key => !allowed.has(key)))
22
+ throw Error(`Invalid ${label} fields`);
23
+ }
24
+ function integer(value, low, high, label) {
25
+ if (!Number.isSafeInteger(value) || value < low || value > high)
26
+ throw Error(`${label} must be an integer in [${low}, ${high}]`);
27
+ return value;
28
+ }
29
+ function number(value, label) {
30
+ if (typeof value !== "number" || !Number.isFinite(value))
31
+ throw Error(`${label} must be finite`);
32
+ return value;
33
+ }
34
+ function string(value, label, max = 4096) {
35
+ if (typeof value !== "string" || !value.length || value.length > max || value.includes("\0"))
36
+ throw Error(`Invalid ${label}`);
37
+ return value;
38
+ }
39
+ function identifier(value, label) {
40
+ const result = string(value, label, 128);
41
+ if (!ID.test(result))
42
+ throw Error(`Invalid ${label}`);
43
+ return result;
44
+ }
45
+ function sha(value, label) {
46
+ if (typeof value !== "string" || !SHA.test(value))
47
+ throw Error(`Invalid ${label} SHA-256`);
48
+ return value;
49
+ }
50
+ function typeUri(value) {
51
+ const result = string(value, "type_uri").replace(/^synny:\/\//, "syn://");
52
+ if (!TYPE.test(result))
53
+ throw Error("Invalid semantic type_uri");
54
+ return result;
55
+ }
56
+ function dimensions(value) {
57
+ if (!Array.isArray(value) || !value.length || value.length > 1000000)
58
+ throw Error("Invalid dimensions");
59
+ const result = value.map(item => identifier(item, "dimension"));
60
+ if (new Set(result).size !== result.length)
61
+ throw Error("Duplicate dimensions");
62
+ return Object.freeze(result);
63
+ }
64
+ function sameDimensions(a, b) {
65
+ return a.length === b.length && a.every((value, index) => value === b[index]);
66
+ }
67
+ function json(value, depth = 0) {
68
+ if (depth > 128)
69
+ throw Error("JSON metadata nesting limit exceeded");
70
+ if (value === null || typeof value === "boolean" || typeof value === "string")
71
+ return value;
72
+ if (typeof value === "number")
73
+ return number(value, "JSON metadata number");
74
+ if (Array.isArray(value))
75
+ return Object.freeze(value.map(item => json(item, depth + 1)));
76
+ const result = Object.create(null);
77
+ for (const [key, item] of Object.entries(object(value, "JSON metadata")))
78
+ result[key] = json(item, depth + 1);
79
+ return Object.freeze(result);
80
+ }
81
+ function jsonObject(value, label) {
82
+ object(value, label);
83
+ return json(value);
84
+ }
85
+ function metadata(value) {
86
+ const result = jsonObject(value, "metadata");
87
+ if (result.timing_kind !== undefined && result.timing_kind !== "music" && result.timing_kind !== "time-only")
88
+ throw Error("metadata.timing_kind must be music or time-only");
89
+ return result;
90
+ }
91
+ function asset(value) {
92
+ const v = object(value, "asset");
93
+ const path = string(v.path, "asset path", 1024);
94
+ if (!PATH.test(path))
95
+ throw Error("Asset path must be a plain relative path without traversal, URL or query");
96
+ if (RESERVED_PATHS.has(path.toLowerCase()))
97
+ throw Error("Asset path collides with reserved package metadata/notices");
98
+ return Object.freeze({ path, sha256: sha(v.sha256, "asset"), byte_length: integer(v.byte_length, 1, MAX_JSON_BYTES, "byte_length") });
99
+ }
100
+ function validateManifest(value, packageId, target) {
101
+ const v = object(value, "motion package");
102
+ fields(v, ["format", "schema_version", "id", "version", "title", "type_uri", "profile_sha256", "dimensions", "profile", "clips", "license", "provenance"], [], "motion package");
103
+ if (v.format !== "dancerudiments.motion-package" || v.schema_version !== 1)
104
+ throw Error("Unsupported motion package format/version");
105
+ const id = identifier(v.id, "package id");
106
+ if (id !== packageId)
107
+ throw Error("Motion package ID mismatch");
108
+ const version = string(v.version, "package version", 128), match = SEMVER.exec(version);
109
+ if (!match || match[4]?.split(".").some(part => /^\d+$/.test(part) && part.length > 1 && part[0] === "0"))
110
+ throw Error("Package version must be semver");
111
+ const uri = typeUri(v.type_uri), fingerprint = sha(v.profile_sha256, "profile"), dims = dimensions(v.dimensions);
112
+ if (uri !== target.typeUri || fingerprint !== target.sha256 || !sameDimensions(dims, target.dimensions))
113
+ throw Error("Motion package target profile/type/dimensions mismatch");
114
+ const profile = asset(v.profile);
115
+ fields(object(v.profile, "profile asset"), ["path", "sha256", "byte_length"], [], "profile asset");
116
+ if (profile.sha256 !== fingerprint)
117
+ throw Error("Canonical profile asset SHA-256 must equal profile fingerprint");
118
+ if (!Array.isArray(v.clips) || !v.clips.length || v.clips.length > MAX_CLIPS)
119
+ throw Error("Invalid package clip count");
120
+ const ids = new Set(), paths = new Set([profile.path.toLowerCase()]);
121
+ const clips = v.clips.map(item => {
122
+ const c = object(item, "clip");
123
+ fields(c, ["id", "path", "sha256", "byte_length", "decoded_byte_length", "encoding", "start_pip", "sample_count"], ["metadata"], "clip");
124
+ const info = asset(c), clipId = identifier(c.id, "clip id");
125
+ if (ids.has(clipId) || paths.has(info.path.toLowerCase()))
126
+ throw Error("Duplicate clip ID or asset path");
127
+ ids.add(clipId);
128
+ paths.add(info.path.toLowerCase());
129
+ if (c.encoding !== "json" && c.encoding !== "gzip")
130
+ throw Error("Unsupported clip encoding");
131
+ const decoded = integer(c.decoded_byte_length, 1, MAX_JSON_BYTES, "decoded_byte_length");
132
+ if (c.encoding === "json" && decoded !== info.byte_length)
133
+ throw Error("JSON stored and decoded byte lengths must match");
134
+ const first = integer(c.start_pip, MIN_PIP, MAX_PIP, "start_pip");
135
+ const count = integer(c.sample_count, 1, Math.floor(MAX_VALUES / dims.length), "sample_count");
136
+ if (first + count > MAX_PIP)
137
+ throw Error("Recording extent exceeds signed int32 pip range");
138
+ return Object.freeze({ ...info, id: clipId, encoding: c.encoding, decoded_byte_length: decoded,
139
+ start_pip: first, sample_count: count, ...(c.metadata === undefined ? {} : { metadata: metadata(c.metadata) }) });
140
+ });
141
+ const title = string(v.title, "title", 1024);
142
+ if (!title.trim())
143
+ throw Error("Package title must not be blank");
144
+ return Object.freeze({ format: "dancerudiments.motion-package", schema_version: 1, id, version,
145
+ title, type_uri: uri, profile_sha256: fingerprint, dimensions: dims, profile,
146
+ clips: Object.freeze(clips), license: jsonObject(v.license, "license"), provenance: jsonObject(v.provenance, "provenance") });
147
+ }
148
+ function validateProfile(value, manifest) {
149
+ const v = object(value, "rig profile");
150
+ fields(v, ["format", "schema_version", "type_uri", "model", "version", "channels", "source"], [], "rig profile");
151
+ if (v.format !== "dancerudiments.rig-profile" || v.schema_version !== 1 || typeUri(v.type_uri) !== manifest.type_uri)
152
+ throw Error("Rig profile format/type mismatch");
153
+ if (!Array.isArray(v.channels) || v.channels.length !== manifest.dimensions.length)
154
+ throw Error("Rig profile dimensions mismatch");
155
+ const bindings = new Set(), modes = new Map(), groups = new Map();
156
+ const channels = v.channels.map((item, index) => {
157
+ const c = object(item, "channel");
158
+ fields(c, ["number", "name", "bone", "data_path", "array_index", "unit", "minimum", "maximum", "neutral"], ["value_type", "interpolation", "rotation_mode", "enum_items"], "channel");
159
+ integer(c.number, index + 1, index + 1, "channel number");
160
+ const name = identifier(c.name, "channel name"), bone = string(c.bone, "bone", 1024);
161
+ if (name !== manifest.dimensions[index])
162
+ throw Error("Profile channel order does not match dimensions");
163
+ const dataPath = string(c.data_path, "data_path", 2048), arrayIndex = integer(c.array_index, -1, 1024, "array_index");
164
+ const binding = JSON.stringify([bone, dataPath, arrayIndex]);
165
+ if (bindings.has(binding))
166
+ throw Error("Duplicate rig channel binding");
167
+ bindings.add(binding);
168
+ const minimum = number(c.minimum, "minimum"), maximum = number(c.maximum, "maximum"), neutral = number(c.neutral, "neutral");
169
+ if (minimum >= maximum || !Number.isFinite(maximum - minimum) || neutral < minimum || neutral > maximum)
170
+ throw Error("Invalid channel range/neutral");
171
+ const valueType = c.value_type === undefined ? "float" : c.value_type;
172
+ const interpolation = c.interpolation === undefined ? "linear" : c.interpolation;
173
+ const mode = c.rotation_mode === undefined ? "" : c.rotation_mode;
174
+ if (!["float", "integer", "boolean"].includes(valueType) || !["linear", "step", "quaternion"].includes(interpolation))
175
+ throw Error("Unsupported channel value/interpolation type");
176
+ if (valueType !== "float" && (interpolation !== "step" || ![minimum, maximum, neutral].every(Number.isSafeInteger) || maximum - minimum > 1e11))
177
+ throw Error("Invalid discrete channel range/interpolation");
178
+ if (valueType === "boolean" && (minimum !== 0 || maximum !== 1))
179
+ throw Error("Boolean channels require range [0,1]");
180
+ if (!["", "QUATERNION", "AXIS_ANGLE", "XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX"].includes(mode))
181
+ throw Error("Unsupported rotation mode");
182
+ if (mode) {
183
+ if (modes.has(bone) && modes.get(bone) !== mode)
184
+ throw Error("Inconsistent rotation modes for control bone");
185
+ modes.set(bone, mode);
186
+ }
187
+ const enumItems = c.enum_items === undefined ? [] : c.enum_items;
188
+ if (!Array.isArray(enumItems))
189
+ throw Error("Invalid enum_items");
190
+ const choices = new Set();
191
+ const enums = enumItems.map(item => {
192
+ if (!Array.isArray(item) || item.length !== 2 || valueType !== "integer")
193
+ throw Error("Invalid enum item");
194
+ const choice = integer(item[0], minimum, maximum, "enum value"), label = string(item[1], "enum label", 1024);
195
+ if (choices.has(choice))
196
+ throw Error("Duplicate enum choice");
197
+ choices.add(choice);
198
+ return Object.freeze([choice, label]);
199
+ });
200
+ if (choices.size && !choices.has(neutral))
201
+ throw Error("Enum neutral must be a declared choice");
202
+ if (dataPath === "rotation_quaternion" && interpolation !== "quaternion")
203
+ throw Error("Quaternion channels require grouped interpolation");
204
+ if (interpolation === "quaternion") {
205
+ if (dataPath !== "rotation_quaternion" || mode !== "QUATERNION" || arrayIndex < 0 || arrayIndex > 3 || minimum !== -1 || maximum !== 1)
206
+ throw Error("Invalid quaternion channel");
207
+ if (!groups.has(bone))
208
+ groups.set(bone, new Set());
209
+ groups.get(bone).add(arrayIndex);
210
+ }
211
+ return Object.freeze({ number: index + 1, name, bone, data_path: dataPath, array_index: arrayIndex,
212
+ unit: string(c.unit, "unit", 128), minimum, maximum, neutral, value_type: valueType,
213
+ interpolation, rotation_mode: mode, enum_items: Object.freeze(enums) });
214
+ });
215
+ for (const group of groups.values())
216
+ if (group.size !== 4)
217
+ throw Error("Quaternion controls require all four wxyz channels");
218
+ return Object.freeze({ format: "dancerudiments.rig-profile", schema_version: 1, type_uri: manifest.type_uri,
219
+ model: string(v.model, "model", 1024), version: integer(v.version, 1, Number.MAX_SAFE_INTEGER, "profile version"),
220
+ channels: Object.freeze(channels), source: jsonObject(v.source, "profile source") });
221
+ }
222
+ function decoder(profile) {
223
+ const groups = new Map();
224
+ const choices = profile.channels.map(channel => new Set(channel.enum_items.map(item => item[0])));
225
+ profile.channels.forEach((channel, index) => {
226
+ if (channel.interpolation === "quaternion") {
227
+ if (!groups.has(channel.bone))
228
+ groups.set(channel.bone, []);
229
+ groups.get(channel.bone).push(index);
230
+ }
231
+ });
232
+ return row => {
233
+ const result = profile.channels.map((channel, index) => {
234
+ const value = channel.minimum + (row[index] + 1) * 0.5 * (channel.maximum - channel.minimum);
235
+ if (channel.value_type === "float")
236
+ return value;
237
+ const nearest = Math.round(value), tolerance = Math.max(1e-9, (channel.maximum - channel.minimum) * 1e-12);
238
+ if (Math.abs(value - nearest) > tolerance || (choices[index].size && !choices[index].has(nearest)))
239
+ throw Error(`Invalid discrete value for channel ${channel.name}`);
240
+ return channel.value_type === "boolean" ? Boolean(nearest) : nearest;
241
+ });
242
+ for (const indices of groups.values()) {
243
+ const norm = Math.hypot(...indices.map(index => result[index]));
244
+ if (Math.abs(norm - 1) > 1e-5)
245
+ throw Error("Recorded quaternion must have unit length");
246
+ }
247
+ return Object.freeze(result);
248
+ };
249
+ }
250
+ function validateRecording(value, clip, manifest, profile) {
251
+ const v = object(value, "recording");
252
+ fields(v, ["format", "schema_version", "pips_per_beat", "sample_dtype", "sample_decimals", "name", "dimensions", "clock", "samples", "provenance"], [], "recording");
253
+ if (v.format !== "dancerudiments.recording" || v.schema_version !== 1 || v.pips_per_beat !== 64 || v.sample_dtype !== "float64" || v.sample_decimals !== 12)
254
+ throw Error("Unsupported recording format/precision/clock");
255
+ if (v.name !== clip.id)
256
+ throw Error("Recording name does not match clip ID");
257
+ const dims = dimensions(v.dimensions);
258
+ if (!sameDimensions(dims, manifest.dimensions))
259
+ throw Error("Recording dimensions/order do not match the rig profile");
260
+ const c = object(v.clock, "clock");
261
+ fields(c, ["bpm", "bpm_source", "bpm_status", "beat_zero_seconds", "phase_status", "start_pip"], [], "recording clock");
262
+ const bpm = number(c.bpm, "BPM"), source = string(c.bpm_source, "bpm_source");
263
+ if (bpm <= 0 || !Number.isFinite(60 / bpm) || 60 / bpm / 64 <= 0 || !source.trim())
264
+ throw Error("A positive representable BPM and provenance are required");
265
+ if (c.bpm_status !== "confirmed" && c.bpm_status !== "estimated")
266
+ throw Error("Invalid bpm_status");
267
+ if (c.phase_status !== "unverified" && c.phase_status !== "estimated" && c.phase_status !== "supplied")
268
+ throw Error("Invalid phase_status");
269
+ if (c.start_pip !== clip.start_pip)
270
+ throw Error("Recording start_pip does not match manifest");
271
+ const clock = Object.freeze({ bpm, bpm_source: source, bpm_status: c.bpm_status,
272
+ beat_zero_seconds: number(c.beat_zero_seconds, "beat_zero_seconds"), phase_status: c.phase_status, start_pip: clip.start_pip });
273
+ if (!Array.isArray(v.samples) || v.samples.length !== clip.sample_count)
274
+ throw Error("Recording sample_count does not match manifest");
275
+ const decode = decoder(profile);
276
+ for (const row of v.samples) {
277
+ if (!Array.isArray(row) || row.length !== dims.length)
278
+ throw Error("Ragged recording sample dimensions");
279
+ for (const value of row) {
280
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1 || Number(value.toFixed(12)) !== value)
281
+ throw Error("Samples must be finite normalized float64 values rounded to twelve decimals");
282
+ }
283
+ decode(row);
284
+ Object.freeze(row);
285
+ }
286
+ return Object.freeze({ format: "dancerudiments.recording", schema_version: 1, pips_per_beat: 64,
287
+ sample_dtype: "float64", sample_decimals: 12, name: clip.id, dimensions: dims, clock,
288
+ samples: Object.freeze(v.samples), provenance: jsonObject(v.provenance, "recording provenance") });
289
+ }
290
+ function abortError() { return new DOMException("Motion loading was aborted", "AbortError"); }
291
+ function checkAbort(signal) { if (signal.aborted)
292
+ throw abortError(); }
293
+ // JSON.parse discards duplicate keys. Scan object keys first so profile identity
294
+ // and clock declarations cannot be silently overwritten by a later field.
295
+ function parseJson(bytes) {
296
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
297
+ const stack = [];
298
+ for (let index = 0; index < text.length; index++) {
299
+ const char = text[index];
300
+ if (char === '"') {
301
+ const start = index++;
302
+ while (index < text.length && text[index] !== '"') {
303
+ if (text[index] === "\\")
304
+ index++;
305
+ index++;
306
+ }
307
+ if (index >= text.length)
308
+ throw Error("Unterminated JSON string");
309
+ const current = stack[stack.length - 1];
310
+ if (current?.key) {
311
+ const key = JSON.parse(text.slice(start, index + 1));
312
+ if (current.keys.has(key))
313
+ throw Error(`Duplicate JSON key: ${key}`);
314
+ current.keys.add(key);
315
+ current.key = false;
316
+ }
317
+ }
318
+ else if (char === "{" || char === "[") {
319
+ stack.push(char === "{" ? { keys: new Set(), key: true } : null);
320
+ if (stack.length > 128)
321
+ throw Error("JSON nesting limit exceeded");
322
+ }
323
+ else if (char === "}" || char === "]")
324
+ stack.pop();
325
+ else if (char === "," && stack[stack.length - 1])
326
+ stack[stack.length - 1].key = true;
327
+ }
328
+ return JSON.parse(text);
329
+ }
330
+ async function readBytes(stream, limit, signal) {
331
+ checkAbort(signal);
332
+ const reader = stream.getReader(), chunks = [];
333
+ let size = 0;
334
+ const cancel = () => { void reader.cancel(abortError()).catch(() => { }); };
335
+ signal.addEventListener("abort", cancel, { once: true });
336
+ try {
337
+ while (true) {
338
+ const next = await reader.read();
339
+ checkAbort(signal);
340
+ if (next.done)
341
+ break;
342
+ size += next.value.byteLength;
343
+ if (size > limit) {
344
+ await reader.cancel();
345
+ throw Error("Asset exceeds declared byte length/resource limit");
346
+ }
347
+ chunks.push(next.value);
348
+ }
349
+ const result = new Uint8Array(size);
350
+ let offset = 0;
351
+ for (const chunk of chunks) {
352
+ result.set(chunk, offset);
353
+ offset += chunk.byteLength;
354
+ }
355
+ return result;
356
+ }
357
+ finally {
358
+ signal.removeEventListener("abort", cancel);
359
+ reader.releaseLock();
360
+ }
361
+ }
362
+ async function digest(bytes) {
363
+ if (!globalThis.crypto?.subtle)
364
+ throw Error("SHA-256 verification requires Web Crypto in a secure context");
365
+ const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
366
+ return Array.from(new Uint8Array(hash), item => item.toString(16).padStart(2, "0")).join("");
367
+ }
368
+ async function fetchBytes(fetcher, url, limit, signal, expected) {
369
+ checkAbort(signal);
370
+ const response = await fetcher(url, { signal, credentials: "omit" });
371
+ if (signal.aborted)
372
+ await response.body?.cancel();
373
+ checkAbort(signal);
374
+ if (!response.ok) {
375
+ await response.body?.cancel();
376
+ throw Error(`Motion asset HTTP ${response.status}: ${url.pathname}`);
377
+ }
378
+ if (!response.body)
379
+ throw Error("Motion asset has no readable response body");
380
+ const length = response.headers.get("content-length");
381
+ if (length && /^\d+$/.test(length) && Number(length) > limit) {
382
+ await response.body.cancel();
383
+ throw Error("Motion asset Content-Length exceeds resource limit");
384
+ }
385
+ const bytes = await readBytes(response.body, limit, signal);
386
+ if (expected && bytes.byteLength !== expected.byte_length)
387
+ throw Error("Motion asset byte length mismatch");
388
+ if (expected && await digest(bytes) !== expected.sha256)
389
+ throw Error("Motion asset SHA-256 integrity mismatch");
390
+ checkAbort(signal);
391
+ return bytes;
392
+ }
393
+ function removeEntry(cache, key, drop) {
394
+ const entry = cache.get(key);
395
+ if (!entry)
396
+ return;
397
+ cache.delete(key);
398
+ entry.controller.abort();
399
+ if (entry.value !== undefined) {
400
+ const value = entry.value;
401
+ entry.value = undefined;
402
+ drop(value);
403
+ }
404
+ }
405
+ function acquire(cache, key, signal, work, drop) {
406
+ if (signal?.aborted)
407
+ return Promise.reject(abortError());
408
+ let entry = cache.get(key);
409
+ if (!entry) {
410
+ const created = { controller: new AbortController(), promise: undefined, waiters: 0, settled: false };
411
+ created.promise = Promise.resolve().then(() => work(created.controller.signal)).then(value => {
412
+ if (cache.get(key) !== created || created.controller.signal.aborted) {
413
+ drop(value);
414
+ throw abortError();
415
+ }
416
+ created.value = value;
417
+ created.settled = true;
418
+ return value;
419
+ }, error => {
420
+ if (cache.get(key) === created)
421
+ cache.delete(key);
422
+ created.settled = true;
423
+ throw error;
424
+ });
425
+ cache.set(key, created);
426
+ entry = created;
427
+ }
428
+ const current = entry;
429
+ current.waiters++;
430
+ return new Promise((resolve, reject) => {
431
+ let finished = false;
432
+ const finish = (success, result) => {
433
+ if (finished)
434
+ return;
435
+ finished = true;
436
+ signal?.removeEventListener("abort", cancelled);
437
+ current.controller.signal.removeEventListener("abort", cancelled);
438
+ current.waiters--;
439
+ if (success)
440
+ resolve(result);
441
+ else
442
+ reject(result);
443
+ if (!current.waiters && !current.settled && cache.get(key) === current)
444
+ removeEntry(cache, key, drop);
445
+ };
446
+ const cancelled = () => finish(false, abortError());
447
+ signal?.addEventListener("abort", cancelled, { once: true });
448
+ current.controller.signal.addEventListener("abort", cancelled, { once: true });
449
+ if (signal?.aborted || current.controller.signal.aborted)
450
+ cancelled();
451
+ current.promise.then(value => finish(true, value), error => finish(false, error));
452
+ });
453
+ }
454
+ /** Construct an inert loader owned by one page/scene. No global motion registry. */
455
+ export function createMotionPackageLoader(options) {
456
+ const packageId = identifier(options.packageId, "packageId");
457
+ const target = Object.freeze({ typeUri: typeUri(options.expectedProfile.typeUri), sha256: sha(options.expectedProfile.sha256, "expected profile"), dimensions: dimensions(options.expectedProfile.dimensions) });
458
+ const url = new URL(options.manifestUrl, typeof location === "undefined" ? undefined : location.href);
459
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.hash)
460
+ throw Error("Manifest URL must be an HTTP(S) URL without credentials/fragment");
461
+ if (typeof options.nativeFactory !== "function")
462
+ throw Error("A native recording factory is required");
463
+ const nativeFactory = options.nativeFactory;
464
+ const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
465
+ if (!fetcher)
466
+ throw Error("A fetch implementation is required");
467
+ const manifestHash = options.manifestSha256 === undefined ? undefined : sha(options.manifestSha256, "manifest");
468
+ const manifests = new Map();
469
+ const profiles = new Map();
470
+ const clips = new Map();
471
+ const noDrop = () => { };
472
+ const dropClip = (value) => value.dispose();
473
+ let disposed = false;
474
+ const active = () => { if (disposed)
475
+ throw Error("Motion package loader is disposed"); };
476
+ const getManifest = (signal) => acquire(manifests, "manifest", signal, async (current) => {
477
+ const bytes = await fetchBytes(fetcher, url, MAX_MANIFEST_BYTES, current);
478
+ if (manifestHash && await digest(bytes) !== manifestHash)
479
+ throw Error("Manifest SHA-256 integrity mismatch");
480
+ checkAbort(current);
481
+ return validateManifest(parseJson(bytes), packageId, target);
482
+ }, noDrop);
483
+ const getProfile = (manifest, signal) => acquire(profiles, "profile", signal, async (current) => {
484
+ const bytes = await fetchBytes(fetcher, new URL(manifest.profile.path, url), manifest.profile.byte_length, current, manifest.profile);
485
+ if (bytes.some(value => value > 127))
486
+ throw Error("Canonical rig profile must use ASCII JSON bytes");
487
+ return validateProfile(parseJson(bytes), manifest);
488
+ }, noDrop);
489
+ return Object.freeze({
490
+ async catalogue(request = {}) {
491
+ active();
492
+ return getManifest(request.signal);
493
+ },
494
+ async load(id, request = {}) {
495
+ active();
496
+ identifier(id, "clip id");
497
+ const loaded = await acquire(clips, id, request.signal, async (signal) => {
498
+ const manifest = await getManifest(signal);
499
+ const clip = manifest.clips.find(item => item.id === id);
500
+ if (!clip)
501
+ throw Error(`Unknown motion clip: ${id}`);
502
+ const profile = await getProfile(manifest, signal);
503
+ let bytes = await fetchBytes(fetcher, new URL(clip.path, url), clip.byte_length, signal, clip);
504
+ if (clip.encoding === "gzip") {
505
+ if (typeof DecompressionStream === "undefined")
506
+ throw Error("Gzip clips require DecompressionStream support");
507
+ const stored = bytes;
508
+ const stream = new ReadableStream({ start(controller) { controller.enqueue(stored); controller.close(); } });
509
+ bytes = await readBytes(stream.pipeThrough(new DecompressionStream("gzip")), clip.decoded_byte_length, signal);
510
+ }
511
+ if (bytes.byteLength !== clip.decoded_byte_length)
512
+ throw Error("Decoded motion byte length mismatch");
513
+ const recording = validateRecording(parseJson(bytes), clip, manifest, profile);
514
+ checkAbort(signal);
515
+ const native = await nativeFactory(recording, { signal });
516
+ if (!native || typeof native.sample !== "function" || typeof native.dispose !== "function") {
517
+ if (native && typeof native.dispose === "function")
518
+ native.dispose();
519
+ throw Error("Native recording factory returned an invalid handle");
520
+ }
521
+ if (signal.aborted) {
522
+ native.dispose();
523
+ throw abortError();
524
+ }
525
+ const count = clip.sample_count, first = clip.start_pip, clock = recording.clock;
526
+ const decode = decoder(profile);
527
+ let live = native;
528
+ const sampleNormalized = (pip) => {
529
+ if (!live)
530
+ throw Error("Rig motion was unloaded/disposed");
531
+ integer(pip, MIN_PIP, MAX_PIP, "pip");
532
+ if (pip < first || pip - first >= count)
533
+ throw Error("Pip outside non-looping rig motion");
534
+ const row = live.sample(pip);
535
+ if (row.length !== manifest.dimensions.length || Array.from(row).some(value => !Number.isFinite(value) || Math.abs(value) > 1))
536
+ throw Error("Native recording returned invalid dimensions/values");
537
+ return Object.freeze(Array.from(row));
538
+ };
539
+ const handle = Object.freeze({ id, profile, profileSha256: manifest.profile_sha256,
540
+ dimensions: manifest.dimensions, clock, startPip: first, sampleCount: count,
541
+ metadata: clip.metadata ?? Object.freeze({}), get disposed() { return !live; }, sampleNormalized,
542
+ sample: (pip) => decode(sampleNormalized(pip)) });
543
+ return { handle, dispose() { const previous = live; live = undefined; previous?.dispose(); } };
544
+ }, dropClip);
545
+ return loaded.handle;
546
+ },
547
+ unload(id) { identifier(id, "clip id"); removeEntry(clips, id, dropClip); },
548
+ dispose() {
549
+ if (disposed)
550
+ return;
551
+ disposed = true;
552
+ let failed = false, failure;
553
+ for (const key of [...clips.keys()]) {
554
+ try {
555
+ removeEntry(clips, key, dropClip);
556
+ }
557
+ catch (error) {
558
+ if (!failed)
559
+ failure = error;
560
+ failed = true;
561
+ }
562
+ }
563
+ for (const key of [...profiles.keys()])
564
+ removeEntry(profiles, key, noDrop);
565
+ for (const key of [...manifests.keys()])
566
+ removeEntry(manifests, key, noDrop);
567
+ if (failed)
568
+ throw failure;
569
+ },
570
+ });
571
+ }
572
+ /** Use the existing recording_wasm.cpp runtime, supplied explicitly by the app.
573
+ * Each clip owns an isolated WASM memory, released when its handle is disposed.
574
+ * Compilation/fetching of the module is an explicit caller operation.
575
+ */
576
+ export function createRecordingWasmAdapter(module) {
577
+ if (!(module instanceof WebAssembly.Module))
578
+ throw Error("A compiled recording WebAssembly.Module is required");
579
+ return async (recording, { signal }) => {
580
+ checkAbort(signal);
581
+ const instance = await WebAssembly.instantiate(module, {});
582
+ checkAbort(signal);
583
+ const exports = instance.exports;
584
+ if (!(exports.memory instanceof WebAssembly.Memory) || typeof exports.table_base !== "function" || typeof exports.sample_recording !== "function")
585
+ throw Error("WASM module lacks the native recording sampler exports");
586
+ let api = exports;
587
+ const width = recording.dimensions.length, count = recording.samples.length, first = recording.clock.start_pip;
588
+ integer(width, 1, MAX_VALUES, "native dimensions");
589
+ integer(count, 1, Math.floor(MAX_VALUES / width), "native sample_count");
590
+ integer(first, MIN_PIP, MAX_PIP - count, "native start_pip");
591
+ const base = integer(api.table_base(), 0, MAX_JSON_BYTES, "native table base");
592
+ if (base % 8)
593
+ throw Error("Native recording table is not float64-aligned");
594
+ const length = count * width * 8, pages = Math.ceil((base + length - api.memory.buffer.byteLength) / 65536);
595
+ if (pages > 0)
596
+ api.memory.grow(pages);
597
+ // WASM memory is little endian, regardless of the host's typed-array order.
598
+ const view = new DataView(api.memory.buffer, base, length);
599
+ let offset = 0;
600
+ for (const row of recording.samples) {
601
+ if (row.length !== width)
602
+ throw Error("Ragged native recording");
603
+ for (const value of row) {
604
+ if (!Number.isFinite(value) || Math.abs(value) > 1)
605
+ throw Error("Invalid native recording value");
606
+ view.setFloat64(offset, value, true);
607
+ offset += 8;
608
+ }
609
+ }
610
+ return Object.freeze({
611
+ sample(pip) {
612
+ if (!api)
613
+ throw Error("Native recording is disposed");
614
+ integer(pip, MIN_PIP, MAX_PIP, "pip");
615
+ if (pip < first || pip - first >= count)
616
+ throw Error("Pip outside non-looping native recording");
617
+ const row = Array.from({ length: width }, (_, axis) => api.sample_recording(base, count, width, first, pip, axis));
618
+ if (row.some(value => !Number.isFinite(value) || Math.abs(value) > 1))
619
+ throw Error("Native recording sampler returned invalid values");
620
+ return Object.freeze(row);
621
+ },
622
+ dispose() { api = undefined; },
623
+ });
624
+ };
625
+ }