@machinal/scenes 0.0.0-stage → 0.1.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -0
  3. package/LICENSING.md +13 -0
  4. package/NOTICE +8 -0
  5. package/README.md +183 -2
  6. package/bin/machinal-scenes.mjs +89 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +2 -0
  9. package/dist/runtime/artifact.d.ts +18 -0
  10. package/dist/runtime/artifact.js +26 -0
  11. package/dist/runtime/bridge.d.ts +8 -0
  12. package/dist/runtime/bridge.js +76 -0
  13. package/dist/runtime/choreography.d.ts +32 -0
  14. package/dist/runtime/choreography.js +81 -0
  15. package/dist/runtime/controller.d.ts +5 -0
  16. package/dist/runtime/controller.js +93 -0
  17. package/dist/runtime/geometry.d.ts +20 -0
  18. package/dist/runtime/geometry.js +34 -0
  19. package/dist/runtime/index.d.ts +14 -0
  20. package/dist/runtime/index.js +14 -0
  21. package/dist/runtime/layout.d.ts +17 -0
  22. package/dist/runtime/layout.js +48 -0
  23. package/dist/runtime/math.d.ts +4 -0
  24. package/dist/runtime/math.js +23 -0
  25. package/dist/runtime/media.d.ts +2 -0
  26. package/dist/runtime/media.js +69 -0
  27. package/dist/runtime/models.d.ts +14 -0
  28. package/dist/runtime/models.js +370 -0
  29. package/dist/runtime/objects.d.ts +8 -0
  30. package/dist/runtime/objects.js +24 -0
  31. package/dist/runtime/parameters.d.ts +17 -0
  32. package/dist/runtime/parameters.js +35 -0
  33. package/dist/runtime/resources.d.ts +5 -0
  34. package/dist/runtime/resources.js +63 -0
  35. package/dist/runtime/types.d.ts +76 -0
  36. package/dist/runtime/types.js +1 -0
  37. package/dist/runtime/validation.d.ts +4 -0
  38. package/dist/runtime/validation.js +37 -0
  39. package/dist/runtime/video.d.ts +90 -0
  40. package/dist/runtime/video.js +46 -0
  41. package/dist/svelte/SceneCanvas.svelte +15 -0
  42. package/dist/svelte/SceneCanvas.svelte.d.ts +8 -0
  43. package/dist/svelte/SceneThree.svelte +61 -0
  44. package/dist/svelte/SceneThree.svelte.d.ts +10 -0
  45. package/dist/svelte/SceneVideo.svelte +38 -0
  46. package/dist/svelte/SceneVideo.svelte.d.ts +18 -0
  47. package/dist/svelte/context.d.ts +12 -0
  48. package/dist/svelte/context.js +8 -0
  49. package/dist/svelte/index.d.ts +9 -0
  50. package/dist/svelte/index.js +8 -0
  51. package/dist/svelte/model-assets.d.ts +4 -0
  52. package/dist/svelte/model-assets.js +42 -0
  53. package/dist/svelte/mount.svelte.d.ts +3 -0
  54. package/dist/svelte/mount.svelte.js +42 -0
  55. package/dist/svelte/three-rigs.d.ts +20 -0
  56. package/dist/svelte/three-rigs.js +22 -0
  57. package/dist/svelte/three-session.d.ts +6 -0
  58. package/dist/svelte/three-session.js +66 -0
  59. package/dist/svelte/three.d.ts +15 -0
  60. package/dist/svelte/three.js +33 -0
  61. package/examples/quickstart/gitignore +6 -0
  62. package/examples/quickstart/index.html +48 -0
  63. package/examples/quickstart/package.json +27 -0
  64. package/examples/quickstart/scene.config.json +11 -0
  65. package/examples/quickstart/scene.html +13 -0
  66. package/examples/quickstart/src/ExportPanel.svelte +74 -0
  67. package/examples/quickstart/src/Title.svelte +22 -0
  68. package/examples/quickstart/src/export-api.ts +30 -0
  69. package/examples/quickstart/src/main.ts +114 -0
  70. package/examples/quickstart/src/playback.ts +6 -0
  71. package/examples/quickstart/src/request.ts +5 -0
  72. package/examples/quickstart/src/scene.ts +12 -0
  73. package/examples/quickstart/src/useLocalExport.svelte.ts +46 -0
  74. package/examples/quickstart/src/workspace.css +79 -0
  75. package/examples/quickstart/svelte.config.js +1 -0
  76. package/examples/quickstart/tools/local-export.mjs +120 -0
  77. package/examples/quickstart/tsconfig.json +15 -0
  78. package/examples/quickstart/vite.config.js +17 -0
  79. package/native/THIRD_PARTY_NOTICES.txt +158 -0
  80. package/native/darwin-arm64/scene-render +0 -0
  81. package/native/darwin-x64/scene-render +0 -0
  82. package/native/linux-x64/scene-render +0 -0
  83. package/native/manifest.json +33 -0
  84. package/native/win32-x64/scene-render.exe +0 -0
  85. package/node/cli-output.mjs +10 -0
  86. package/node/download.mjs +37 -0
  87. package/node/ffmpeg-releases.mjs +24 -0
  88. package/node/index.d.ts +50 -0
  89. package/node/index.mjs +2 -0
  90. package/node/platforms.mjs +14 -0
  91. package/node/render.mjs +59 -0
  92. package/node/toolchain.mjs +118 -0
  93. package/package.json +89 -4
  94. package/renderer/browser.go +116 -0
  95. package/renderer/browser_log.go +36 -0
  96. package/renderer/browser_log_test.go +48 -0
  97. package/renderer/browser_test.go +307 -0
  98. package/renderer/cmd/scene-render/main.go +128 -0
  99. package/renderer/cmd/scene-render/main_test.go +37 -0
  100. package/renderer/cmd/scene-render/server.go +42 -0
  101. package/renderer/encoder.go +116 -0
  102. package/renderer/encoder_test.go +22 -0
  103. package/renderer/go.mod +11 -0
  104. package/renderer/go.sum +10 -0
  105. package/renderer/network.go +50 -0
  106. package/renderer/output.go +93 -0
  107. package/renderer/pipeline.go +108 -0
  108. package/renderer/pipeline_test.go +108 -0
  109. package/renderer/render.go +141 -0
  110. package/renderer/request.go +140 -0
  111. package/renderer/request_test.go +94 -0
  112. package/renderer/types.go +113 -0
  113. package/renderer/worker.go +110 -0
