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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
+
}
|