cozyclay 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -0,0 +1,75 @@
1
+ // detector.js — the App-side loader for MediaPipe's PoseLandmarker. Model
2
+ // creation stays out of browser.js on purpose (see that header): this module
3
+ // is where the engine version, the download sources and the failure names
4
+ // live, so "extraction is broken" always decomposes into which piece failed.
5
+ //
6
+ // Both the wasm engine and the .task weights are runtime downloads. The wasm
7
+ // must match the npm bundle compiled into this build byte-for-byte, so its
8
+ // URL is pinned to the same version; the weights are ~5 MB of Google binaries
9
+ // that belong in neither this repo nor its npm package. Offline is therefore
10
+ // a NAMED failure, never a hang or a silent fallback.
11
+
12
+ export const TASKS_VISION_VERSION = "0.10.17"; // must equal the @mediapipe/tasks-vision entry in package.json — the JS bundle and the wasm ABI move together
13
+ export const POSE_WASM_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${TASKS_VISION_VERSION}/wasm`;
14
+ export const POSE_MODEL_URL =
15
+ "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task";
16
+
17
+ /**
18
+ * Create a `{ detect, close }` pair for collectLandmarkTrack. Every failure
19
+ * mode has its own name:
20
+ * pose-runtime-unavailable — the JS bundle did not load (broken build)
21
+ * pose-model-download-failed — the .task weights were unreachable
22
+ * pose-engine-download-failed— the wasm engine was unreachable
23
+ * pose-engine-init-failed — the engine loaded but refused to start
24
+ * The GPU delegate is tried first and CPU is the retry: WebGPU blocklists
25
+ * are common and a slower extraction beats a refused one.
26
+ */
27
+ export async function createPoseDetector({
28
+ wasmBase = POSE_WASM_BASE,
29
+ modelUrl = POSE_MODEL_URL,
30
+ fetchImpl,
31
+ loadRuntime,
32
+ numPoses = 2, // the footage guidance allows two performers in frame; selectMostConfidentPerson picks one
33
+ } = {}) {
34
+ const doFetch = fetchImpl ?? globalThis.fetch;
35
+ let runtime;
36
+ try {
37
+ runtime = await (loadRuntime ?? (() => import("@mediapipe/tasks-vision")))();
38
+ } catch {
39
+ throw new Error("pose-runtime-unavailable");
40
+ }
41
+ let modelAssetBuffer;
42
+ try {
43
+ const response = await doFetch(modelUrl);
44
+ if (!response.ok) throw new Error(`http-${response.status}`);
45
+ modelAssetBuffer = new Uint8Array(await response.arrayBuffer());
46
+ } catch {
47
+ throw new Error("pose-model-download-failed");
48
+ }
49
+ let fileset;
50
+ try {
51
+ fileset = await runtime.FilesetResolver.forVisionTasks(wasmBase);
52
+ } catch {
53
+ throw new Error("pose-engine-download-failed");
54
+ }
55
+ const optionsFor = (delegate) => ({
56
+ baseOptions: { modelAssetBuffer, delegate },
57
+ runningMode: "VIDEO",
58
+ numPoses,
59
+ outputSegmentationMasks: false,
60
+ });
61
+ let landmarker;
62
+ try {
63
+ landmarker = await runtime.PoseLandmarker.createFromOptions(fileset, optionsFor("GPU"));
64
+ } catch {
65
+ try {
66
+ landmarker = await runtime.PoseLandmarker.createFromOptions(fileset, optionsFor("CPU"));
67
+ } catch {
68
+ throw new Error("pose-engine-init-failed");
69
+ }
70
+ }
71
+ return {
72
+ detect: (image, timestampMs) => landmarker.detectForVideo(image, Math.round(timestampMs)),
73
+ close: () => landmarker.close?.(),
74
+ };
75
+ }
@@ -12,6 +12,9 @@ import { selectPoseKeyframes } from "./keyframes.js";
12
12
  import { filterLandmarkTrack, normalizeLandmarkTrack } from "./landmarks.js";
13
13
 
14
14
  export { collectLandmarkTrack, selectMostConfidentPerson } from "./browser.js";
15
+ export { createPoseDetector, POSE_MODEL_URL, POSE_WASM_BASE, TASKS_VISION_VERSION } from "./detector.js";
16
+ export { sampleTimes, videoFrames } from "./video-frames.js";
17
+ export { bakeExtractedTake } from "./take.js";
15
18
  export { fitLandmarksToPose } from "./fit.js";
16
19
  export { poseChangeScores, selectPoseKeyframes } from "./keyframes.js";
17
20
  export { filterLandmarkTrack, normalizeLandmarkSample, normalizeLandmarkTrack, POSE_LANDMARK } from "./landmarks.js";
@@ -0,0 +1,87 @@
1
+ // take.js — bake a raw landmark track into the exact motion shape the npz
2
+ // decoder returns ({ frames, fps, rotMats, rootPos, posedJoints }), so the
3
+ // existing timeline playback consumes an extracted take with zero new
4
+ // playback code. This is deliberately NOT an ARDY surface: the take is a
5
+ // measurement of the footage, and the generator is never in the loop.
6
+
7
+ import { CSKEL27_JOINTS } from "../ardy/cskel27.js";
8
+ import { poseToCskel27 } from "../ardy/to-cskel27.js";
9
+ import { fitLandmarksToPose } from "./fit.js";
10
+ import { filterLandmarkTrack, normalizeLandmarkTrack } from "./landmarks.js";
11
+
12
+ /**
13
+ * `samples` are raw `{ timeS, landmarks }` from collectLandmarkTrack on the
14
+ * uniform `fps` grid, holes allowed — detection loses the person for a frame
15
+ * without warning. Holes hold the previous baked frame and the leading hole
16
+ * backfills from the first fitted one: a previs take must never cut to
17
+ * T-pose because one frame missed. `fitted`/`held` in the result say exactly
18
+ * how much of the take was measured versus held.
19
+ */
20
+ export function bakeExtractedTake({ samples, rest, fps, durationS, ...options }) {
21
+ if (!Number.isFinite(fps) || fps <= 0) {
22
+ throw new Error("bakeExtractedTake: fps must be a positive finite number");
23
+ }
24
+ if (!Number.isFinite(durationS) || durationS <= 0) {
25
+ throw new Error("bakeExtractedTake: durationS must be a positive finite number");
26
+ }
27
+ const normalized = normalizeLandmarkTrack(samples, options);
28
+ const filtered = filterLandmarkTrack(normalized, options.filter);
29
+ const joints = CSKEL27_JOINTS.length;
30
+ // Same counting rule as frameCountFor: the frame at t=0 plus every whole
31
+ // frame inside the duration, so take length and timeline length agree.
32
+ const frames = Math.max(1, Math.floor(durationS * fps + 1e-6) + 1);
33
+ const rotMats = new Float32Array(frames * joints * 9);
34
+ const rootPos = new Float32Array(frames * 3);
35
+ const posedJoints = new Float32Array(frames * joints * 3);
36
+
37
+ const fitByFrame = new Map();
38
+ let fitted = 0;
39
+ for (const sample of filtered) {
40
+ if (!sample.valid) continue;
41
+ const frame = Math.round(sample.timeS * fps);
42
+ if (frame < 0 || frame >= frames) continue;
43
+ const { pose } = fitLandmarksToPose({ sample, rest, createdMs: options.createdMs ?? 0 });
44
+ fitByFrame.set(frame, poseToCskel27({ pose, rest }));
45
+ fitted += 1;
46
+ }
47
+ if (fitted === 0) throw new Error("no-usable-pose");
48
+
49
+ const writeFrame = (frame, converted) => {
50
+ const rotBase = frame * joints * 9;
51
+ const jointBase = frame * joints * 3;
52
+ for (let j = 0; j < joints; j += 1) {
53
+ const m = converted.local_rot_mats[j];
54
+ const o = rotBase + j * 9;
55
+ rotMats[o] = m[0][0]; rotMats[o + 1] = m[0][1]; rotMats[o + 2] = m[0][2];
56
+ rotMats[o + 3] = m[1][0]; rotMats[o + 4] = m[1][1]; rotMats[o + 5] = m[1][2];
57
+ rotMats[o + 6] = m[2][0]; rotMats[o + 7] = m[2][1]; rotMats[o + 8] = m[2][2];
58
+ const p = converted.posed_joints[j];
59
+ posedJoints[jointBase + j * 3] = p[0];
60
+ posedJoints[jointBase + j * 3 + 1] = p[1];
61
+ posedJoints[jointBase + j * 3 + 2] = p[2];
62
+ }
63
+ rootPos[frame * 3] = converted.posed_joints[0][0];
64
+ rootPos[frame * 3 + 1] = converted.posed_joints[0][1];
65
+ rootPos[frame * 3 + 2] = converted.posed_joints[0][2];
66
+ };
67
+
68
+ let last = null;
69
+ let held = 0;
70
+ const leading = [];
71
+ for (let frame = 0; frame < frames; frame += 1) {
72
+ const converted = fitByFrame.get(frame) ?? null;
73
+ if (converted) last = converted;
74
+ if (!last) {
75
+ leading.push(frame);
76
+ continue;
77
+ }
78
+ if (!converted) held += 1;
79
+ writeFrame(frame, last);
80
+ }
81
+ if (leading.length > 0) {
82
+ const first = fitByFrame.get(Math.min(...fitByFrame.keys()));
83
+ for (const frame of leading) writeFrame(frame, first);
84
+ held += leading.length;
85
+ }
86
+ return { frames, fps, rotMats, rootPos, posedJoints, fitted, held };
87
+ }
@@ -0,0 +1,91 @@
1
+ // video-frames.js — seek-stepped frame supply for landmark extraction. The
2
+ // video element is injected (same pattern as probeFootage) so the node suite
3
+ // drives exactly the stepping the app runs. Frames come out in ascending time
4
+ // order, which is also the contract detectForVideo enforces on timestamps.
5
+
6
+ /**
7
+ * The times extraction samples: t=0 plus every whole step inside the
8
+ * duration — the same counting rule as frameCountFor, so a clip's sample
9
+ * count and its timeline frame count agree at equal rates. The final sample
10
+ * is backed off the stream end by 1 ms: a seek to the exact duration lands
11
+ * past the last presentable frame on some decoders.
12
+ */
13
+ export function sampleTimes(durationS, sampleFps) {
14
+ if (!(durationS > 0) || !(sampleFps > 0)) return [];
15
+ const count = Math.floor(durationS * sampleFps + 1e-6) + 1;
16
+ const tail = Math.max(0, durationS - 1e-3);
17
+ const times = [];
18
+ for (let i = 0; i < count; i += 1) times.push(Math.min(i / sampleFps, tail));
19
+ if (times.length > 1 && times[times.length - 1] <= times[times.length - 2]) times.pop();
20
+ return times;
21
+ }
22
+
23
+ function waitForData(video, timeoutMs) {
24
+ if ((video.readyState ?? 0) >= 2) return Promise.resolve();
25
+ return new Promise((resolve, reject) => {
26
+ let settled = false;
27
+ const timer = setTimeout(() => finish(new Error("footage-load-timeout")), timeoutMs);
28
+ const onLoaded = () => finish(null);
29
+ const onError = () => finish(new Error("decode-failed"));
30
+ function finish(error) {
31
+ if (settled) return;
32
+ settled = true;
33
+ clearTimeout(timer);
34
+ video.removeEventListener?.("loadeddata", onLoaded);
35
+ video.removeEventListener?.("error", onError);
36
+ if (error) reject(error);
37
+ else resolve();
38
+ }
39
+ video.addEventListener("loadeddata", onLoaded);
40
+ video.addEventListener("error", onError);
41
+ video.load?.();
42
+ });
43
+ }
44
+
45
+ function seekTo(video, timeS, timeoutMs) {
46
+ // The first sample is usually t=0 with the decoder already sitting there;
47
+ // assigning currentTime=0 again fires no seeked event, so that case must
48
+ // resolve without waiting for one.
49
+ if (Math.abs((video.currentTime ?? 0) - timeS) < 1e-4 && (video.readyState ?? 0) >= 2) {
50
+ return Promise.resolve();
51
+ }
52
+ return new Promise((resolve, reject) => {
53
+ let settled = false;
54
+ const timer = setTimeout(() => finish(new Error("seek-timeout")), timeoutMs);
55
+ const onSeeked = () => finish(null);
56
+ const onError = () => finish(new Error("decode-failed"));
57
+ function finish(error) {
58
+ if (settled) return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ video.removeEventListener?.("seeked", onSeeked);
62
+ video.removeEventListener?.("error", onError);
63
+ if (error) reject(error);
64
+ else resolve();
65
+ }
66
+ video.addEventListener("seeked", onSeeked);
67
+ video.addEventListener("error", onError);
68
+ video.currentTime = timeS;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Async-iterate `{ image, timeS }` frames from already-ingested footage.
74
+ * `image` is the video element itself — exactly what detectForVideo accepts —
75
+ * so no canvas copy sits between the decoder and the detector.
76
+ */
77
+ export async function* videoFrames(objectUrl, { createVideo, sampleFps = 20, timeoutMs = 8000 } = {}) {
78
+ if (typeof createVideo !== "function") throw new Error("videoFrames: createVideo is required");
79
+ const video = createVideo();
80
+ video.src = objectUrl;
81
+ video.muted = true;
82
+ video.playsInline = true;
83
+ video.preload = "auto";
84
+ await waitForData(video, timeoutMs);
85
+ const times = sampleTimes(Number(video.duration), sampleFps);
86
+ if (times.length === 0) throw new Error("duration-unreadable");
87
+ for (const timeS of times) {
88
+ await seekTo(video, timeS, timeoutMs);
89
+ yield { image: video, timeS };
90
+ }
91
+ }
@@ -0,0 +1,152 @@
1
+ import * as THREE from "three";
2
+ import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
3
+ import { SkeletonUtils } from "three/examples/jsm/Addons.js";
4
+ import { applyPose, primeBindPose, POSE_BONES } from "./poses.js";
5
+
6
+ /**
7
+ * Pose Studio thumbnails, rendered on demand. The studio's pose entries are
8
+ * pure bone data (no image assets ship), so each tile's preview is generated
9
+ * here: clone the character rig, apply the pose, render one 256px frame with
10
+ * the same clay look as the viewport, and hand back a data URL. Results are
11
+ * cached per model+pose for the session; the FBX itself is fetched once per
12
+ * model (the browser HTTP cache dedupes the studio's own copy).
13
+ */
14
+
15
+ const THUMB_SIZE = 256;
16
+ const BODY_CLAY = "#f2eee6";
17
+
18
+ const modelCache = new Map(); // model id -> Promise<Group>
19
+ const thumbCache = new Map(); // `${model}:${poseId}` -> Promise<dataURL>
20
+
21
+ // Thumbnail renders are SERIALIZED and yield to the browser between tiles:
22
+ // a burst of 16 skinned 165k-vert clones on the main thread freezes the
23
+ // studio open for half a second, so each tile waits for an idle slice.
24
+ let renderQueue = Promise.resolve();
25
+ const idle = () => new Promise((resolve) => {
26
+ if (typeof requestIdleCallback === "function") requestIdleCallback(() => resolve(), { timeout: 120 });
27
+ else setTimeout(resolve, 16);
28
+ });
29
+ function enqueueRender(work) {
30
+ const run = renderQueue.then(async () => {
31
+ await idle();
32
+ return work();
33
+ });
34
+ renderQueue = run.catch(() => {});
35
+ return run;
36
+ }
37
+
38
+ function loadModel(model) {
39
+ if (!modelCache.has(model)) {
40
+ modelCache.set(model, new Promise((resolve, reject) => {
41
+ new FBXLoader().load(`/models/${model}.fbx`, resolve, undefined, reject);
42
+ }));
43
+ }
44
+ return modelCache.get(model);
45
+ }
46
+
47
+ let renderer = null;
48
+ function thumbRenderer() {
49
+ if (renderer) return renderer;
50
+ const canvas = document.createElement("canvas");
51
+ canvas.width = THUMB_SIZE;
52
+ canvas.height = THUMB_SIZE;
53
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
54
+ renderer.setSize(THUMB_SIZE, THUMB_SIZE, false);
55
+ return renderer;
56
+ }
57
+
58
+ // One rig per model, RE-POSED per tile: cloning + material traversal per
59
+ // pose was the bulk of the studio-open stall — one clone serves them all.
60
+ const thumbRigCache = new Map(); // model id -> Promise<Group (prepared clone)>
61
+ function thumbRig(model) {
62
+ if (!thumbRigCache.has(model)) {
63
+ thumbRigCache.set(model, loadModel(model).then((fbx) => {
64
+ const rig = SkeletonUtils.clone(fbx);
65
+ rig.scale.setScalar(0.01); // Mixamo exports centimetres
66
+ const jointTint = new THREE.Color(BODY_CLAY).multiplyScalar(0.45);
67
+ rig.traverse((child) => {
68
+ if (child.isMesh) {
69
+ child.material = new THREE.MeshStandardMaterial({
70
+ color: /_Joints$/i.test(child.name) ? jointTint : BODY_CLAY,
71
+ roughness: 0.82,
72
+ metalness: 0,
73
+ });
74
+ child.frustumCulled = false;
75
+ }
76
+ });
77
+ primeBindPose(rig);
78
+ return rig;
79
+ }));
80
+ }
81
+ return thumbRigCache.get(model);
82
+ }
83
+
84
+ function thumbScene() {
85
+ const scene = new THREE.Scene();
86
+ scene.background = new THREE.Color("#101014");
87
+ const hemi = new THREE.HemisphereLight(0xffffff, 0x3a3a40, 1.25);
88
+ const key = new THREE.DirectionalLight(0xffffff, 1.6);
89
+ key.position.set(2.2, 4, 3);
90
+ // A barely-lighter floor so floor poses (seated, kneel) read grounded and
91
+ // air poses (jump) read airborne instead of everyone floating in a void.
92
+ const floor = new THREE.Mesh(
93
+ new THREE.CircleGeometry(6, 48),
94
+ new THREE.MeshStandardMaterial({ color: "#17171d", roughness: 1 })
95
+ );
96
+ floor.rotation.x = -Math.PI / 2;
97
+ scene.add(hemi, key, floor);
98
+ return scene;
99
+ }
100
+
101
+ /** Frame the POSED rig, not a standing mannequin: hands-up, jump and floor
102
+ * poses all leave the fixed framing of a standing figure, so the camera is
103
+ * recomputed from the pose's own bone extents (plus a small margin). */
104
+ function frameCamera(rig) {
105
+ rig.updateMatrixWorld(true);
106
+ const box = new THREE.Box3();
107
+ const point = new THREE.Vector3();
108
+ rig.traverse((object) => {
109
+ if (!object.isBone) return;
110
+ object.getWorldPosition(point);
111
+ box.expandByPoint(point);
112
+ });
113
+ if (box.isEmpty()) box.setFromObject(rig);
114
+ const center = box.getCenter(new THREE.Vector3());
115
+ const size = box.getSize(new THREE.Vector3());
116
+ // Hands and feet sit slightly past their bones; heads a touch more.
117
+ const padded = Math.max(size.x, size.y, size.z) * 0.5 + 0.22;
118
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 50);
119
+ const distance = padded / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
120
+ const direction = new THREE.Vector3(0.3, 0.42, 1).normalize();
121
+ camera.position.copy(center).addScaledVector(direction, distance);
122
+ camera.lookAt(center.x, center.y - size.y * 0.04, center.z);
123
+ return camera;
124
+ }
125
+
126
+ /** Preload a rig model during browser idle so the first studio open does
127
+ * not pay the FBX parse in one frame. Fire-and-forget by design. */
128
+ export function warmThumbnailModels(models) {
129
+ for (const model of models) enqueueRender(() => thumbRig(model));
130
+ }
131
+
132
+ /** Render one pose of one rig model to a PNG data URL. */
133
+ export function poseThumbnail(model, pose) {
134
+ const key = `${model}:${pose.id}`;
135
+ if (!thumbCache.has(key)) {
136
+ thumbCache.set(key, enqueueRender(() => thumbRig(model).then((rig) => {
137
+ // Re-pose the shared rig: applyPose writes deltas over the stamped
138
+ // bind pose, so a rest stamp per tile resets the previous pose.
139
+ applyPose(rig, Object.fromEntries(POSE_BONES.map((bone) => [bone.id, [0, 0, 0]])));
140
+ applyPose(rig, pose.bones);
141
+ const scene = thumbScene();
142
+ scene.add(rig);
143
+ const camera = frameCamera(rig);
144
+ const gl = thumbRenderer();
145
+ gl.render(scene, camera);
146
+ const url = gl.domElement.toDataURL("image/png");
147
+ scene.remove(rig);
148
+ return url;
149
+ })));
150
+ }
151
+ return thumbCache.get(key);
152
+ }