@@ -0,0 +1,370 @@
1
+ export const MODEL_DATA_PREFIX = 'data:model/gltf-binary;base64,';
2
+ export const MAX_MODEL_BYTES = 4 * 1024 * 1024;
3
+ export const MAX_MODEL_PIXELS = 4_000_000;
4
+ const unsupportedFeatures = ['extensions', 'extensionsUsed', 'extensionsRequired', 'animations', 'skins', 'targets', 'sparse'];
5
+ function record(value, label) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value))
7
+ throw new Error(`Invalid GLB ${label}`);
8
+ return value;
9
+ }
10
+ function integer(value, min, max, label) {
11
+ if (!Number.isSafeInteger(value) || value < min || value > max)
12
+ throw new Error(`Invalid GLB ${label}`);
13
+ return value;
14
+ }
15
+ function list(value, max, label) {
16
+ if (value === undefined)
17
+ return [];
18
+ if (!Array.isArray(value) || value.length > max)
19
+ throw new Error(`GLB ${label} exceeds supported limits`);
20
+ return value;
21
+ }
22
+ function noExternalResources(value, depth = 0) {
23
+ if (depth > 32)
24
+ throw new Error('GLB structure is too deeply nested');
25
+ if (!value || typeof value !== 'object')
26
+ return;
27
+ if (Array.isArray(value) && value.length > 512)
28
+ throw new Error('GLB metadata array exceeds limits');
29
+ for (const [key, child] of Object.entries(value)) {
30
+ if (key === 'uri')
31
+ throw new Error('GLB must embed all resources; URI references are unsupported');
32
+ if (['__proto__', 'prototype', 'constructor'].includes(key))
33
+ throw new Error('Unsupported GLB metadata key');
34
+ if (unsupportedFeatures.includes(key) && child !== undefined && child !== null &&
35
+ (typeof child !== 'object' || Object.keys(child).length > 0))
36
+ throw new Error(`Unsupported GLB feature: ${key}`);
37
+ noExternalResources(child, depth + 1);
38
+ }
39
+ }
40
+ export function decodeModelData(source) {
41
+ if (typeof source !== 'string' || !source.startsWith(MODEL_DATA_PREFIX) || source.length > MODEL_DATA_PREFIX.length + Math.ceil(MAX_MODEL_BYTES / 3) * 4)
42
+ throw new Error('Use an approved embedded GLB of at most 4 MiB');
43
+ const encoded = source.slice(MODEL_DATA_PREFIX.length);
44
+ if (encoded.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))
45
+ throw new Error('Invalid GLB base64 encoding');
46
+ const decoded = atob(encoded);
47
+ if (decoded.length === 0 || decoded.length > MAX_MODEL_BYTES)
48
+ throw new Error('GLB exceeds its byte limit');
49
+ return Uint8Array.from(decoded, character => character.charCodeAt(0));
50
+ }
51
+ export function pngDimensions(bytes) {
52
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
53
+ if (bytes.length < 33 || !signature.every((value, i) => bytes[i] === value) || String.fromCharCode(...bytes.subarray(12, 16)) !== 'IHDR')
54
+ throw new Error('Model textures must be embedded PNG images');
55
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56
+ const width = view.getUint32(16), height = view.getUint32(20);
57
+ if (!width || !height || width * height > MAX_MODEL_PIXELS)
58
+ throw new Error('Model texture exceeds 4 million pixels');
59
+ if (view.getUint32(8) !== 13)
60
+ throw new Error('Invalid PNG header');
61
+ let imageData = false, ended = false, chunks = 0;
62
+ for (let offset = 8; offset < bytes.length;) {
63
+ if (++chunks > 1024 || offset + 12 > bytes.length)
64
+ throw new Error('Invalid PNG chunk limits');
65
+ const length = view.getUint32(offset);
66
+ if (length > bytes.length - offset - 12)
67
+ throw new Error('Invalid PNG chunk size');
68
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
69
+ if (['acTL', 'fcTL', 'fdAT'].includes(type))
70
+ throw new Error('Animated PNG textures are unsupported');
71
+ if (type === 'IHDR' && offset !== 8)
72
+ throw new Error('Duplicate PNG header');
73
+ if (type === 'IDAT')
74
+ imageData = true;
75
+ offset += length + 12;
76
+ if (type === 'IEND') {
77
+ if (length !== 0 || offset !== bytes.length)
78
+ throw new Error('Invalid PNG end');
79
+ ended = true;
80
+ }
81
+ }
82
+ if (!imageData || !ended)
83
+ throw new Error('PNG texture needs complete image data');
84
+ return { width, height };
85
+ }
86
+ function finiteVector(value, length, label) {
87
+ if (value === undefined)
88
+ return;
89
+ if (!Array.isArray(value) || value.length !== length || value.some(item => typeof item !== 'number' || !Number.isFinite(item) || Math.abs(item) > 1e6))
90
+ throw new Error(`Invalid GLB ${label}`);
91
+ }
92
+ function scalar(value, min, max, label) {
93
+ if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max))
94
+ throw new Error(`Invalid GLB ${label}`);
95
+ }
96
+ function boolean(value, label) {
97
+ if (value !== undefined && typeof value !== 'boolean')
98
+ throw new Error(`Invalid GLB ${label}`);
99
+ }
100
+ function choice(value, choices, label) {
101
+ if (value !== undefined && !choices.includes(value))
102
+ throw new Error(`Unsupported GLB ${label}`);
103
+ }
104
+ function color(value, length, label) {
105
+ finiteVector(value, length, label);
106
+ if (value !== undefined)
107
+ value.forEach(component => scalar(component, 0, 1, label));
108
+ }
109
+ function validateMaterials(doc, imageCount) {
110
+ const samplers = list(doc.samplers, 16, 'samplers').map(item => record(item, 'sampler'));
111
+ for (const sampler of samplers) {
112
+ choice(sampler.magFilter, [9728, 9729], 'magnification filter');
113
+ choice(sampler.minFilter, [9728, 9729, 9984, 9985, 9986, 9987], 'minification filter');
114
+ choice(sampler.wrapS, [33071, 33648, 10497], 'sampler wrap');
115
+ choice(sampler.wrapT, [33071, 33648, 10497], 'sampler wrap');
116
+ }
117
+ const textures = list(doc.textures, 8, 'textures').map(item => record(item, 'texture'));
118
+ for (const texture of textures) {
119
+ integer(texture.source, 0, imageCount - 1, 'texture source');
120
+ if (texture.sampler !== undefined)
121
+ integer(texture.sampler, 0, samplers.length - 1, 'texture sampler');
122
+ }
123
+ const textureInfo = (value) => {
124
+ if (value === undefined)
125
+ return;
126
+ const info = record(value, 'texture info');
127
+ integer(info.index, 0, textures.length - 1, 'material texture');
128
+ if (info.texCoord !== undefined)
129
+ integer(info.texCoord, 0, 1, 'texture coordinates');
130
+ };
131
+ const materials = list(doc.materials, 64, 'materials').map(item => record(item, 'material'));
132
+ for (const material of materials) {
133
+ const pbr = material.pbrMetallicRoughness === undefined ? {} : record(material.pbrMetallicRoughness, 'PBR material');
134
+ color(pbr.baseColorFactor, 4, 'base color');
135
+ scalar(pbr.metallicFactor, 0, 1, 'metallic factor');
136
+ scalar(pbr.roughnessFactor, 0, 1, 'roughness factor');
137
+ color(material.emissiveFactor, 3, 'emissive color');
138
+ choice(material.alphaMode, ['OPAQUE', 'MASK', 'BLEND'], 'alpha mode');
139
+ scalar(material.alphaCutoff, 0, 1, 'alpha cutoff');
140
+ boolean(material.doubleSided, 'double sided');
141
+ for (const info of [pbr.baseColorTexture, pbr.metallicRoughnessTexture, material.normalTexture, material.occlusionTexture, material.emissiveTexture])
142
+ textureInfo(info);
143
+ if (material.normalTexture !== undefined)
144
+ scalar(material.normalTexture.scale, -100, 100, 'normal scale');
145
+ if (material.occlusionTexture !== undefined)
146
+ scalar(material.occlusionTexture.strength, 0, 1, 'occlusion strength');
147
+ }
148
+ return { materials, textures };
149
+ }
150
+ export function validateGLB(bytes) {
151
+ if (bytes.byteLength < 28 || bytes.byteLength > MAX_MODEL_BYTES)
152
+ throw new Error('Invalid GLB length');
153
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
154
+ if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.length)
155
+ throw new Error('Use a valid GLB version 2 container');
156
+ const jsonLength = view.getUint32(12, true);
157
+ const binaryHeader = 20 + jsonLength;
158
+ if (!jsonLength || jsonLength > 256 * 1024 || jsonLength % 4 || view.getUint32(16, true) !== 0x4e4f534a || binaryHeader + 8 > bytes.length)
159
+ throw new Error('Invalid GLB JSON chunk');
160
+ const binaryLength = view.getUint32(binaryHeader, true);
161
+ if (binaryLength % 4 || view.getUint32(binaryHeader + 4, true) !== 0x004e4942 || binaryHeader + 8 + binaryLength !== bytes.length)
162
+ throw new Error('GLB needs exactly one embedded binary chunk');
163
+ const doc = record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(20, binaryHeader))), 'document');
164
+ noExternalResources(doc);
165
+ if (record(doc.asset, 'asset').version !== '2.0')
166
+ throw new Error('Unsupported GLB asset version');
167
+ const buffers = list(doc.buffers, 1, 'buffers');
168
+ if (buffers.length !== 1)
169
+ throw new Error('GLB needs one embedded buffer');
170
+ const bufferLength = integer(record(buffers[0], 'buffer').byteLength, 1, binaryLength, 'buffer size');
171
+ if (binaryLength - bufferLength > 3)
172
+ throw new Error('Invalid GLB buffer padding');
173
+ const views = list(doc.bufferViews, 256, 'buffer views').map(item => {
174
+ const entry = record(item, 'buffer view');
175
+ if (entry.buffer !== 0)
176
+ throw new Error('Invalid GLB buffer reference');
177
+ const offset = integer(entry.byteOffset ?? 0, 0, bufferLength, 'view offset');
178
+ const length = integer(entry.byteLength, 1, bufferLength - offset, 'view length');
179
+ const stride = entry.byteStride === undefined ? undefined : integer(entry.byteStride, 4, 252, 'view stride');
180
+ if (stride && stride % 4)
181
+ throw new Error('Invalid GLB byte stride');
182
+ choice(entry.target, [34962, 34963], 'buffer target');
183
+ return { offset, length, stride };
184
+ });
185
+ let accessorValues = 0;
186
+ const binaryStart = binaryHeader + 8;
187
+ const accessors = list(doc.accessors, 256, 'accessors').map(item => {
188
+ const entry = record(item, 'accessor');
189
+ const source = views[integer(entry.bufferView, 0, views.length - 1, 'accessor view')];
190
+ const sizes = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
191
+ const formats = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
192
+ const size = sizes[integer(entry.componentType, 5120, 5126, 'component type')];
193
+ const components = typeof entry.type === 'string' && Object.hasOwn(formats, entry.type) ? formats[entry.type] : 0;
194
+ if (!size || !components)
195
+ throw new Error('Unsupported GLB accessor format');
196
+ boolean(entry.normalized, 'normalized accessor');
197
+ if (entry.normalized === true && [5125, 5126].includes(entry.componentType))
198
+ throw new Error('GLB float/uint32 accessors cannot be normalized');
199
+ const count = integer(entry.count, 1, 100000, 'accessor count');
200
+ accessorValues += count * components;
201
+ if (accessorValues > 1_000_000)
202
+ throw new Error('GLB accessor budget exceeded');
203
+ const offset = integer(entry.byteOffset ?? 0, 0, source.length, 'accessor offset');
204
+ const stride = source.stride ?? size * components;
205
+ if (stride < size * components || offset % size || source.offset % size || offset + (count - 1) * stride + size * components > source.length)
206
+ throw new Error('GLB accessor exceeds its buffer view');
207
+ finiteVector(entry.min, components, 'accessor minimum');
208
+ finiteVector(entry.max, components, 'accessor maximum');
209
+ if (entry.min && entry.max && entry.min.some((min, i) => min > entry.max[i]))
210
+ throw new Error('Invalid GLB accessor bounds');
211
+ if (entry.componentType === 5126) {
212
+ for (let i = 0; i < count; i++)
213
+ for (let component = 0; component < components; component++) {
214
+ const value = view.getFloat32(binaryStart + source.offset + offset + i * stride + component * size, true);
215
+ if (!Number.isFinite(value) || Math.abs(value) > 1e6)
216
+ throw new Error('GLB contains invalid floating-point vertex data');
217
+ }
218
+ }
219
+ return { type: entry.type, componentType: entry.componentType, count,
220
+ min: entry.min, max: entry.max,
221
+ normalized: entry.normalized,
222
+ offset: binaryStart + source.offset + offset, stride, sourceStride: source.stride };
223
+ });
224
+ const images = list(doc.images, 8, 'images');
225
+ let pixels = 0;
226
+ for (const item of images) {
227
+ const image = record(item, 'image');
228
+ if (image.mimeType !== 'image/png')
229
+ throw new Error('GLB supports embedded PNG textures only');
230
+ const source = views[integer(image.bufferView, 0, views.length - 1, 'image view')];
231
+ if (source.stride !== undefined)
232
+ throw new Error('GLB image views cannot be interleaved');
233
+ const dimensions = pngDimensions(bytes.subarray(binaryHeader + 8 + source.offset, binaryHeader + 8 + source.offset + source.length));
234
+ pixels += dimensions.width * dimensions.height;
235
+ }
236
+ if (pixels > MAX_MODEL_PIXELS * 2)
237
+ throw new Error('GLB total texture pixel budget exceeded');
238
+ const { materials, textures } = validateMaterials(doc, images.length);
239
+ const meshes = list(doc.meshes, 64, 'meshes');
240
+ let primitives = 0;
241
+ const meshCosts = meshes.map(item => {
242
+ const mesh = record(item, 'mesh');
243
+ if (mesh.weights !== undefined)
244
+ throw new Error('GLB morph weights are unsupported');
245
+ const entries = list(mesh.primitives, 64, 'primitives');
246
+ if (!entries.length)
247
+ throw new Error('GLB mesh needs triangle primitives');
248
+ let vertices = 0;
249
+ for (const item of entries) {
250
+ const primitive = record(item, 'primitive');
251
+ primitives++;
252
+ if (primitives > 128)
253
+ throw new Error('GLB primitive budget exceeded');
254
+ if ((primitive.mode ?? 4) !== 4)
255
+ throw new Error('GLB supports triangle primitives only');
256
+ const attributes = record(primitive.attributes, 'attributes');
257
+ if (attributes.POSITION === undefined)
258
+ throw new Error('GLB mesh needs positions');
259
+ const position = accessors[integer(attributes.POSITION, 0, accessors.length - 1, 'position accessor')];
260
+ if (position.type !== 'VEC3' || position.componentType !== 5126 || !position.min || !position.max)
261
+ throw new Error('GLB positions need float VEC3 data and bounds');
262
+ for (const [name, index] of Object.entries(attributes)) {
263
+ if (!['POSITION', 'NORMAL', 'TEXCOORD_0', 'TEXCOORD_1', 'COLOR_0', 'TANGENT'].includes(name))
264
+ throw new Error('Unsupported GLB mesh attribute');
265
+ const accessor = accessors[integer(index, 0, accessors.length - 1, 'mesh accessor')];
266
+ const format = name.startsWith('TEXCOORD_') ? ['VEC2'] : name === 'TANGENT' ? ['VEC4'] : name === 'COLOR_0' ? ['VEC3', 'VEC4'] : ['VEC3'];
267
+ const integerAttribute = name.startsWith('TEXCOORD_') || name === 'COLOR_0';
268
+ if (!format.includes(accessor.type) || accessor.count !== position.count ||
269
+ accessor.componentType !== 5126 && !(integerAttribute && [5121, 5123].includes(accessor.componentType) && accessor.normalized === true))
270
+ throw new Error('Unsupported GLB vertex attribute format/count');
271
+ }
272
+ let count = position.count;
273
+ if (primitive.indices !== undefined) {
274
+ const indices = accessors[integer(primitive.indices, 0, accessors.length - 1, 'indices')];
275
+ if (indices.type !== 'SCALAR' || ![5121, 5123, 5125].includes(indices.componentType) || indices.normalized || indices.sourceStride !== undefined)
276
+ throw new Error('GLB indices need packed unsigned scalars');
277
+ const restart = indices.componentType === 5121 ? 255 : indices.componentType === 5123 ? 65535 : 4294967295;
278
+ for (let i = 0; i < indices.count; i++) {
279
+ const offset = indices.offset + i * indices.stride;
280
+ const value = indices.componentType === 5121 ? view.getUint8(offset) : indices.componentType === 5123 ? view.getUint16(offset, true) : view.getUint32(offset, true);
281
+ if (value >= position.count || value === restart)
282
+ throw new Error('GLB index exceeds vertex bounds');
283
+ }
284
+ count = indices.count;
285
+ }
286
+ if (count % 3)
287
+ throw new Error('GLB triangle vertex/index count must be divisible by three');
288
+ vertices += count;
289
+ if (primitive.material !== undefined) {
290
+ const material = materials[integer(primitive.material, 0, materials.length - 1, 'material')];
291
+ const pbr = material.pbrMetallicRoughness ?? {};
292
+ for (const info of [pbr.baseColorTexture, pbr.metallicRoughnessTexture, material.normalTexture, material.occlusionTexture, material.emissiveTexture]) {
293
+ if (info && attributes[`TEXCOORD_${info.texCoord ?? 0}`] === undefined)
294
+ throw new Error('GLB material references missing texture coordinates');
295
+ }
296
+ }
297
+ }
298
+ return { vertices, primitives: entries.length };
299
+ });
300
+ if (!primitives || primitives > 128)
301
+ throw new Error('GLB primitive budget exceeded');
302
+ const nodes = list(doc.nodes, 128, 'nodes');
303
+ const parents = new Set();
304
+ let renderedVertices = 0, renderedPrimitives = 0;
305
+ const edges = nodes.map(item => {
306
+ const node = record(item, 'node');
307
+ if (node.skin !== undefined || node.weights !== undefined || node.camera !== undefined)
308
+ throw new Error('GLB skins, morphs and cameras are unsupported');
309
+ if (node.mesh !== undefined) {
310
+ const cost = meshCosts[integer(node.mesh, 0, meshes.length - 1, 'node mesh')];
311
+ renderedVertices += cost.vertices;
312
+ renderedPrimitives += cost.primitives;
313
+ }
314
+ if (renderedVertices > 500_000 || renderedPrimitives > 256)
315
+ throw new Error('GLB expanded instance geometry budget exceeded');
316
+ for (const [key, length] of Object.entries({ translation: 3, rotation: 4, scale: 3, matrix: 16 }))
317
+ finiteVector(node[key], length, `node ${key}`);
318
+ if (node.matrix !== undefined && [node.translation, node.rotation, node.scale].some(value => value !== undefined))
319
+ throw new Error('GLB nodes cannot mix matrix and TRS');
320
+ if (node.rotation && Math.abs(Math.hypot(...node.rotation) - 1) > 0.0001)
321
+ throw new Error('GLB rotations need unit quaternions');
322
+ return list(node.children, 128, 'children').map(child => {
323
+ const id = integer(child, 0, nodes.length - 1, 'child node');
324
+ if (parents.has(id))
325
+ throw new Error('GLB nodes must have a single parent');
326
+ parents.add(id);
327
+ return id;
328
+ });
329
+ });
330
+ const visited = new Set(), visiting = new Set();
331
+ const visit = (id) => {
332
+ if (visiting.has(id))
333
+ throw new Error('Cyclic GLB node hierarchy');
334
+ if (visited.has(id))
335
+ return;
336
+ visiting.add(id);
337
+ edges[id].forEach(visit);
338
+ visiting.delete(id);
339
+ visited.add(id);
340
+ };
341
+ nodes.forEach((_, id) => visit(id));
342
+ const scenes = list(doc.scenes, 1, 'scenes');
343
+ if (!scenes.length)
344
+ throw new Error('GLB needs an explicit scene');
345
+ if (doc.scene !== undefined)
346
+ integer(doc.scene, 0, scenes.length - 1, 'default scene');
347
+ for (const item of scenes) {
348
+ const scene = record(item, 'scene');
349
+ const roots = new Set();
350
+ for (const node of list(scene.nodes, 128, 'scene nodes')) {
351
+ const id = integer(node, 0, nodes.length - 1, 'scene node');
352
+ if (parents.has(id) || roots.has(id))
353
+ throw new Error('GLB scene roots must be distinct parentless nodes');
354
+ roots.add(id);
355
+ }
356
+ }
357
+ return { nodes: nodes.length, meshes: meshes.length, textures: textures.length };
358
+ }
359
+ export function validateSceneModels(models) {
360
+ if (models === undefined)
361
+ return;
362
+ const entries = Object.entries(record(models, 'attachments'));
363
+ if (entries.length > 2)
364
+ throw new Error('Attach at most two GLB models');
365
+ for (const [id, source] of entries) {
366
+ if (!/^model_[12]$/.test(id) || typeof source !== 'string')
367
+ throw new Error('Invalid scene model attachment');
368
+ validateGLB(decodeModelData(source));
369
+ }
370
+ }
@@ -0,0 +1,8 @@
1
+ import { type SceneObjectParameters, type SceneParameters } from './parameters.js';
2
+ import type { FrameContext } from './types.js';
3
+ export interface SceneObjectRegistry {
4
+ register(id: string, read: (frame: FrameContext) => SceneObjectParameters): () => void;
5
+ snapshot(frame: FrameContext): SceneParameters;
6
+ clear(): void;
7
+ }
8
+ export declare function createObjectRegistry(): SceneObjectRegistry;
@@ -0,0 +1,24 @@
1
+ import { validateSceneParameters } from './parameters.js';
2
+ export function createObjectRegistry() {
3
+ const readers = new Map();
4
+ return {
5
+ register(id, read) {
6
+ validateSceneParameters({ [id]: {} });
7
+ if (readers.has(id) || readers.size >= 32 || typeof read !== 'function')
8
+ throw new Error('Object readers need unique IDs; at most 32 can be registered');
9
+ readers.set(id, read);
10
+ return () => { if (readers.get(id) === read)
11
+ readers.delete(id); };
12
+ },
13
+ snapshot(frame) {
14
+ const states = Object.fromEntries([...readers].map(([id, read]) => {
15
+ const state = read(frame);
16
+ validateSceneParameters({ [id]: state });
17
+ return [id, { ...state }];
18
+ }));
19
+ validateSceneParameters(states);
20
+ return states;
21
+ },
22
+ clear() { readers.clear(); },
23
+ };
24
+ }
@@ -0,0 +1,17 @@
1
+ export interface SceneObjectParameters {
2
+ x?: number;
3
+ y?: number;
4
+ z?: number;
5
+ rotation?: number;
6
+ scaleX?: number;
7
+ scaleY?: number;
8
+ opacity?: number;
9
+ delay?: number;
10
+ duration?: number;
11
+ color?: string;
12
+ }
13
+ export type SceneParameters = Record<string, SceneObjectParameters>;
14
+ export type ResolvedSceneObject = Required<SceneObjectParameters>;
15
+ export declare function validateSceneParameters(parameters: unknown): asserts parameters is SceneParameters | undefined;
16
+ /** Components opt into stable object parameters; arbitrary Svelte is never rewritten. */
17
+ export declare function resolveSceneObject(parameters: SceneParameters, id: string, defaults?: SceneObjectParameters): ResolvedSceneObject;
@@ -0,0 +1,35 @@
1
+ const bounds = {
2
+ x: [-19200, 19200], y: [-19200, 19200], z: [-19200, 19200], rotation: [-Math.PI * 100, Math.PI * 100],
3
+ scaleX: [-100, 100], scaleY: [-100, 100], opacity: [0, 1], delay: [0, 60], duration: [0, 60],
4
+ };
5
+ export function validateSceneParameters(parameters) {
6
+ if (parameters === undefined)
7
+ return;
8
+ if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters))
9
+ throw new Error('Invalid scene parameters');
10
+ const objects = Object.entries(parameters);
11
+ if (objects.length > 32)
12
+ throw new Error('Expose at most 32 scene parameter objects');
13
+ for (const [id, object] of objects) {
14
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) || !object || typeof object !== 'object' || Array.isArray(object))
15
+ throw new Error('Invalid scene parameter object');
16
+ for (const [key, value] of Object.entries(object)) {
17
+ if (key === 'color') {
18
+ if (typeof value !== 'string' || !/^#[a-fA-F0-9]{6}$/.test(value))
19
+ throw new Error('Scene color must be a six-digit hex color');
20
+ }
21
+ else {
22
+ const range = Object.hasOwn(bounds, key) ? bounds[key] : undefined;
23
+ if (!range || typeof value !== 'number' || !Number.isFinite(value) || value < range[0] || value > range[1])
24
+ throw new Error(`Invalid scene parameter: ${key}`);
25
+ }
26
+ }
27
+ }
28
+ }
29
+ /** Components opt into stable object parameters; arbitrary Svelte is never rewritten. */
30
+ export function resolveSceneObject(parameters, id, defaults = {}) {
31
+ const override = Object.hasOwn(parameters, id) ? parameters[id] : {};
32
+ const object = { x: 0, y: 0, z: 0, rotation: 0, scaleX: 1, scaleY: 1, opacity: 1, delay: 0, duration: 1, color: '#ffffff', ...defaults, ...override };
33
+ validateSceneParameters({ [id]: object });
34
+ return object;
35
+ }
@@ -0,0 +1,5 @@
1
+ import type { ResourceScope } from './types.js';
2
+ export declare function createResourceScope(signal: AbortSignal): ResourceScope;
3
+ export declare function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T>;
4
+ export declare function waitForVisualResources(target: HTMLElement): Promise<void>;
5
+ export declare function seekCssAnimations(target: HTMLElement, time: number): void;
@@ -0,0 +1,63 @@
1
+ export function createResourceScope(signal) {
2
+ const pending = new Set();
3
+ let failure;
4
+ let failed = false;
5
+ return {
6
+ signal,
7
+ track(resource) {
8
+ signal.throwIfAborted();
9
+ const tracked = resource.then((value) => { pending.delete(tracked); return value; }, (error) => { pending.delete(tracked); failed = true; failure = error; throw error; });
10
+ pending.add(tracked);
11
+ // A tracked resource may finish before ready()/seek() starts awaiting it.
12
+ void tracked.catch(() => undefined);
13
+ return tracked;
14
+ },
15
+ async wait() {
16
+ signal.throwIfAborted();
17
+ while (pending.size > 0) {
18
+ await abortable(Promise.all([...pending]), signal);
19
+ signal.throwIfAborted();
20
+ }
21
+ if (failed)
22
+ throw failure;
23
+ },
24
+ };
25
+ }
26
+ export function abortable(operation, signal) {
27
+ signal.throwIfAborted();
28
+ return new Promise((resolve, reject) => {
29
+ const aborted = () => reject(signal.reason);
30
+ signal.addEventListener('abort', aborted, { once: true });
31
+ operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
32
+ });
33
+ }
34
+ const decodedImages = new WeakMap();
35
+ async function waitForImage(image) {
36
+ if (!image.currentSrc && !image.src)
37
+ return;
38
+ const source = { currentSrc: image.currentSrc, src: image.src, srcset: image.srcset, sizes: image.sizes };
39
+ const cached = decodedImages.get(image);
40
+ if (image.complete && image.naturalWidth > 0 && cached?.currentSrc === source.currentSrc
41
+ && cached.src === source.src && cached.srcset === source.srcset && cached.sizes === source.sizes)
42
+ return;
43
+ // decode() can wait for a compositor tick even for an unchanged image.
44
+ // Retain only successful decodes; source changes and failures must still wait.
45
+ await image.decode();
46
+ decodedImages.set(image, source);
47
+ }
48
+ export async function waitForVisualResources(target) {
49
+ // Font requests can start only after layout. Awaiting fonts.ready before
50
+ // discovering the used faces can capture fallback metrics on a fresh worker.
51
+ target.getBoundingClientRect();
52
+ if (target.ownerDocument.fonts) {
53
+ await Promise.all([...target.ownerDocument.fonts].map(face => face.status === 'loaded' ? undefined : face.load()));
54
+ }
55
+ await target.ownerDocument.fonts?.ready;
56
+ await Promise.all([...target.querySelectorAll('img')].map(waitForImage));
57
+ }
58
+ export function seekCssAnimations(target, time) {
59
+ for (const animation of target.getAnimations({ subtree: true })) {
60
+ animation.pause();
61
+ animation.currentTime = time * 1000;
62
+ }
63
+ }
@@ -0,0 +1,76 @@
1
+ import type { SceneObjectRegistry } from './objects.js';
2
+ import type { SceneParameters } from './parameters.js';
3
+ export declare const PROTOCOL_VERSION: 1;
4
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
5
+ [key: string]: JsonValue;
6
+ };
7
+ export type SceneProps = Record<string, JsonValue>;
8
+ export interface SceneConfig {
9
+ width: number;
10
+ height: number;
11
+ fps: number;
12
+ durationInFrames: number;
13
+ transparent?: boolean;
14
+ captionSafeBottom?: number;
15
+ }
16
+ export interface SceneRequest {
17
+ version: typeof PROTOCOL_VERSION;
18
+ sceneId: string;
19
+ props?: SceneProps;
20
+ seed?: string;
21
+ config: SceneConfig;
22
+ }
23
+ export interface SceneManifest extends SceneRequest {
24
+ props: SceneProps;
25
+ seed: string;
26
+ }
27
+ export interface SceneRenderOptions {
28
+ renderScale?: number;
29
+ capture?: boolean;
30
+ }
31
+ export interface FrameContext extends SceneRenderOptions {
32
+ frame: number;
33
+ time: number;
34
+ progress: number;
35
+ config: Readonly<SceneConfig>;
36
+ random: (key: string) => number;
37
+ }
38
+ export interface SceneInstance {
39
+ update: (context: FrameContext) => void | Promise<void>;
40
+ dispose: () => void | Promise<void>;
41
+ }
42
+ export interface ResourceScope {
43
+ track: <T>(resource: Promise<T>) => Promise<T>;
44
+ wait: () => Promise<void>;
45
+ readonly signal: AbortSignal;
46
+ }
47
+ export interface SceneMountContext<P extends SceneProps = SceneProps> {
48
+ target: HTMLElement;
49
+ props: P;
50
+ config: Readonly<SceneConfig>;
51
+ resources: ResourceScope;
52
+ objects: SceneObjectRegistry;
53
+ }
54
+ export interface SceneDefinition<P extends SceneProps = SceneProps> {
55
+ id: string;
56
+ defaults: P;
57
+ create: (context: SceneMountContext<P>) => SceneInstance | Promise<SceneInstance>;
58
+ }
59
+ export interface SceneController {
60
+ readonly version: typeof PROTOCOL_VERSION;
61
+ readonly manifest: SceneManifest;
62
+ ready: () => Promise<void>;
63
+ seek: (frame: number, options?: SceneRenderOptions & {
64
+ realtime?: boolean;
65
+ playbackRate?: number;
66
+ }) => Promise<void>;
67
+ dispose: () => Promise<void>;
68
+ snapshotObjects?: () => SceneParameters;
69
+ }
70
+ declare global {
71
+ interface Window {
72
+ __scene?: SceneController;
73
+ __sceneRequest?: SceneRequest;
74
+ __sceneError?: string;
75
+ }
76
+ }
@@ -0,0 +1 @@
1
+ export const PROTOCOL_VERSION = 1;
@@ -0,0 +1,4 @@
1
+ import { type SceneConfig, type SceneRequest } from './types.js';
2
+ export declare function validateConfig(config: SceneConfig): void;
3
+ export declare function validateRequest(request: SceneRequest): void;
4
+ export declare function validateFrame(frame: number, config: SceneConfig): void;
@@ -0,0 +1,37 @@
1
+ import { PROTOCOL_VERSION } from './types.js';
2
+ export function validateConfig(config) {
3
+ for (const key of ['width', 'height', 'durationInFrames']) {
4
+ if (!Number.isSafeInteger(config?.[key]) || config[key] <= 0) {
5
+ throw new Error(`${key} must be a positive safe integer`);
6
+ }
7
+ }
8
+ if (!Number.isFinite(config.fps) || config.fps <= 0 || config.fps > 240) {
9
+ throw new Error('fps must be between 0 and 240');
10
+ }
11
+ if (config.transparent !== undefined && typeof config.transparent !== 'boolean') {
12
+ throw new Error('transparent must be a boolean');
13
+ }
14
+ if (config.captionSafeBottom !== undefined && (!Number.isFinite(config.captionSafeBottom) || config.captionSafeBottom < 0 || config.captionSafeBottom > 0.5)) {
15
+ throw new Error('captionSafeBottom must be between 0 and 0.5');
16
+ }
17
+ const duration = config.durationInFrames / config.fps;
18
+ if (!Number.isFinite(duration) || duration > 24 * 60 * 60)
19
+ throw new Error('Scene duration must not exceed 24 hours');
20
+ }
21
+ export function validateRequest(request) {
22
+ if (request?.version !== PROTOCOL_VERSION)
23
+ throw new Error('Unsupported scene protocol version');
24
+ if (typeof request.sceneId !== 'string' || !request.sceneId.trim())
25
+ throw new Error('sceneId is required');
26
+ if (request.seed !== undefined && typeof request.seed !== 'string')
27
+ throw new Error('seed must be a string');
28
+ if (request.props !== undefined && (!request.props || Array.isArray(request.props) || typeof request.props !== 'object')) {
29
+ throw new Error('props must be an object');
30
+ }
31
+ validateConfig(request.config);
32
+ }
33
+ export function validateFrame(frame, config) {
34
+ if (!Number.isSafeInteger(frame) || frame < 0 || frame >= config.durationInFrames) {
35
+ throw new Error(`Frame must be an integer between 0 and ${config.durationInFrames - 1}`);
36
+ }
37
+ }