@hraness/slopcamera 3.2.6 → 3.2.8

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 (78) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +41 -13
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +0 -9
  6. package/apps/desktop/application/default-registry.ts +0 -10
  7. package/apps/desktop/application/operation.ts +1 -5
  8. package/apps/desktop/application/operations/index.ts +0 -1
  9. package/apps/desktop/cli/args.ts +22 -86
  10. package/apps/desktop/cli/command-host-resources.ts +5 -1
  11. package/apps/desktop/cli/commands.ts +37 -67
  12. package/apps/desktop/cli/help.ts +24 -20
  13. package/apps/desktop/cli/html-scene.ts +479 -0
  14. package/apps/desktop/cli/io.ts +2 -1
  15. package/apps/desktop/cli/main.ts +28 -47
  16. package/apps/desktop/cli/media-ingest-model.ts +2 -0
  17. package/apps/desktop/cli/media-ingest-program.ts +9 -4
  18. package/apps/desktop/cli/menubar.ts +244 -0
  19. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  20. package/apps/desktop/cli/portable-surface.ts +13 -4
  21. package/apps/desktop/cli/root-help-intro.ts +14 -0
  22. package/apps/desktop/code/application-node-planner.ts +0 -47
  23. package/apps/desktop/code/host-source-layout.ts +46 -0
  24. package/apps/desktop/code/public.ts +0 -1
  25. package/apps/desktop/code/runtime-identity.ts +50 -8
  26. package/apps/desktop/code/semantic-builder.ts +0 -43
  27. package/apps/desktop/code/source-bundle.ts +23 -6
  28. package/apps/desktop/code/source-typecheck.ts +131 -43
  29. package/apps/desktop/code/worker-client.ts +14 -2
  30. package/apps/desktop/core/music-analysis.ts +124 -1
  31. package/apps/desktop/dist/cli/main.js +261 -248
  32. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  33. package/apps/desktop/html-overlay/contracts.ts +3 -0
  34. package/apps/desktop/html-overlay/index.ts +28 -0
  35. package/apps/desktop/html-overlay/music-clock.ts +126 -0
  36. package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
  37. package/apps/desktop/html-overlay/runtime.ts +4 -0
  38. package/apps/desktop/html-overlay/scaffolds.ts +2 -1
  39. package/apps/desktop/html-overlay/scene.ts +76 -0
  40. package/dist/cli.js +83 -81
  41. package/dist/{index-jchqst8w.js → index-7131pg1c.js} +43 -236
  42. package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
  43. package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
  44. package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
  45. package/dist/index.js +4 -18
  46. package/dist/operations.js +2 -2
  47. package/dist/vectorize/worker.js +1 -1
  48. package/dist/workflow.js +3 -3
  49. package/docs/README.md +2 -1
  50. package/docs/studio.md +1 -1
  51. package/examples/html/music-video.html +514 -0
  52. package/examples/html/music-video.json +16 -0
  53. package/package.json +8 -21
  54. package/skills/slopcamera/SKILL.md +10 -1
  55. package/skills/slopcamera/references/diagrams.md +5 -14
  56. package/skills/slopcamera/references/install.md +2 -2
  57. package/skills/slopcamera/references/music-video.md +169 -0
  58. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  59. package/skills/slopcamera/references/support.md +25 -0
  60. package/skills/slopcamera/references/video-projects.md +6 -2
  61. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  62. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  63. package/src/cli.ts +28 -98
  64. package/src/index.ts +0 -23
  65. package/src/support-completion.ts +16 -0
  66. package/src/support.ts +42 -0
  67. package/src/version.ts +1 -1
  68. package/apps/desktop/application/operations/recording/index.ts +0 -5
  69. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  70. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  71. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  72. package/apps/desktop/application/operations/recording/start.ts +0 -169
  73. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  74. package/apps/desktop/capture/protocol.ts +0 -689
  75. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  76. package/apps/desktop/cli/recording-controller.ts +0 -1567
  77. package/apps/desktop/cli/recording-daemon.ts +0 -931
  78. package/src/desktop.ts +0 -253
