@hraness/slopcamera 3.2.6 → 3.2.7

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 (34) hide show
  1. package/README.md +5 -2
  2. package/apps/desktop/application/operation.ts +1 -1
  3. package/apps/desktop/cli/args.ts +8 -0
  4. package/apps/desktop/cli/command-host-resources.ts +3 -0
  5. package/apps/desktop/cli/commands.ts +10 -1
  6. package/apps/desktop/cli/help.ts +13 -3
  7. package/apps/desktop/cli/html-scene.ts +368 -0
  8. package/apps/desktop/cli/main.ts +6 -1
  9. package/apps/desktop/cli/media-ingest-model.ts +2 -0
  10. package/apps/desktop/cli/media-ingest-program.ts +9 -4
  11. package/apps/desktop/cli/portable-surface.ts +1 -0
  12. package/apps/desktop/cli/recording-controller.ts +1 -1
  13. package/apps/desktop/cli/root-help-intro.ts +14 -0
  14. package/apps/desktop/dist/cli/main.js +241 -222
  15. package/apps/desktop/html-overlay/contracts.ts +3 -0
  16. package/apps/desktop/html-overlay/index.ts +19 -0
  17. package/apps/desktop/html-overlay/music-clock.ts +126 -0
  18. package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
  19. package/apps/desktop/html-overlay/runtime.ts +2 -0
  20. package/apps/desktop/html-overlay/scaffolds.ts +2 -1
  21. package/apps/desktop/html-overlay/scene.ts +47 -0
  22. package/dist/cli.js +1 -1
  23. package/dist/{index-jchqst8w.js → index-rvghzt7b.js} +1 -1
  24. package/dist/index.js +1 -1
  25. package/docs/README.md +1 -0
  26. package/examples/html/music-video.html +514 -0
  27. package/examples/html/music-video.json +16 -0
  28. package/package.json +3 -1
  29. package/skills/slopcamera/SKILL.md +2 -1
  30. package/skills/slopcamera/references/install.md +1 -1
  31. package/skills/slopcamera/references/music-video.md +149 -0
  32. package/skills/slopcamera/references/video-projects.md +6 -2
  33. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  34. package/src/version.ts +1 -1