@@ -0,0 +1,429 @@
1
+ import { z } from "zod";
2
+
3
+ import { createBoundedJsonValueSnapshot, deepFreezeJson } from "../../../src/code/json-snapshot.js";
4
+ import { createSha256HexHasher } from "../../../src/code/sha256.js";
5
+ import { evaluateSpatialGlb, parseSpatialGlb, SPATIAL_GLB_LIMITS, type SpatialGlbImage, type SpatialGlbPrimitive } from "../../../src/spatial-scene/gltf.js";
6
+ import { composeTransform, IDENTITY_MATRIX, invertTransform, multiplyTransforms, transformPoint, type Bounds, type Mat4, type Vec3 } from "../../../src/spatial-scene/math.js";
7
+ import { canonicalJson } from "../core/canonical-json";
8
+ import { HtmlOverlayDeclaredResourcesSchema, type HtmlOverlayDeclaredResource } from "./contracts";
9
+
10
+ /** An HTML Three asset profile. The portable spatial-scene GLB profile remains unchanged. */
11
+ export const THREE_RIGGED_GLB_PROFILE = "slopcamera.three-skinned-glb-trs-pbr-basecolor-v1" as const;
12
+ export const THREE_RIGGED_GLB_LIMITS = Object.freeze({
13
+ ...SPATIAL_GLB_LIMITS,
14
+ nodes: 1024,
15
+ skins: 64,
16
+ jointsPerSkin: 256,
17
+ images: 32,
18
+ moduleBytes: 32 * 1024 * 1024,
19
+ });
20
+
21
+ const nodeIndex = z.number().int().min(0).max(THREE_RIGGED_GLB_LIMITS.nodes - 1);
22
+ const accessorIndex = z.number().int().min(0).max(SPATIAL_GLB_LIMITS.accessors - 1);
23
+ const finite = z.number().finite().min(-1e6).max(1e6);
24
+ const vec3 = z.tuple([finite, finite, finite]);
25
+ const quaternion = z.tuple([finite, finite, finite, finite]);
26
+ const ProvenanceSchema = z.strictObject({
27
+ source: z.enum(["authored", "imported"]),
28
+ description: z.string().trim().min(1).max(4096),
29
+ sourceUrl: z.url().max(2048).refine(value => /^https?:\/\//u.test(value), "Expected an HTTP(S) provenance URL.").optional(),
30
+ license: z.string().trim().min(1).max(1024).optional(),
31
+ attribution: z.string().trim().min(1).max(4096).optional(),
32
+ });
33
+ const OptionsSchema = z.strictObject({
34
+ name: z.string().min(1).max(32).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u),
35
+ provenance: ProvenanceSchema,
36
+ });
37
+ const SkinSchema = z.strictObject({
38
+ name: z.string().max(1024).optional(),
39
+ extras: z.unknown().optional(),
40
+ joints: z.array(nodeIndex).min(1).max(THREE_RIGGED_GLB_LIMITS.jointsPerSkin),
41
+ inverseBindMatrices: accessorIndex.optional(),
42
+ skeleton: nodeIndex.optional(),
43
+ });
44
+ const NodeProjectionSchema = z.object({
45
+ name: z.string().max(1024).optional(),
46
+ children: z.array(nodeIndex).max(THREE_RIGGED_GLB_LIMITS.nodes).default([]),
47
+ mesh: z.number().int().min(0).max(SPATIAL_GLB_LIMITS.meshes - 1).optional(),
48
+ skin: z.number().int().min(0).max(THREE_RIGGED_GLB_LIMITS.skins - 1).optional(),
49
+ translation: vec3.default([0, 0, 0]),
50
+ rotation: quaternion.default([0, 0, 0, 1]),
51
+ scale: vec3.default([1, 1, 1]),
52
+ matrix: z.array(finite).length(16).optional(),
53
+ });
54
+ type RigNode = z.infer<typeof NodeProjectionSchema>;
55
+ type SkinInput = z.infer<typeof SkinSchema>;
56
+ type RigSkin = Readonly<{ joints: readonly number[]; inverseBindMatrices: readonly Mat4[] }>;
57
+ type RigPrimitive = SpatialGlbPrimitive & Readonly<{
58
+ skin?: number;
59
+ joints?: readonly number[];
60
+ weights?: readonly number[];
61
+ }>;
62
+ type RigData = Readonly<{ nodes: readonly RigNode[]; roots: readonly number[]; skins: readonly RigSkin[]; primitives: readonly RigPrimitive[] }>;
63
+ export type ThreeRiggedGlbProvenance = Readonly<z.infer<typeof ProvenanceSchema>>;
64
+ export type ThreeRiggedGlbPrepareOptions = Readonly<z.infer<typeof OptionsSchema>>;
65
+ export interface ThreeRiggedGlbResource {
66
+ readonly declaration: HtmlOverlayDeclaredResource;
67
+ /** A detached copy. Write these bytes and bind this declaration together. */
68
+ readonly bytes: Uint8Array;
69
+ }
70
+ export interface ThreeRiggedGlbInspection {
71
+ readonly profile: typeof THREE_RIGGED_GLB_PROFILE;
72
+ readonly source: Readonly<{ bytes: number; sha256: string; provenance: ThreeRiggedGlbProvenance }>;
73
+ readonly bounds: Bounds;
74
+ readonly primitiveCount: number;
75
+ readonly triangleCount: number;
76
+ readonly vertexCount: number;
77
+ readonly bones: readonly Readonly<{ nodeIndex: number; name: string; parentNodeIndex: number | null }>[];
78
+ readonly images: readonly Readonly<{ width: number; height: number; declaration: HtmlOverlayDeclaredResource }>[];
79
+ }
80
+
81
+ function fail(message: string): never {
82
+ throw new RangeError(`${THREE_RIGGED_GLB_PROFILE}: ${message}`);
83
+ }
84
+ function object(value: unknown, label: string): Record<string, unknown> {
85
+ if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label} must be an object.`);
86
+ return value as Record<string, unknown>;
87
+ }
88
+ function array(value: unknown, maximum: number, label: string): unknown[] {
89
+ if (!Array.isArray(value) || value.length > maximum) fail(`${label} exceeds its array profile.`);
90
+ return value;
91
+ }
92
+ function at<T>(values: readonly T[], index: number, label: string): T {
93
+ return values[index] ?? fail(`${label} references missing index ${index}.`);
94
+ }
95
+ function boundedValue(value: unknown, label: string): unknown {
96
+ return createBoundedJsonValueSnapshot(value, SPATIAL_GLB_LIMITS.jsonBytes, label, {
97
+ maximumDepth: SPATIAL_GLB_LIMITS.jsonDepth, maximumValues: SPATIAL_GLB_LIMITS.jsonValues,
98
+ }).value;
99
+ }
100
+ function digest(bytes: Uint8Array): string {
101
+ const hasher = createSha256HexHasher();
102
+ hasher.update(bytes);
103
+ return hasher.digestHex();
104
+ }
105
+
106
+ function readEnvelope(input: Uint8Array) {
107
+ if (!(input instanceof Uint8Array) || input.byteLength < 28 || input.byteLength > SPATIAL_GLB_LIMITS.bytes || input.buffer instanceof SharedArrayBuffer) {
108
+ fail("Expected bounded, non-shared GLB 2.0 bytes.");
109
+ }
110
+ const bytes = Uint8Array.from(input), view = new DataView(bytes.buffer);
111
+ if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.length) fail("Invalid GLB 2.0 header or total length.");
112
+ const jsonLength = view.getUint32(12, true), binHeader = 20 + jsonLength;
113
+ if (view.getUint32(16, true) !== 0x4e4f534a || jsonLength < 4 || jsonLength % 4 !== 0 || jsonLength > SPATIAL_GLB_LIMITS.jsonBytes || binHeader + 8 > bytes.length) fail("Expected bounded JSON and BIN chunks.");
114
+ const binLength = view.getUint32(binHeader, true);
115
+ if (view.getUint32(binHeader + 4, true) !== 0x004e4942 || binLength % 4 !== 0 || binHeader + 8 + binLength !== bytes.length) fail("Expected exactly one BIN chunk without trailing data.");
116
+ let json: unknown;
117
+ try { json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(20, binHeader))); }
118
+ catch { return fail("GLB JSON must be valid UTF-8 JSON."); }
119
+ return { bytes, document: object(boundedValue(json, "rigged GLB"), "glTF"), binary: bytes.subarray(binHeader + 8) };
120
+ }
121
+
122
+ function envelope(document: unknown, binary: Uint8Array): Uint8Array {
123
+ const raw = new TextEncoder().encode(canonicalJson(document));
124
+ if (raw.length > SPATIAL_GLB_LIMITS.jsonBytes) fail("Lowered geometry JSON exceeds the existing static decoder profile.");
125
+ const jsonLength = Math.ceil(raw.length / 4) * 4;
126
+ const bytes = new Uint8Array(28 + jsonLength + binary.length), view = new DataView(bytes.buffer);
127
+ view.setUint32(0, 0x46546c67, true); view.setUint32(4, 2, true); view.setUint32(8, bytes.length, true);
128
+ view.setUint32(12, jsonLength, true); view.setUint32(16, 0x4e4f534a, true);
129
+ bytes.fill(32, 20, 20 + jsonLength); bytes.set(raw, 20);
130
+ view.setUint32(20 + jsonLength, binary.length, true); view.setUint32(24 + jsonLength, 0x004e4942, true); bytes.set(binary, 28 + jsonLength);
131
+ return bytes;
132
+ }
133
+
134
+ /** After the unchanged static decoder has checked every accessor's storage and finite values. */
135
+ function accessorValues(document: Record<string, unknown>, binary: Uint8Array, index: number, components: number) {
136
+ const accessors = array(document.accessors, SPATIAL_GLB_LIMITS.accessors, "accessors");
137
+ const source = object(at(accessors, index, "accessor"), "accessor");
138
+ const views = array(document.bufferViews, SPATIAL_GLB_LIMITS.bufferViews, "bufferViews");
139
+ const view = object(at(views, source.bufferView as number, "bufferView"), "bufferView");
140
+ const componentBytes = source.componentType === 5121 ? 1 : source.componentType === 5123 ? 2 : 4;
141
+ const stride = (view.byteStride as number | undefined) ?? components * componentBytes;
142
+ const offset = ((view.byteOffset as number | undefined) ?? 0) + ((source.byteOffset as number | undefined) ?? 0);
143
+ const data = new DataView(binary.buffer, binary.byteOffset, binary.byteLength), values: number[] = [];
144
+ for (let item = 0; item < (source.count as number); item++) for (let component = 0; component < components; component++) {
145
+ const address = offset + item * stride + component * componentBytes;
146
+ const raw = source.componentType === 5121 ? data.getUint8(address) : source.componentType === 5123 ? data.getUint16(address, true) : data.getFloat32(address, true);
147
+ values.push(source.normalized ? raw / (source.componentType === 5121 ? 255 : 65535) : raw);
148
+ }
149
+ return { source, view, stride, offset, values };
150
+ }
151
+
152
+ function checkViewRoles(document: Record<string, unknown>, skins: readonly SkinInput[]): void {
153
+ const roles = new Map<number, string>(), vertices = new Map<number, Set<number>>();
154
+ const accessors = document.accessors as Record<string, unknown>[], views = document.bufferViews as Record<string, unknown>[];
155
+ const assign = (index: number, role: string) => {
156
+ const prior = roles.get(index);
157
+ if (prior !== undefined && prior !== role) fail(`Buffer view mixes ${prior} and ${role} data.`);
158
+ roles.set(index, role);
159
+ };
160
+ const accessor = (index: number, role: string) => {
161
+ const source = at(accessors, index, "accessor"), view = source.bufferView as number;
162
+ assign(view, role);
163
+ if (role === "vertex") { const ids = vertices.get(view) ?? new Set<number>(); ids.add(index); vertices.set(view, ids); }
164
+ };
165
+ for (const mesh of document.meshes as { primitives: { attributes: Record<string, number>; indices?: number }[] }[]) for (const primitive of mesh.primitives) {
166
+ for (const index of Object.values(primitive.attributes)) accessor(index, "vertex");
167
+ if (primitive.indices !== undefined) accessor(primitive.indices, "index");
168
+ }
169
+ for (const skin of skins) if (skin.inverseBindMatrices !== undefined) accessor(skin.inverseBindMatrices, "skin");
170
+ for (const image of (document.images ?? []) as { bufferView: number }[]) assign(image.bufferView, "image");
171
+ for (const [view, ids] of vertices) if (ids.size > 1 && at(views, view, "bufferView").byteStride === undefined) fail("Shared vertex views require explicit stride, including joint and weight attributes.");
172
+ }
173
+
174
+ function prepareData(document: Record<string, unknown>, binary: Uint8Array): { data: RigData; images: readonly SpatialGlbImage[]; bounds: Bounds; parents: readonly (number | null)[] } {
175
+ for (const field of ["extensionsUsed", "extensionsRequired"] as const) if (Array.isArray(document[field]) && document[field].includes("KHR_draco_mesh_compression")) fail("Draco-compressed geometry is unsupported; provide an explicitly decoded uncompressed GLB derivative and retain its original source.");
176
+ if (array(document.animations ?? [], SPATIAL_GLB_LIMITS.clips, "animations").length !== 0) fail("Animation clips are unsupported in this profile; author bone poses on the absolute frame clock.");
177
+ const skinInputs = array(document.skins, THREE_RIGGED_GLB_LIMITS.skins, "skins").map(value => SkinSchema.parse(value));
178
+ if (skinInputs.length === 0) fail("At least one skin is required.");
179
+ const nodesRaw = array(document.nodes, THREE_RIGGED_GLB_LIMITS.nodes, "nodes");
180
+ const nodes = nodesRaw.map(value => NodeProjectionSchema.parse(value));
181
+ for (const node of nodes) if (node.skin !== undefined) {
182
+ at(skinInputs, node.skin, "skin");
183
+ if (node.mesh === undefined) fail("A skin requires a mesh on the same node.");
184
+ }
185
+ const accessors = array(document.accessors, SPATIAL_GLB_LIMITS.accessors, "accessors").map(value => object(value, "accessor"));
186
+ const inverseIds = new Set(skinInputs.flatMap(skin => skin.inverseBindMatrices === undefined ? [] : [skin.inverseBindMatrices]));
187
+ for (const skin of skinInputs) {
188
+ if (new Set(skin.joints).size !== skin.joints.length) fail("Skin joints must be unique.");
189
+ for (const joint of skin.joints) at(nodes, joint, "skin joint");
190
+ if (skin.skeleton !== undefined) at(nodes, skin.skeleton, "skeleton");
191
+ if (skin.inverseBindMatrices !== undefined) {
192
+ const source = at(accessors, skin.inverseBindMatrices, "inverse bind accessor");
193
+ if (source.type !== "MAT4" || source.componentType !== 5126 || source.normalized === true || source.count !== skin.joints.length || source.min !== undefined || source.max !== undefined) fail("Inverse binds require unnormalized float32 MAT4 values matching the joint count, without min/max.");
194
+ }
195
+ }
196
+ const meshes = array(document.meshes, SPATIAL_GLB_LIMITS.meshes, "meshes").map(value => object(value, "mesh"));
197
+ const rigAttributes = meshes.map(mesh => array(mesh.primitives, SPATIAL_GLB_LIMITS.primitives, "primitives").map(value => {
198
+ const primitive = object(value, "primitive"), attributes = object(primitive.attributes, "attributes");
199
+ if (primitive.targets !== undefined) fail("Morph targets are unsupported in this profile.");
200
+ const joints = attributes.JOINTS_0 === undefined ? undefined : accessorIndex.parse(attributes.JOINTS_0);
201
+ const weights = attributes.WEIGHTS_0 === undefined ? undefined : accessorIndex.parse(attributes.WEIGHTS_0);
202
+ if ((joints === undefined) !== (weights === undefined)) fail("JOINTS_0 and WEIGHTS_0 must be supplied together.");
203
+ return { joints, weights };
204
+ }));
205
+ // Strip only the independently validated skin fields. The static profile still
206
+ // checks all remaining fields, geometry, materials, images, storage and hierarchy.
207
+ const { skins: _skins, ...base } = document;
208
+ const lowered = {
209
+ ...base,
210
+ nodes: nodesRaw.map(value => { const { skin: _skin, ...node } = object(value, "node"); return node; }),
211
+ meshes: meshes.map(mesh => ({ ...mesh, primitives: (mesh.primitives as unknown[]).map(value => {
212
+ const primitive = object(value, "primitive");
213
+ const { JOINTS_0: _joints, WEIGHTS_0: _weights, ...attributes } = object(primitive.attributes, "attributes");
214
+ return { ...primitive, attributes };
215
+ }) })),
216
+ accessors: accessors.map((source, index) => {
217
+ if (source.type !== "MAT4") return source;
218
+ if (!inverseIds.has(index)) fail("MAT4 accessors are admitted only as inverse bind matrices.");
219
+ return { ...source, type: "VEC4", count: (source.count as number) * 4 };
220
+ }),
221
+ };
222
+ const geometry = evaluateSpatialGlb(parseSpatialGlb(envelope(lowered, binary)), { metersPerUnit: 1, sourceUp: "y", timeUs: 0, materialMode: "source" });
223
+ if (geometry.images.length > THREE_RIGGED_GLB_LIMITS.images) fail("Extracted texture count exceeds this profile.");
224
+ checkViewRoles(document, skinInputs);
225
+ const skins = skinInputs.map(skin => {
226
+ if (skin.inverseBindMatrices === undefined) return { joints: skin.joints, inverseBindMatrices: skin.joints.map(() => IDENTITY_MATRIX) };
227
+ const decoded = accessorValues(document, binary, skin.inverseBindMatrices, 16);
228
+ if (decoded.view.byteStride !== undefined || decoded.view.target !== undefined) fail("Inverse binds require tightly packed untargeted storage.");
229
+ const inverseBindMatrices = skin.joints.map((_, index) => {
230
+ const matrix = decoded.values.slice(index * 16, index * 16 + 16) as unknown as Mat4;
231
+ try { invertTransform(matrix); } catch { fail("Inverse bind matrices must be bounded invertible affine transforms."); }
232
+ return matrix;
233
+ });
234
+ return { joints: skin.joints, inverseBindMatrices };
235
+ });
236
+ const parents: (number | null)[] = nodes.map(() => null);
237
+ nodes.forEach((node, index) => node.children.forEach(child => { parents[child] = index; }));
238
+ const scenes = document.scenes as { nodes: number[] }[];
239
+ const roots = at(scenes, (document.scene as number | undefined) ?? 0, "scene").nodes;
240
+ const reachable = new Set<number>(), pending = [...roots];
241
+ const matrices = new Map<number, Mat4>();
242
+ while (pending.length) {
243
+ const index = pending.shift()!, node = nodes[index]!;
244
+ reachable.add(index);
245
+ const local = node.matrix ? node.matrix as unknown as Mat4 : composeTransform({ position: node.translation, rotation: node.rotation, scale: node.scale });
246
+ matrices.set(index, multiplyTransforms(parents[index] === null ? IDENTITY_MATRIX : matrices.get(parents[index]!)!, local));
247
+ pending.push(...node.children);
248
+ }
249
+ for (const skin of skinInputs) {
250
+ if (skin.joints.some(index => !reachable.has(index))) fail("Every joint must belong to the selected default scene.");
251
+ if (skin.skeleton !== undefined) for (const joint of skin.joints) {
252
+ let current: number | null = joint;
253
+ while (current !== null && current !== skin.skeleton) current = parents[current]!;
254
+ if (current === null) fail("The declared skeleton must be an ancestor of every skin joint.");
255
+ }
256
+ }
257
+ const decodedAttributes = rigAttributes.map((mesh, meshIndex) => mesh.map((attributes, primitiveIndex) => {
258
+ if (attributes.joints === undefined || attributes.weights === undefined) return undefined;
259
+ const primitive = (meshes[meshIndex]!.primitives as { attributes: { POSITION: number } }[])[primitiveIndex]!;
260
+ const vertexCount = accessors[primitive.attributes.POSITION]!.count;
261
+ const joints = accessorValues(document, binary, attributes.joints, 4), weights = accessorValues(document, binary, attributes.weights, 4);
262
+ if (joints.source.type !== "VEC4" || ![5121, 5123].includes(joints.source.componentType as number) || joints.source.normalized === true) fail("JOINTS_0 requires unnormalized unsigned byte/short VEC4.");
263
+ if (weights.source.type !== "VEC4" || !(weights.source.componentType === 5126 && weights.source.normalized !== true || [5121, 5123].includes(weights.source.componentType as number) && weights.source.normalized === true)) fail("WEIGHTS_0 requires float32 or normalized unsigned byte/short VEC4.");
264
+ for (const attribute of [joints, weights]) {
265
+ if (attribute.source.count !== vertexCount || attribute.offset % 4 !== 0 || attribute.stride % 4 !== 0 || (attribute.view.target !== undefined && attribute.view.target !== 34962)) fail("Skin attributes require matching vertex counts, four-byte alignment, and ARRAY_BUFFER storage.");
266
+ }
267
+ for (let index = 0; index < weights.values.length; index += 4) {
268
+ const vertex = weights.values.slice(index, index + 4), sum = vertex.reduce((total, value) => total + value, 0);
269
+ if (vertex.some(value => value < 0 || value > 1) || Math.abs(sum - 1) > 1e-4) fail("Skin weights must be nonnegative and sum to one per vertex.");
270
+ for (let component = 0; component < 4; component++) weights.values[index + component] = vertex[component]! / sum;
271
+ }
272
+ return { joints: joints.values, weights: weights.values };
273
+ }));
274
+ for (const node of nodes) if (node.mesh !== undefined) for (const attributes of decodedAttributes[node.mesh]!) {
275
+ if (node.skin === undefined) {
276
+ if (attributes !== undefined) fail("Joint attributes require an explicit skin on every mesh instance.");
277
+ } else {
278
+ if (attributes === undefined) fail("Every skinned primitive requires JOINTS_0 and WEIGHTS_0.");
279
+ if (attributes.joints.some(value => value >= skins[node.skin!]!.joints.length)) fail("A joint attribute exceeds the skin joint palette.");
280
+ }
281
+ }
282
+ let skinned = false;
283
+ const primitives = geometry.primitives.map(primitive => {
284
+ const node = nodes[primitive.sourceNodeIndex]!;
285
+ if (node.skin === undefined) return primitive;
286
+ skinned = true;
287
+ return { ...primitive, skin: node.skin, ...decodedAttributes[node.mesh!]![primitive.sourcePrimitiveIndex]! };
288
+ });
289
+ if (!skinned) fail("The selected scene must contain at least one skinned triangle primitive.");
290
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
291
+ for (const primitive of primitives as RigPrimitive[]) {
292
+ const skin = primitive.skin === undefined ? undefined : skins[primitive.skin]!;
293
+ const skinMatrices = skin?.joints.map((node, index) => multiplyTransforms(matrices.get(node)!, skin.inverseBindMatrices[index]!));
294
+ for (const vertex of primitive.indices ?? Array.from({ length: primitive.positions.length / 3 }, (_, index) => index)) {
295
+ const position: Vec3 = [primitive.positions[vertex * 3]!, primitive.positions[vertex * 3 + 1]!, primitive.positions[vertex * 3 + 2]!];
296
+ let point: readonly number[];
297
+ if (!skinMatrices) point = transformPoint(primitive.matrix, position);
298
+ else {
299
+ const weighted = [0, 0, 0];
300
+ for (let component = 0; component < 4; component++) {
301
+ const index = vertex * 4 + component, weight = primitive.weights![index]!;
302
+ const transformed = transformPoint(skinMatrices[primitive.joints![index]!]!, position);
303
+ for (let axis = 0; axis < 3; axis++) weighted[axis] = weighted[axis]! + weight * transformed[axis]!;
304
+ }
305
+ point = weighted;
306
+ }
307
+ for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis]!, point[axis]!); max[axis] = Math.max(max[axis]!, point[axis]!); }
308
+ }
309
+ }
310
+ const bounds: Bounds = { min: min as unknown as Vec3, max: max as unknown as Vec3 };
311
+ return { data: deepFreezeJson({ nodes, roots, skins, primitives }), images: geometry.images, bounds: deepFreezeJson(bounds), parents };
312
+ }
313
+
314
+ function moduleSource(data: RigData, images: readonly { imageIndex: number; declaration: HtmlOverlayDeclaredResource }[]): string {
315
+ const embedded = canonicalJson({ ...data, images: images.map(({ imageIndex, declaration }) => ({ imageIndex, declaration })) }).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
316
+ return `// ${THREE_RIGGED_GLB_PROFILE}\nconst data=${embedded};
317
+ export async function createRig(THREE) {
318
+ const textureResults=await Promise.allSettled(data.images.map(image=>Promise.resolve().then(()=>new THREE.TextureLoader().loadAsync(SlopcameraOverlay.asset(image.declaration.name)))));
319
+ const sourceTextures=textureResults.filter(result=>result.status==="fulfilled").map(result=>result.value);
320
+ const rejected=textureResults.find(result=>result.status==="rejected");
321
+ if(rejected){sourceTextures.forEach(texture=>texture.dispose());throw rejected.reason;}
322
+ const textureByImage=new Map(data.images.map((image,index)=>[image.imageIndex,sourceTextures[index]]));
323
+ const root=new THREE.Group(),geometries=[],materials=[],textures=[...sourceTextures],skeletons=[];
324
+ let disposed=false;
325
+ const nodes=data.nodes.map((source,index)=>{
326
+ const node=new THREE.Bone();node.name=source.name??("node-"+index);node.userData.slopcameraNodeIndex=index;
327
+ if(source.matrix){node.matrix.fromArray(source.matrix);node.matrix.decompose(node.position,node.quaternion,node.scale);}
328
+ else{node.position.fromArray(source.translation);node.quaternion.fromArray(source.rotation).normalize();node.scale.fromArray(source.scale);}
329
+ return node;
330
+ });
331
+ const rest=nodes.map(node=>({position:node.position.clone(),quaternion:node.quaternion.clone(),scale:node.scale.clone()}));
332
+ data.nodes.forEach((source,index)=>source.children.forEach(child=>nodes[index].add(nodes[child])));
333
+ data.roots.forEach(index=>root.add(nodes[index]));root.updateMatrixWorld(true);
334
+ try {
335
+ data.skins.forEach(skin=>skeletons.push(new THREE.Skeleton(skin.joints.map(index=>nodes[index]),skin.inverseBindMatrices.map(matrix=>new THREE.Matrix4().fromArray(matrix)))));
336
+ data.primitives.forEach(primitive=>{
337
+ const geometry=new THREE.BufferGeometry();geometries.push(geometry);
338
+ geometry.setAttribute("position",new THREE.Float32BufferAttribute(primitive.positions,3));
339
+ if(primitive.normals)geometry.setAttribute("normal",new THREE.Float32BufferAttribute(primitive.normals,3));
340
+ if(primitive.uvs)geometry.setAttribute("uv",new THREE.Float32BufferAttribute(primitive.uvs,2));
341
+ if(primitive.indices)geometry.setIndex(primitive.indices);
342
+ if(!primitive.normals)geometry.computeVertexNormals();
343
+ const source=primitive.material,color=source.baseColorLinear;
344
+ const material=new THREE.MeshStandardMaterial({color:new THREE.Color().setRGB(color[0],color[1],color[2],THREE.LinearSRGBColorSpace),opacity:color[3],metalness:source.metalness,roughness:source.roughness,transparent:source.alphaMode==="BLEND",alphaTest:source.alphaMode==="MASK"?source.alphaCutoff:0,side:source.doubleSided?THREE.DoubleSide:THREE.FrontSide});
345
+ materials.push(material);
346
+ if(source.baseColorTexture){
347
+ const binding=source.baseColorTexture,texture=textureByImage.get(binding.imageIndex).clone();textures.push(texture);
348
+ const wraps={33071:THREE.ClampToEdgeWrapping,33648:THREE.MirroredRepeatWrapping,10497:THREE.RepeatWrapping};
349
+ const filters={9728:THREE.NearestFilter,9729:THREE.LinearFilter,9984:THREE.NearestMipmapNearestFilter,9985:THREE.LinearMipmapNearestFilter,9986:THREE.NearestMipmapLinearFilter,9987:THREE.LinearMipmapLinearFilter};
350
+ texture.flipY=false;texture.colorSpace=THREE.SRGBColorSpace;texture.wrapS=wraps[binding.sampler.wrapS];texture.wrapT=wraps[binding.sampler.wrapT];
351
+ texture.magFilter=filters[binding.sampler.magFilter??9729];texture.minFilter=filters[binding.sampler.minFilter??9987];texture.generateMipmaps=(binding.sampler.minFilter??9987)>=9984;
352
+ texture.needsUpdate=true;material.map=texture;
353
+ }
354
+ let mesh;
355
+ if(primitive.skin===undefined)mesh=new THREE.Mesh(geometry,material);
356
+ else{
357
+ geometry.setAttribute("skinIndex",new THREE.Uint16BufferAttribute(primitive.joints,4));geometry.setAttribute("skinWeight",new THREE.Float32BufferAttribute(primitive.weights,4));
358
+ mesh=new THREE.SkinnedMesh(geometry,material);mesh.frustumCulled=false;
359
+ }
360
+ mesh.name=nodes[primitive.sourceNodeIndex].name+"-primitive-"+primitive.sourcePrimitiveIndex;nodes[primitive.sourceNodeIndex].add(mesh);
361
+ if(primitive.skin!==undefined)mesh.bind(skeletons[primitive.skin],new THREE.Matrix4());
362
+ });
363
+ } catch(error) {geometries.forEach(value=>value.dispose());materials.forEach(value=>value.dispose());textures.forEach(value=>value.dispose());skeletons.forEach(value=>value.dispose());throw error;}
364
+ const indices=[...new Set(data.skins.flatMap(skin=>skin.joints))].sort((a,b)=>a-b),bones=Object.freeze(indices.map(index=>nodes[index]));
365
+ const resetPose=()=>{
366
+ if(disposed)throw new Error("Rig is disposed.");
367
+ nodes.forEach((node,index)=>{const source=rest[index];node.position.copy(source.position);node.quaternion.copy(source.quaternion);node.scale.copy(source.scale);});
368
+ root.updateMatrixWorld(true);skeletons.forEach(skeleton=>skeleton.update());
369
+ };
370
+ resetPose();
371
+ return Object.freeze({root,bones,nodes:Object.freeze(nodes),resetPose,
372
+ boneByName(name){const found=bones.filter(bone=>bone.name===name);if(found.length!==1)throw new Error("Expected exactly one bone named "+name+"; found "+found.length+". Use nodes[sourceNodeIndex] for ambiguous names.");return found[0];},
373
+ dispose(){if(disposed)return;disposed=true;root.removeFromParent();geometries.forEach(value=>value.dispose());materials.forEach(value=>value.dispose());textures.forEach(value=>value.dispose());skeletons.forEach(value=>value.dispose());}
374
+ });
375
+ }
376
+ `;
377
+ }
378
+
379
+ /** Opaque preparation retains verified source identity and detached generated resources. */
380
+ export class PreparedThreeRiggedGlb {
381
+ readonly profile = THREE_RIGGED_GLB_PROFILE;
382
+ readonly inspection: ThreeRiggedGlbInspection;
383
+ readonly moduleResource: HtmlOverlayDeclaredResource;
384
+ readonly #moduleSource: string;
385
+ readonly #resources: readonly ThreeRiggedGlbResource[];
386
+
387
+ private constructor(input: Uint8Array, optionsInput: ThreeRiggedGlbPrepareOptions) {
388
+ const options = OptionsSchema.parse(boundedValue(optionsInput, "rig preparation options"));
389
+ const { bytes, document, binary } = readEnvelope(input);
390
+ const { data, images, bounds, parents } = prepareData(document, binary), sha256 = digest(bytes);
391
+ const imageResources = images.map(image => {
392
+ const declaration: HtmlOverlayDeclaredResource = { name: `${options.name}-image-${image.imageIndex}`, urlPath: `rig/${options.name}/${sha256}/image-${image.imageIndex}.${image.mimeType === "image/png" ? "png" : "jpg"}`, mediaType: image.mimeType, bytes: image.bytes.length, sha256: digest(image.bytes) };
393
+ return { imageIndex: image.imageIndex, width: image.width, height: image.height, declaration, bytes: image.bytes };
394
+ });
395
+ this.#moduleSource = moduleSource(data, imageResources);
396
+ const moduleBytes = new TextEncoder().encode(this.#moduleSource);
397
+ if (moduleBytes.length > THREE_RIGGED_GLB_LIMITS.moduleBytes) fail("Generated module exceeds the bounded module byte profile.");
398
+ this.moduleResource = Object.freeze({ name: `${options.name}-module`, urlPath: `rig/${options.name}/${sha256}/rig.mjs`, mediaType: "text/javascript", bytes: moduleBytes.length, sha256: digest(moduleBytes) });
399
+ HtmlOverlayDeclaredResourcesSchema.parse([this.moduleResource, ...imageResources.map(image => image.declaration)]);
400
+ this.#resources = [{ declaration: this.moduleResource, bytes: moduleBytes }, ...imageResources.map(image => ({ declaration: Object.freeze(image.declaration), bytes: image.bytes }))];
401
+ const jointIndices = [...new Set(data.skins.flatMap(skin => skin.joints))].sort((a, b) => a - b);
402
+ this.inspection = deepFreezeJson({
403
+ profile: this.profile, source: { bytes: bytes.length, sha256, provenance: options.provenance }, bounds,
404
+ primitiveCount: data.primitives.length,
405
+ triangleCount: data.primitives.reduce((sum, primitive) => sum + (primitive.indices?.length ?? primitive.positions.length / 3) / 3, 0),
406
+ vertexCount: data.primitives.reduce((sum, primitive) => sum + primitive.positions.length / 3, 0),
407
+ bones: jointIndices.map(nodeIndex => ({ nodeIndex, name: data.nodes[nodeIndex]!.name ?? `node-${nodeIndex}`, parentNodeIndex: parents[nodeIndex]! })),
408
+ images: imageResources.map(({ width, height, declaration }) => ({ width, height, declaration })),
409
+ });
410
+ Object.freeze(this);
411
+ }
412
+
413
+ get resources(): readonly ThreeRiggedGlbResource[] {
414
+ return Object.freeze(this.#resources.map(resource => Object.freeze({ declaration: resource.declaration, bytes: resource.bytes.slice() })));
415
+ }
416
+ get moduleSource(): string { return this.#moduleSource; }
417
+ static prepare(bytes: Uint8Array, options: ThreeRiggedGlbPrepareOptions): PreparedThreeRiggedGlb { return new PreparedThreeRiggedGlb(bytes, options); }
418
+ }
419
+
420
+ /** Pure preparation: no files, network, decoder downloads, or browser execution. */
421
+ export function prepareThreeRiggedGlb(bytes: Uint8Array, options: ThreeRiggedGlbPrepareOptions): PreparedThreeRiggedGlb {
422
+ return PreparedThreeRiggedGlb.prepare(bytes, options);
423
+ }
424
+
425
+ /** A declared local module exporting async createRig(THREE), using core Three only. */
426
+ export function createThreeRiggedGlbModule(prepared: PreparedThreeRiggedGlb): string {
427
+ if (!(prepared instanceof PreparedThreeRiggedGlb)) fail("Module generation requires a prepared rigged GLB.");
428
+ return prepared.moduleSource;
429
+ }
@@ -10,6 +10,8 @@ import {
10
10
  } from "./contracts";
11
11
  import { htmlOverlayAssetLocalUrl } from "./libraries";
12
12
  import { HTML_OVERLAY_RANDOM_ALGORITHM } from "./random";
13
+ import { createHtmlOverlayMusicClockRuntimeSource } from "./music-clock";
14
+ import { createHtmlOverlayAudioReactivityRuntimeSource } from "./audio-reactivity";
13
15
 
14
16
  const Uint32Schema = z.number().int().safe().min(0).max(0xffff_ffff);
15
17
 
@@ -711,6 +713,8 @@ export function createHtmlOverlayBrowserRuntimeSource(
711
713
  if (index >= 0) callbacks.splice(index, 1);
712
714
  };
713
715
  },
716
+ ...${createHtmlOverlayMusicClockRuntimeSource()},
717
+ ...${createHtmlOverlayAudioReactivityRuntimeSource()},
714
718
  parameters: deepFreeze(config.parameters),
715
719
  random,
716
720
  randomFor,
@@ -531,7 +531,8 @@ const THREE_SCAFFOLD = documentShell(
531
531
  event.preventDefault();
532
532
  throw new Error("The Three.js rendering context was lost.");
533
533
  });
534
- SlopcameraOverlay.ready(renderer.compileAsync(scene, camera));
534
+ // Prepare shaders without making frame zero await asynchronous polling.
535
+ renderer.compile(scene, camera);
535
536
 
536
537
  SlopcameraOverlay.onFrame(({ progress }) => {
537
538
  const phase = progress * Math.PI * 2;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { RepositoryRelativePathSchema } from "../contracts";
3
+ import {
4
+ HtmlOverlayAuthoringInputSchema, HtmlOverlayCanvasSchema, HtmlOverlayDeclaredResourceSchema,
5
+ HtmlOverlayInlineDocumentSchema, HtmlOverlayParametersSchema, HtmlOverlayTimingSchema,
6
+ } from "./contracts";
7
+ import { HtmlOverlayExecutionProfileSchema } from "./execution-profile";
8
+ import { HtmlOverlayLibrarySelectionSchema } from "./libraries";
9
+ import { AUDIO_REACTIVITY_MAX_DURATION_US } from "../core/music-analysis";
10
+ import { HTML_AUDIO_REACTIVITY_RESOURCE_NAME, HTML_AUDIO_REACTIVITY_RESOURCE_URL_PATH } from "./audio-reactivity";
11
+
12
+ const ResourceSchema = HtmlOverlayDeclaredResourceSchema.omit({ bytes: true, sha256: true }).extend({
13
+ path: RepositoryRelativePathSchema,
14
+ });
15
+
16
+ /** A local scene source. Audio is an explicit caller-selected import, never a browser resource. */
17
+ export const HtmlSceneInputSchema = z.strictObject({
18
+ kind: z.literal("slopcamera.html-scene"),
19
+ schemaVersion: z.literal(1),
20
+ name: z.string().trim().min(1).max(512),
21
+ document: z.union([HtmlOverlayInlineDocumentSchema, z.strictObject({ path: RepositoryRelativePathSchema })]),
22
+ canvas: HtmlOverlayCanvasSchema,
23
+ timing: HtmlOverlayTimingSchema,
24
+ libraries: HtmlOverlayLibrarySelectionSchema.default([]),
25
+ parameters: HtmlOverlayParametersSchema.default({}),
26
+ resources: z.array(ResourceSchema).max(64).default([]),
27
+ seed: z.number().int().safe().min(0).max(0xffff_ffff).default(0),
28
+ background: z.string().regex(/^#[0-9a-fA-F]{6}$/u).default("#080b16"),
29
+ executionProfile: HtmlOverlayExecutionProfileSchema.optional(),
30
+ audio: z.strictObject({
31
+ path: z.string().min(1).max(4096).refine(path => !/[\0\r\n]/u.test(path), "Audio path contains a control character."),
32
+ reactivity: z.strictObject({
33
+ profile: z.literal("bands-v1"),
34
+ resource: z.literal(HTML_AUDIO_REACTIVITY_RESOURCE_NAME).optional(),
35
+ sha256: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
36
+ }).optional(),
37
+ }).optional(),
38
+ }).superRefine((input, context) => {
39
+ if (input.audio?.reactivity !== undefined && input.timing.durationUs > AUDIO_REACTIVITY_MAX_DURATION_US) {
40
+ context.addIssue({ code: "custom", message: "Audio reactivity is limited to ten minutes per scene." });
41
+ }
42
+ if (input.audio?.reactivity?.resource === undefined && input.audio?.reactivity !== undefined && (
43
+ input.resources.length >= 64
44
+ || input.resources.some(resource => resource.name === HTML_AUDIO_REACTIVITY_RESOURCE_NAME
45
+ || resource.urlPath === HTML_AUDIO_REACTIVITY_RESOURCE_URL_PATH)
46
+ )) {
47
+ context.addIssue({ code: "custom", message: "Audio reactivity requires one free resource slot and an unused audio-reactivity name and slopcamera/audio-reactivity.json URL path." });
48
+ }
49
+ if (input.audio?.reactivity?.resource === undefined && input.audio?.reactivity?.sha256 !== undefined) {
50
+ context.addIssue({ code: "custom", message: "An audio-reactivity sidecar digest requires its retained resource name." });
51
+ }
52
+ if (input.audio?.reactivity?.resource !== undefined && !input.resources.some(resource => (
53
+ resource.name === HTML_AUDIO_REACTIVITY_RESOURCE_NAME && resource.urlPath === HTML_AUDIO_REACTIVITY_RESOURCE_URL_PATH
54
+ && resource.mediaType === "application/json" && resource.transport === "fetch"
55
+ ))) {
56
+ context.addIssue({ code: "custom", message: "A retained audio-reactivity source must declare its fixed fetched JSON resource." });
57
+ }
58
+ if (input.audio?.reactivity?.resource !== undefined && input.audio.reactivity.sha256 === undefined) {
59
+ context.addIssue({ code: "custom", message: "A retained audio-reactivity source must record its sidecar SHA-256 digest." });
60
+ }
61
+ const authoring = HtmlOverlayAuthoringInputSchema.safeParse({
62
+ kind: "slopcamera.html-overlay", schemaVersion: 1, canvas: input.canvas, timing: input.timing,
63
+ libraries: input.libraries, parameters: input.parameters, seed: input.seed,
64
+ html: "html" in input.document ? input.document.html : "<html></html>",
65
+ resources: input.resources.map(({ path: _path, ...resource }) => ({ ...resource, bytes: 0, sha256: "0".repeat(64) })),
66
+ });
67
+ // Feed only the authoring contract's own fields through its workload and identity checks.
68
+ if (!authoring.success) {
69
+ for (const issue of authoring.error.issues) context.addIssue({ code: "custom", message: issue.message });
70
+ }
71
+ // The existing renderer emits CSS-sized screenshots at every device scale.
72
+ if (input.canvas.width % 2 !== 0 || input.canvas.height % 2 !== 0) {
73
+ context.addIssue({ code: "custom", message: "MP4 export requires even canvas width and height." });
74
+ }
75
+ });
76
+ export type HtmlSceneInput = Readonly<z.infer<typeof HtmlSceneInputSchema>>;