@@ -304,6 +304,9 @@ export const HtmlOverlayAuthoringInputSchema = z.strictObject({
304
304
  seed: Uint32Schema,
305
305
  timing: HtmlOverlayTimingSchema,
306
306
  }).superRefine((input, context) => {
307
+ // Zod may run refinements after a numeric bound fails. Leave that issue in
308
+ // the parse result instead of throwing from the checked frame-count helper.
309
+ if (!HtmlOverlayTimingSchema.safeParse(input.timing).success) return;
307
310
  const frames = htmlOverlayFrameCount(input.timing);
308
311
  if (frames > HTML_OVERLAY_MAX_FRAMES) {
309
312
  context.addIssue({
@@ -32,6 +32,7 @@ export {
32
32
  type HtmlOverlayRuntimeFrame,
33
33
  type HtmlOverlayTiming,
34
34
  } from "./contracts";
35
+ export { HtmlSceneInputSchema, type HtmlSceneInput } from "./scene";
35
36
  export {
36
37
  HTML_OVERLAY_SCAFFOLD_CLOCK_INTEGRATIONS,
37
38
  HTML_OVERLAY_SCAFFOLD_KINDS,
@@ -96,3 +97,21 @@ export {
96
97
  type HtmlOverlayScaffoldInput,
97
98
  type ThreeReferenceScaffoldInput,
98
99
  } from "./scaffolds";
100
+ export {
101
+ HtmlOverlayMusicTimingSchema,
102
+ htmlOverlayMusicPulse,
103
+ sampleHtmlOverlayMusicClock,
104
+ type HtmlOverlayMusicSample,
105
+ type HtmlOverlayMusicTiming,
106
+ } from "./music-clock";
107
+ export {
108
+ THREE_RIGGED_GLB_LIMITS,
109
+ THREE_RIGGED_GLB_PROFILE,
110
+ PreparedThreeRiggedGlb,
111
+ createThreeRiggedGlbModule,
112
+ prepareThreeRiggedGlb,
113
+ type ThreeRiggedGlbInspection,
114
+ type ThreeRiggedGlbPrepareOptions,
115
+ type ThreeRiggedGlbProvenance,
116
+ type ThreeRiggedGlbResource,
117
+ } from "./rigged-glb";
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+
3
+ import { HTML_OVERLAY_MAX_DURATION_US } from "./contracts";
4
+
5
+ const MUSIC_CLOCK_LIMITS = Object.freeze({
6
+ maxBeatsPerBar: 32,
7
+ maxBpm: 400,
8
+ maxTimeUs: HTML_OVERLAY_MAX_DURATION_US,
9
+ minBeatsPerBar: 1,
10
+ minBpm: 20,
11
+ });
12
+
13
+ /** Explicit constant tempo and the absolute project time of beat zero. */
14
+ export const HtmlOverlayMusicTimingSchema = z.strictObject({
15
+ beatOffsetUs: z.number().int().safe()
16
+ .min(-MUSIC_CLOCK_LIMITS.maxTimeUs).max(MUSIC_CLOCK_LIMITS.maxTimeUs),
17
+ beatsPerBar: z.number().int().safe()
18
+ .min(MUSIC_CLOCK_LIMITS.minBeatsPerBar).max(MUSIC_CLOCK_LIMITS.maxBeatsPerBar),
19
+ bpm: z.number().finite().min(MUSIC_CLOCK_LIMITS.minBpm).max(MUSIC_CLOCK_LIMITS.maxBpm),
20
+ });
21
+ export type HtmlOverlayMusicTiming = Readonly<z.infer<typeof HtmlOverlayMusicTimingSchema>>;
22
+
23
+ export type HtmlOverlayMusicSample = Readonly<{
24
+ /** Zero-based signed bar index; pre-roll bars have negative indices. */
25
+ barIndex: number;
26
+ /** Normalized position within the bar, in [0, 1). */
27
+ barPhase: number;
28
+ /** Zero-based signed beat index, rounded toward negative infinity. */
29
+ beatIndex: number;
30
+ /** Normalized position within the beat, in [0, 1). */
31
+ beatPhase: number;
32
+ /** Signed elapsed beats since the explicitly supplied beat-zero offset. */
33
+ beatPosition: number;
34
+ }>;
35
+
36
+ type MusicClockRuntime = Readonly<{
37
+ musicClock: (timeUs: number, timing: unknown) => HtmlOverlayMusicSample;
38
+ musicPulse: (beatPhase: number, widthBeats?: number) => number;
39
+ }>;
40
+
41
+ /** This closure is self-contained so the browser and SDK execute the same code. */
42
+ function createMusicClockRuntime(limits: typeof MUSIC_CLOCK_LIMITS): MusicClockRuntime {
43
+ const checkTimeUs = (timeUs: unknown): timeUs is number => (
44
+ typeof timeUs === "number"
45
+ && Number.isSafeInteger(timeUs)
46
+ && Math.abs(timeUs) <= limits.maxTimeUs
47
+ );
48
+
49
+ const musicClock = (timeUs: number, timing: unknown): HtmlOverlayMusicSample => {
50
+ if (!checkTimeUs(timeUs)) {
51
+ throw new RangeError(`HTML overlay music timeUs must be an integer within ±${String(limits.maxTimeUs)} microseconds.`);
52
+ }
53
+ if (timing === null || typeof timing !== "object" || Array.isArray(timing)) {
54
+ throw new TypeError("HTML overlay music timing must be an object with bpm, beatOffsetUs, and beatsPerBar.");
55
+ }
56
+ for (const key in timing) {
57
+ if (key !== "bpm" && key !== "beatOffsetUs" && key !== "beatsPerBar") {
58
+ throw new TypeError(`Unknown HTML overlay music timing field ${key}.`);
59
+ }
60
+ }
61
+ const { bpm, beatOffsetUs, beatsPerBar } = timing as Record<string, unknown>;
62
+ if (typeof bpm !== "number" || !Number.isFinite(bpm)
63
+ || bpm < limits.minBpm || bpm > limits.maxBpm) {
64
+ throw new RangeError(`HTML overlay music bpm must be between ${String(limits.minBpm)} and ${String(limits.maxBpm)}.`);
65
+ }
66
+ if (!checkTimeUs(beatOffsetUs)) {
67
+ throw new RangeError(`HTML overlay music beatOffsetUs must be an integer within ±${String(limits.maxTimeUs)} microseconds.`);
68
+ }
69
+ if (typeof beatsPerBar !== "number" || !Number.isSafeInteger(beatsPerBar)
70
+ || beatsPerBar < limits.minBeatsPerBar || beatsPerBar > limits.maxBeatsPerBar) {
71
+ throw new RangeError(`HTML overlay music beatsPerBar must be an integer between ${String(limits.minBeatsPerBar)} and ${String(limits.maxBeatsPerBar)}.`);
72
+ }
73
+
74
+ // Keep the continuous clock unquantized; floor gives consistent pre-roll.
75
+ // Canonicalize negative zero without changing any nonzero beat position.
76
+ const beatPosition = ((timeUs - beatOffsetUs) * bpm) / 60_000_000 || 0;
77
+ const beatIndex = Math.floor(beatPosition);
78
+ const beatPhase = beatPosition - beatIndex;
79
+ const barIndex = Math.floor(beatIndex / beatsPerBar);
80
+ const barPhase = (beatIndex - barIndex * beatsPerBar + beatPhase) / beatsPerBar;
81
+ return Object.freeze({ barIndex, barPhase, beatIndex, beatPhase, beatPosition });
82
+ };
83
+
84
+ const musicPulse = (beatPhase: number, widthBeats = 0.5): number => {
85
+ if (!Number.isFinite(beatPhase) || beatPhase < 0 || beatPhase >= 1) {
86
+ throw new RangeError("HTML overlay music beatPhase must be in [0, 1).");
87
+ }
88
+ if (!Number.isFinite(widthBeats) || widthBeats <= 0 || widthBeats > 1) {
89
+ throw new RangeError("HTML overlay music pulse widthBeats must be in (0, 1].");
90
+ }
91
+ const distance = Math.min(beatPhase, 1 - beatPhase);
92
+ // Divide before doubling so even a positive subnormal width stays defined.
93
+ const position = Math.min(1, (distance / widthBeats) * 2);
94
+ return 1 - position * position * (3 - 2 * position);
95
+ };
96
+
97
+ return Object.freeze({ musicClock, musicPulse });
98
+ }
99
+
100
+ const musicRuntime = createMusicClockRuntime(MUSIC_CLOCK_LIMITS);
101
+
102
+ /** Samples absolute integer project time. This does not detect tempo or downbeats. */
103
+ export function sampleHtmlOverlayMusicClock(
104
+ timeUs: number,
105
+ timing: HtmlOverlayMusicTiming,
106
+ ): HtmlOverlayMusicSample {
107
+ // Validate the original value with the shared browser boundary as well as the
108
+ // public schema; neither path silently strips an undeclared timing field.
109
+ const sample = musicRuntime.musicClock(timeUs, timing);
110
+ HtmlOverlayMusicTimingSchema.parse(timing);
111
+ return sample;
112
+ }
113
+
114
+ /**
115
+ * A periodic [0, 1] smoothstep pulse centered on each beat. widthBeats is the
116
+ * complete nonzero width, with equal anticipation and decay. Both the beat seam
117
+ * and the support edges have zero slope. This is an envelope, not flash analysis.
118
+ */
119
+ export function htmlOverlayMusicPulse(beatPhase: number, widthBeats = 0.5): number {
120
+ return musicRuntime.musicPulse(beatPhase, widthBeats);
121
+ }
122
+
123
+ /** Internal browser integration: a complete expression with musicClock/musicPulse. */
124
+ export function createHtmlOverlayMusicClockRuntimeSource(): string {
125
+ return `(${createMusicClockRuntime.toString()})(${JSON.stringify(MUSIC_CLOCK_LIMITS)})`;
126
+ }
@@ -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,7 @@ 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";
13
14
 
14
15
  const Uint32Schema = z.number().int().safe().min(0).max(0xffff_ffff);
15
16
 
@@ -711,6 +712,7 @@ export function createHtmlOverlayBrowserRuntimeSource(
711
712
  if (index >= 0) callbacks.splice(index, 1);
712
713
  };
713
714
  },
715
+ ...${createHtmlOverlayMusicClockRuntimeSource()},
714
716
  parameters: deepFreeze(config.parameters),
715
717
  random,
716
718
  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;