poseref 0.1.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.
@@ -0,0 +1,2601 @@
1
+ #!/usr/bin/env node
2
+ import { closeSync, copyFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs";
3
+ import { dirname, extname, join, normalize, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
6
+ import { readdir, rm, stat, writeFile } from "node:fs/promises";
7
+ import { createServer } from "node:http";
8
+ import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
9
+ import { WebSocketServer } from "ws";
10
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { homedir } from "node:os";
12
+ import * as z from "zod";
13
+ import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
14
+ import { Euler, Matrix4, Quaternion, Vector3 } from "three";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { createInterface } from "node:readline/promises";
17
+ //#region server/bridge.ts
18
+ var REQUEST_TIMEOUT_MS$1 = 2e4;
19
+ /** Thrown when no browser tab is connected; the message is meant for the agent to relay. */
20
+ var NoBrowserError = class extends Error {};
21
+ /**
22
+ * Tracks the PoseRef browser tabs connected over WebSocket and forwards agent
23
+ * requests to one of them. The tab that most recently gained focus wins, so
24
+ * with several tabs open the agent edits the one the user is looking at.
25
+ */
26
+ var BrowserBridge = class {
27
+ tabs = [];
28
+ pending = /* @__PURE__ */ new Map();
29
+ nextId = 1;
30
+ appUrl;
31
+ constructor(appUrl) {
32
+ this.appUrl = appUrl;
33
+ }
34
+ get connectedTabs() {
35
+ return this.tabs.length;
36
+ }
37
+ attach(socket) {
38
+ this.tabs.push(socket);
39
+ socket.on("message", (data) => this.handleMessage(socket, String(data)));
40
+ socket.on("close", () => this.detach(socket));
41
+ socket.on("error", () => socket.close());
42
+ }
43
+ request(method, params) {
44
+ const socket = this.tabs[this.tabs.length - 1];
45
+ if (!socket) return Promise.reject(new NoBrowserError(`No PoseRef browser tab is connected. Ask the user to open ${this.appUrl} in a browser, then retry. If that page does not load, PoseRef is not running: the user can start it with \`npx poseref\`.`));
46
+ const id = this.nextId++;
47
+ const message = {
48
+ type: "request",
49
+ id,
50
+ method,
51
+ params
52
+ };
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ this.pending.delete(id);
56
+ reject(/* @__PURE__ */ new Error("The PoseRef browser tab did not answer in time. Ask the user to check that the tab is still open."));
57
+ }, REQUEST_TIMEOUT_MS$1);
58
+ this.pending.set(id, {
59
+ socket,
60
+ resolve,
61
+ reject,
62
+ timer
63
+ });
64
+ socket.send(JSON.stringify(message));
65
+ });
66
+ }
67
+ closeAll() {
68
+ for (const socket of this.tabs) socket.terminate();
69
+ }
70
+ handleMessage(socket, raw) {
71
+ let message;
72
+ try {
73
+ message = JSON.parse(raw);
74
+ } catch {
75
+ return;
76
+ }
77
+ if (message.type === "focus") {
78
+ const index = this.tabs.indexOf(socket);
79
+ if (index >= 0) this.tabs.push(...this.tabs.splice(index, 1));
80
+ return;
81
+ }
82
+ if (message.type !== "response") return;
83
+ const entry = this.pending.get(message.id);
84
+ if (!entry || entry.socket !== socket) return;
85
+ this.pending.delete(message.id);
86
+ clearTimeout(entry.timer);
87
+ if (message.ok) entry.resolve(message.result);
88
+ else entry.reject(new Error(message.error));
89
+ }
90
+ detach(socket) {
91
+ const index = this.tabs.indexOf(socket);
92
+ if (index >= 0) this.tabs.splice(index, 1);
93
+ for (const [id, entry] of this.pending) {
94
+ if (entry.socket !== socket) continue;
95
+ this.pending.delete(id);
96
+ clearTimeout(entry.timer);
97
+ entry.reject(/* @__PURE__ */ new Error("The PoseRef browser tab was closed before it answered."));
98
+ }
99
+ }
100
+ };
101
+ //#endregion
102
+ //#region server/home.ts
103
+ /**
104
+ * Per-user folder for the shared secret and review renders. It lives in the
105
+ * home directory rather than the system temp folder, which other users can
106
+ * read (and pre-create paths in) on Linux and macOS. POSEREF_HOME overrides it,
107
+ * mainly for tests.
108
+ */
109
+ function poserefHome() {
110
+ return process.env.POSEREF_HOME || join(homedir(), ".poseref");
111
+ }
112
+ /** Creates the folder if needed, readable only by the current user where the OS supports modes. */
113
+ function ensurePrivateDir(path) {
114
+ mkdirSync(path, {
115
+ recursive: true,
116
+ mode: 448
117
+ });
118
+ return path;
119
+ }
120
+ function secretPath() {
121
+ return join(poserefHome(), "secret");
122
+ }
123
+ function rendersDir() {
124
+ return ensurePrivateDir(join(ensurePrivateDir(poserefHome()), "renders"));
125
+ }
126
+ //#endregion
127
+ //#region server/auth.ts
128
+ /**
129
+ * A per-user secret shared by the long-running PoseRef server and the stdio MCP
130
+ * processes that agents start. It lets those processes (1) prove the listener
131
+ * on the PoseRef port really is PoseRef before sending it anything, and (2)
132
+ * call the server's internal bridge endpoint, which other local users and web
133
+ * pages cannot. The file is created once, exclusively, with owner-only access.
134
+ */
135
+ var SECRET_BYTES = 32;
136
+ function loadOrCreateSecret(path = secretPath()) {
137
+ ensurePrivateDir(poserefHome());
138
+ try {
139
+ const fd = openSync(path, "wx", 384);
140
+ try {
141
+ writeSync(fd, randomBytes(SECRET_BYTES).toString("hex"));
142
+ } finally {
143
+ closeSync(fd);
144
+ }
145
+ } catch (error) {
146
+ if (error.code !== "EEXIST") throw error;
147
+ }
148
+ const secret = Buffer.from(readFileSync(path, "utf8").trim(), "hex");
149
+ if (secret.length !== SECRET_BYTES) throw new Error(`The PoseRef secret at ${path} is damaged. Delete it and start PoseRef again.`);
150
+ return secret;
151
+ }
152
+ function newNonce() {
153
+ return randomBytes(16).toString("hex");
154
+ }
155
+ function isValidNonce(nonce) {
156
+ return /^[0-9a-f]{32}$/.test(nonce);
157
+ }
158
+ /** The server's answer to a nonce: only a holder of the secret can compute it. */
159
+ function identityProof(secret, nonce) {
160
+ return createHmac("sha256", secret).update(`poseref-identify:${nonce}`).digest("hex");
161
+ }
162
+ /** Compares digests so neither length nor content leaks through timing. */
163
+ function sameDigest(a, b) {
164
+ const left = createHash("sha256").update(a).digest();
165
+ const right = createHash("sha256").update(b).digest();
166
+ return timingSafeEqual(left, right);
167
+ }
168
+ function verifyIdentityProof(secret, nonce, proof) {
169
+ return typeof proof === "string" && sameDigest(identityProof(secret, nonce), proof);
170
+ }
171
+ function bearerToken(secret) {
172
+ return secret.toString("hex");
173
+ }
174
+ function bearerMatches(secret, header) {
175
+ const match = /^Bearer ([0-9a-f]+)$/.exec(header ?? "");
176
+ return match !== null && sameDigest(bearerToken(secret), match[1]);
177
+ }
178
+ //#endregion
179
+ //#region src/constants/aspectRatios.ts
180
+ /** Supported aspect ratios with their 1024px-basis export sizes. */
181
+ var ASPECT_RATIOS = [
182
+ {
183
+ id: "1:1",
184
+ label: "1:1",
185
+ width: 1024,
186
+ height: 1024
187
+ },
188
+ {
189
+ id: "16:9",
190
+ label: "16:9",
191
+ width: 1024,
192
+ height: 576
193
+ },
194
+ {
195
+ id: "9:16",
196
+ label: "9:16",
197
+ width: 576,
198
+ height: 1024
199
+ },
200
+ {
201
+ id: "4:3",
202
+ label: "4:3",
203
+ width: 1024,
204
+ height: 768
205
+ },
206
+ {
207
+ id: "3:4",
208
+ label: "3:4",
209
+ width: 768,
210
+ height: 1024
211
+ }
212
+ ];
213
+ Object.fromEntries(ASPECT_RATIOS.map((option) => [option.id, option]));
214
+ //#endregion
215
+ //#region src/constants/bones.ts
216
+ /** Base height of the hips pivot above the floor (meters). */
217
+ var HIPS_BASE_Y = .94;
218
+ /**
219
+ * Bone hierarchy. Pivots are expressed relative to the parent pivot.
220
+ * The character faces +Z, +X is the left side of the character, +Y is up.
221
+ * Limb meshes extend along local -Y from their pivot.
222
+ */
223
+ var BONES = [
224
+ {
225
+ id: "hips",
226
+ label: "腰",
227
+ parent: null,
228
+ offset: {
229
+ x: 0,
230
+ y: HIPS_BASE_Y,
231
+ z: 0
232
+ }
233
+ },
234
+ {
235
+ id: "chest",
236
+ label: "胸",
237
+ parent: "hips",
238
+ offset: {
239
+ x: 0,
240
+ y: .07,
241
+ z: 0
242
+ }
243
+ },
244
+ {
245
+ id: "neck",
246
+ label: "首",
247
+ parent: "chest",
248
+ offset: {
249
+ x: 0,
250
+ y: .43,
251
+ z: 0
252
+ }
253
+ },
254
+ {
255
+ id: "head",
256
+ label: "頭",
257
+ parent: "neck",
258
+ offset: {
259
+ x: 0,
260
+ y: .08,
261
+ z: 0
262
+ }
263
+ },
264
+ {
265
+ id: "leftShoulder",
266
+ label: "左肩",
267
+ parent: "chest",
268
+ offset: {
269
+ x: .07,
270
+ y: .38,
271
+ z: 0
272
+ }
273
+ },
274
+ {
275
+ id: "rightShoulder",
276
+ label: "右肩",
277
+ parent: "chest",
278
+ offset: {
279
+ x: -.07,
280
+ y: .38,
281
+ z: 0
282
+ }
283
+ },
284
+ {
285
+ id: "leftUpperArm",
286
+ label: "左上腕",
287
+ parent: "leftShoulder",
288
+ offset: {
289
+ x: .14,
290
+ y: 0,
291
+ z: 0
292
+ }
293
+ },
294
+ {
295
+ id: "rightUpperArm",
296
+ label: "右上腕",
297
+ parent: "rightShoulder",
298
+ offset: {
299
+ x: -.14,
300
+ y: 0,
301
+ z: 0
302
+ }
303
+ },
304
+ {
305
+ id: "leftForearm",
306
+ label: "左前腕",
307
+ parent: "leftUpperArm",
308
+ offset: {
309
+ x: 0,
310
+ y: -.28,
311
+ z: 0
312
+ }
313
+ },
314
+ {
315
+ id: "rightForearm",
316
+ label: "右前腕",
317
+ parent: "rightUpperArm",
318
+ offset: {
319
+ x: 0,
320
+ y: -.28,
321
+ z: 0
322
+ }
323
+ },
324
+ {
325
+ id: "leftHand",
326
+ label: "左手",
327
+ parent: "leftForearm",
328
+ offset: {
329
+ x: 0,
330
+ y: -.26,
331
+ z: 0
332
+ }
333
+ },
334
+ {
335
+ id: "rightHand",
336
+ label: "右手",
337
+ parent: "rightForearm",
338
+ offset: {
339
+ x: 0,
340
+ y: -.26,
341
+ z: 0
342
+ }
343
+ },
344
+ {
345
+ id: "leftThigh",
346
+ label: "左太もも",
347
+ parent: "hips",
348
+ offset: {
349
+ x: .095,
350
+ y: -.06,
351
+ z: 0
352
+ }
353
+ },
354
+ {
355
+ id: "rightThigh",
356
+ label: "右太もも",
357
+ parent: "hips",
358
+ offset: {
359
+ x: -.095,
360
+ y: -.06,
361
+ z: 0
362
+ }
363
+ },
364
+ {
365
+ id: "leftLowerLeg",
366
+ label: "左すね",
367
+ parent: "leftThigh",
368
+ offset: {
369
+ x: 0,
370
+ y: -.4,
371
+ z: 0
372
+ }
373
+ },
374
+ {
375
+ id: "rightLowerLeg",
376
+ label: "右すね",
377
+ parent: "rightThigh",
378
+ offset: {
379
+ x: 0,
380
+ y: -.4,
381
+ z: 0
382
+ }
383
+ }
384
+ ];
385
+ var BONE_MAP = Object.fromEntries(BONES.map((bone) => [bone.id, bone]));
386
+ var BONE_IDS = BONES.map((bone) => bone.id);
387
+ var sided = (side, bone) => `${side}${bone}`;
388
+ function sidedLandmarks(name, bone, offset, chain) {
389
+ const title = name[0].toUpperCase() + name.slice(1);
390
+ return ["left", "right"].map((side) => ({
391
+ id: `${side}${title}`,
392
+ bone: sided(side, bone),
393
+ offset,
394
+ ikChain: chain.map((name) => sided(side, name))
395
+ }));
396
+ }
397
+ /**
398
+ * Points an AI agent can measure to check contact and grounding, or move with
399
+ * the `reach` IK tool. Offsets are in the bone's local space and follow the
400
+ * shapes in MANNEQUIN_PARTS (head ellipsoid, hand ellipsoid, foot on the lower
401
+ * leg), so update both together. `ikChain` lists the bones IK rotates by
402
+ * default to move the point, root side first; the pelvis and hips have none
403
+ * because moving them means moving the whole body.
404
+ */
405
+ var JOINT_LANDMARKS = [
406
+ {
407
+ id: "pelvis",
408
+ bone: "hips",
409
+ offset: {
410
+ x: 0,
411
+ y: 0,
412
+ z: 0
413
+ },
414
+ ikChain: []
415
+ },
416
+ {
417
+ id: "neckBase",
418
+ bone: "neck",
419
+ offset: {
420
+ x: 0,
421
+ y: 0,
422
+ z: 0
423
+ },
424
+ ikChain: ["chest"]
425
+ },
426
+ {
427
+ id: "headCenter",
428
+ bone: "head",
429
+ offset: {
430
+ x: 0,
431
+ y: .12,
432
+ z: 0
433
+ },
434
+ ikChain: ["neck", "head"]
435
+ },
436
+ {
437
+ id: "headTop",
438
+ bone: "head",
439
+ offset: {
440
+ x: 0,
441
+ y: .24,
442
+ z: 0
443
+ },
444
+ ikChain: ["neck", "head"]
445
+ },
446
+ {
447
+ id: "chin",
448
+ bone: "head",
449
+ offset: {
450
+ x: 0,
451
+ y: .02,
452
+ z: .06
453
+ },
454
+ ikChain: ["neck", "head"]
455
+ },
456
+ {
457
+ id: "nose",
458
+ bone: "head",
459
+ offset: {
460
+ x: 0,
461
+ y: .1,
462
+ z: .13
463
+ },
464
+ ikChain: ["neck", "head"]
465
+ },
466
+ ...sidedLandmarks("shoulder", "UpperArm", {
467
+ x: 0,
468
+ y: 0,
469
+ z: 0
470
+ }, ["Shoulder"]),
471
+ ...sidedLandmarks("elbow", "Forearm", {
472
+ x: 0,
473
+ y: 0,
474
+ z: 0
475
+ }, ["UpperArm"]),
476
+ ...sidedLandmarks("wrist", "Hand", {
477
+ x: 0,
478
+ y: 0,
479
+ z: 0
480
+ }, ["UpperArm", "Forearm"]),
481
+ ...sidedLandmarks("palm", "Hand", {
482
+ x: 0,
483
+ y: -.085,
484
+ z: 0
485
+ }, ["UpperArm", "Forearm"]),
486
+ ...sidedLandmarks("fingertips", "Hand", {
487
+ x: 0,
488
+ y: -.18,
489
+ z: 0
490
+ }, ["UpperArm", "Forearm"]),
491
+ ...sidedLandmarks("hip", "Thigh", {
492
+ x: 0,
493
+ y: 0,
494
+ z: 0
495
+ }, []),
496
+ ...sidedLandmarks("knee", "LowerLeg", {
497
+ x: 0,
498
+ y: 0,
499
+ z: 0
500
+ }, ["Thigh"]),
501
+ ...sidedLandmarks("ankle", "LowerLeg", {
502
+ x: 0,
503
+ y: -.4,
504
+ z: 0
505
+ }, ["Thigh", "LowerLeg"]),
506
+ ...sidedLandmarks("sole", "LowerLeg", {
507
+ x: 0,
508
+ y: -.48,
509
+ z: .06
510
+ }, ["Thigh", "LowerLeg"]),
511
+ ...sidedLandmarks("toe", "LowerLeg", {
512
+ x: 0,
513
+ y: -.445,
514
+ z: .18
515
+ }, ["Thigh", "LowerLeg"])
516
+ ];
517
+ var JOINT_LANDMARK_MAP = Object.fromEntries(JOINT_LANDMARKS.map((landmark) => [landmark.id, landmark]));
518
+ /**
519
+ * Elbows and knees bend on one axis only. IK changes just their local X angle,
520
+ * within this range, so they never bend backwards or twist.
521
+ */
522
+ var HINGE_LIMITS = {
523
+ leftForearm: {
524
+ min: -160,
525
+ max: 0
526
+ },
527
+ rightForearm: {
528
+ min: -160,
529
+ max: 0
530
+ },
531
+ leftLowerLeg: {
532
+ min: 0,
533
+ max: 160
534
+ },
535
+ rightLowerLeg: {
536
+ min: 0,
537
+ max: 160
538
+ }
539
+ };
540
+ /** Per-axis limits (Euler degrees) IK applies to the torso and head so it cannot fold them unnaturally. */
541
+ var BALL_LIMITS = {
542
+ chest: {
543
+ x: {
544
+ min: -40,
545
+ max: 90
546
+ },
547
+ y: {
548
+ min: -60,
549
+ max: 60
550
+ },
551
+ z: {
552
+ min: -45,
553
+ max: 45
554
+ }
555
+ },
556
+ neck: {
557
+ x: {
558
+ min: -50,
559
+ max: 60
560
+ },
561
+ y: {
562
+ min: -70,
563
+ max: 70
564
+ },
565
+ z: {
566
+ min: -40,
567
+ max: 40
568
+ }
569
+ },
570
+ head: {
571
+ x: {
572
+ min: -40,
573
+ max: 40
574
+ },
575
+ y: {
576
+ min: -50,
577
+ max: 50
578
+ },
579
+ z: {
580
+ min: -30,
581
+ max: 30
582
+ }
583
+ }
584
+ };
585
+ [
586
+ {
587
+ id: "light",
588
+ label: "明るいグレー",
589
+ value: "#c9c9c9"
590
+ },
591
+ {
592
+ id: "mid",
593
+ label: "グレー",
594
+ value: "#8e8e8e"
595
+ },
596
+ {
597
+ id: "dark",
598
+ label: "ダークグレー",
599
+ value: "#4f4f4f"
600
+ },
601
+ {
602
+ id: "white",
603
+ label: "白",
604
+ value: "#f0f0f0"
605
+ }
606
+ ][0].value;
607
+ //#endregion
608
+ //#region src/constants/cameraPresets.ts
609
+ var v = (x, y, z) => ({
610
+ x,
611
+ y,
612
+ z
613
+ });
614
+ /** Camera presets. The character stands at the origin facing +Z. */
615
+ var CAMERA_PRESETS = [
616
+ {
617
+ id: "front",
618
+ label: "正面",
619
+ position: v(0, 1, 3.4),
620
+ target: v(0, .95, 0)
621
+ },
622
+ {
623
+ id: "back",
624
+ label: "背面",
625
+ position: v(0, 1, -3.4),
626
+ target: v(0, .95, 0)
627
+ },
628
+ {
629
+ id: "left",
630
+ label: "左側面",
631
+ position: v(3.4, 1, 0),
632
+ target: v(0, .95, 0)
633
+ },
634
+ {
635
+ id: "right",
636
+ label: "右側面",
637
+ position: v(-3.4, 1, 0),
638
+ target: v(0, .95, 0)
639
+ },
640
+ {
641
+ id: "highAngle",
642
+ label: "ハイアングル",
643
+ position: v(1.6, 3.6, 2.6),
644
+ target: v(0, .85, 0)
645
+ },
646
+ {
647
+ id: "lowAngle",
648
+ label: "ローアングル",
649
+ position: v(1.3, .25, 3),
650
+ target: v(0, 1, 0)
651
+ },
652
+ {
653
+ id: "threeQuarter",
654
+ label: "斜め前",
655
+ position: v(1.95, 1.15, 2.78),
656
+ target: v(0, .95, 0)
657
+ }
658
+ ];
659
+ Object.fromEntries(CAMERA_PRESETS.map((preset) => [preset.id, preset]));
660
+ var FOV_RANGE = {
661
+ min: 15,
662
+ max: 120
663
+ };
664
+ var CAMERA_POSITION_RANGE = {
665
+ min: -10,
666
+ max: 10
667
+ };
668
+ var CAMERA_TARGET_RANGE = {
669
+ min: -4,
670
+ max: 4
671
+ };
672
+ //#endregion
673
+ //#region src/utils/math.ts
674
+ var DEG2RAD = Math.PI / 180;
675
+ var RAD2DEG = 180 / Math.PI;
676
+ function clamp(value, min, max) {
677
+ return Math.min(max, Math.max(min, value));
678
+ }
679
+ function round(value, decimals = 3) {
680
+ const factor = 10 ** decimals;
681
+ return Math.round(value * factor) / factor;
682
+ }
683
+ /** Normalizes an angle in degrees to the range [-180, 180). */
684
+ function normalizeDegrees(degrees) {
685
+ return ((degrees + 180) % 360 + 360) % 360 - 180;
686
+ }
687
+ //#endregion
688
+ //#region server/sceneSchema.ts
689
+ /** Accepted ranges. They match the UI sliders so anything an agent sets can also be edited by hand. */
690
+ var RANGES = {
691
+ angle: {
692
+ min: -360,
693
+ max: 360
694
+ },
695
+ rootOffset: {
696
+ min: -1.5,
697
+ max: 1.5
698
+ },
699
+ characterX: {
700
+ min: -3,
701
+ max: 3
702
+ },
703
+ characterY: {
704
+ min: -1,
705
+ max: 2
706
+ },
707
+ characterZ: {
708
+ min: -3,
709
+ max: 3
710
+ },
711
+ cameraPosition: CAMERA_POSITION_RANGE,
712
+ cameraTarget: CAMERA_TARGET_RANGE,
713
+ fov: FOV_RANGE
714
+ };
715
+ var between = (range) => z.number().min(range.min).max(range.max);
716
+ var vec3 = (x, y = x, zRange = x) => z.strictObject({
717
+ x: between(x),
718
+ y: between(y),
719
+ z: between(zRange)
720
+ });
721
+ var angle = between(RANGES.angle).transform(normalizeDegrees);
722
+ /** Missing axes mean 0, so `{ "x": -90 }` is enough to lift a thigh. */
723
+ var rotation = z.strictObject({
724
+ x: angle.default(0),
725
+ y: angle.default(0),
726
+ z: angle.default(0)
727
+ });
728
+ var boneIds = BONE_IDS;
729
+ var aspectRatioIds = ASPECT_RATIOS.map((option) => option.id);
730
+ var sceneSchema = z.strictObject({
731
+ version: z.literal(1).optional().describe("Scene format version. Always 1."),
732
+ pose: z.strictObject({
733
+ mode: z.enum(["replace", "merge"]).optional().describe("'replace' (default): bones not listed are reset to 0. 'merge': bones not listed keep their current rotation."),
734
+ bones: z.partialRecord(z.enum(boneIds), rotation).optional().describe("Per-bone local Euler XYZ rotation in degrees. See get_pose_spec for axis conventions."),
735
+ rootOffset: vec3(RANGES.rootOffset).optional().describe("Offset of the hips in meters. y is adjusted by grounding unless ground is false."),
736
+ ground: z.boolean().optional().describe("Default true: after applying, raise or lower the hips so the lowest body point rests on the floor. Set false for jumps or other airborne poses.")
737
+ }).optional(),
738
+ character: z.strictObject({
739
+ yaw: between(RANGES.angle).transform(normalizeDegrees).optional().describe("Whole-body turn in degrees. 0 faces +Z."),
740
+ position: vec3(RANGES.characterX, RANGES.characterY, RANGES.characterZ).optional()
741
+ }).optional(),
742
+ camera: z.strictObject({
743
+ position: vec3(RANGES.cameraPosition).optional().describe("Camera position in meters."),
744
+ target: vec3(RANGES.cameraTarget).optional().describe("Point the camera looks at, in meters."),
745
+ fov: between(RANGES.fov).optional().describe("Vertical field of view in degrees.")
746
+ }).optional(),
747
+ aspectRatio: z.enum(aspectRatioIds).optional().describe("Output image aspect ratio.")
748
+ });
749
+ var renderViewsSchema = z.strictObject({
750
+ views: z.array(z.enum([
751
+ "current",
752
+ "front",
753
+ "back",
754
+ "left",
755
+ "right",
756
+ "top"
757
+ ])).min(1).max(6).optional().describe("Views to render side by side. 'current' is the user's camera and composition; the others are framed on the character and follow its yaw. Default: current, front, left."),
758
+ size: z.number().int().min(256).max(768).optional().describe("Height of each view in pixels. Default 480.")
759
+ });
760
+ var JOINT_IDS = JOINT_LANDMARKS.map((landmark) => landmark.id);
761
+ var jointPositionsSchema = z.strictObject({ pairs: z.array(z.tuple([z.enum(JOINT_IDS), z.enum(JOINT_IDS)])).max(20).optional().describe("Joint pairs to measure, e.g. [[\"rightElbow\",\"rightKnee\"],[\"rightPalm\",\"chin\"]]. Distances are returned in meters.") });
762
+ var worldPoint = z.strictObject({
763
+ x: between({
764
+ min: -5,
765
+ max: 5
766
+ }),
767
+ y: between({
768
+ min: -5,
769
+ max: 5
770
+ }),
771
+ z: between({
772
+ min: -5,
773
+ max: 5
774
+ })
775
+ });
776
+ var reachSchema = z.strictObject({
777
+ goals: z.array(z.strictObject({
778
+ effector: z.enum(JOINT_IDS).describe("Landmark to move, e.g. \"rightElbow\" or \"rightPalm\"."),
779
+ target: z.union([z.enum(JOINT_IDS), worldPoint]).describe("Landmark name, or a world point in meters, to move the effector to."),
780
+ offset: z.strictObject({
781
+ x: between({
782
+ min: -1,
783
+ max: 1
784
+ }),
785
+ y: between({
786
+ min: -1,
787
+ max: 1
788
+ }),
789
+ z: between({
790
+ min: -1,
791
+ max: 1
792
+ })
793
+ }).optional().describe("World-space offset added to the target, e.g. {\"x\":0,\"y\":0.11,\"z\":0} to rest an elbow on top of a knee."),
794
+ chain: z.array(z.enum(boneIds)).min(1).max(6).optional().describe("Bones allowed to rotate, root side first. Default: the effector's usual chain, minus bones used by earlier goals.")
795
+ })).min(1).max(4).describe("Solved in order and re-checked on every pass, so list the goal that must hold most firmly first."),
796
+ ground: z.boolean().optional().describe("Default true: keep the body resting on the floor afterwards.")
797
+ });
798
+ /**
799
+ * Parameters the internal /poseref/bridge endpoint accepts per method. The
800
+ * stdio MCP process validates tool input already; the server checks again so
801
+ * the endpoint never forwards anything the browser side does not expect.
802
+ */
803
+ var bridgeParamSchemas = {
804
+ getScene: z.strictObject({}),
805
+ getJoints: z.strictObject({}),
806
+ setScene: sceneSchema,
807
+ renderViews: renderViewsSchema.required()
808
+ };
809
+ /** Bridge requests are small JSON documents; anything bigger is not ours. */
810
+ var MAX_BODY_BYTES = 262144;
811
+ function json(res, status, body, close = false) {
812
+ res.writeHead(status, {
813
+ "Content-Type": "application/json",
814
+ "Cache-Control": "no-store",
815
+ ...close ? { Connection: "close" } : {}
816
+ });
817
+ res.end(JSON.stringify(body));
818
+ }
819
+ function readBody(req) {
820
+ return new Promise((resolve, reject) => {
821
+ const chunks = [];
822
+ let size = 0;
823
+ let tooLarge = false;
824
+ req.on("data", (chunk) => {
825
+ if (tooLarge) return;
826
+ size += chunk.length;
827
+ if (size > MAX_BODY_BYTES) {
828
+ tooLarge = true;
829
+ chunks.length = 0;
830
+ reject(Object.assign(/* @__PURE__ */ new Error("Request body too large"), { status: 413 }));
831
+ return;
832
+ }
833
+ chunks.push(chunk);
834
+ });
835
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
836
+ req.on("error", reject);
837
+ });
838
+ }
839
+ /**
840
+ * Endpoints for PoseRef's own stdio MCP processes, never for web pages:
841
+ * - GET /poseref/identify?nonce=… proves this is PoseRef (HMAC of the nonce with the per-user secret)
842
+ * - POST /poseref/bridge forwards one request to the browser tab (bearer secret required)
843
+ * - POST /poseref/shutdown stops the server (bearer secret required)
844
+ * Returns false when the path is not an internal one.
845
+ */
846
+ function createInternalHandler(options) {
847
+ return (req, res) => {
848
+ const url = new URL(req.url ?? "/", "http://localhost");
849
+ if (!url.pathname.startsWith("/poseref/")) return false;
850
+ if (req.headers.origin !== void 0) {
851
+ json(res, 403, { error: "Forbidden" });
852
+ return true;
853
+ }
854
+ if (url.pathname === `/poseref/identify` && req.method === "GET") {
855
+ const nonce = url.searchParams.get("nonce") ?? "";
856
+ if (!isValidNonce(nonce)) {
857
+ json(res, 400, { error: "Invalid nonce" });
858
+ return true;
859
+ }
860
+ json(res, 200, {
861
+ app: "poseref",
862
+ version: options.version,
863
+ tabs: options.bridge.connectedTabs,
864
+ proof: identityProof(options.secret, nonce)
865
+ });
866
+ return true;
867
+ }
868
+ if (req.method !== "POST" || !(url.pathname === `/poseref/bridge` || url.pathname === `/poseref/shutdown`)) {
869
+ json(res, 404, { error: "Not found" });
870
+ return true;
871
+ }
872
+ if (!bearerMatches(options.secret, req.headers.authorization)) {
873
+ json(res, 401, { error: "Unauthorized" });
874
+ return true;
875
+ }
876
+ if (url.pathname === `/poseref/shutdown`) {
877
+ if (!options.onShutdown) {
878
+ json(res, 409, { error: "This PoseRef server is managed by another process (e.g. npm run dev)." });
879
+ return true;
880
+ }
881
+ json(res, 200, { ok: true });
882
+ options.onShutdown();
883
+ return true;
884
+ }
885
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
886
+ json(res, 415, { error: "Expected application/json" });
887
+ return true;
888
+ }
889
+ handleBridge(req, res, options.bridge);
890
+ return true;
891
+ };
892
+ }
893
+ async function handleBridge(req, res, bridge) {
894
+ let payload;
895
+ try {
896
+ payload = JSON.parse(await readBody(req));
897
+ } catch (error) {
898
+ const status = error.status ?? 400;
899
+ json(res, status, { error: status === 413 ? "Request body too large" : "Invalid request body" }, status === 413);
900
+ return;
901
+ }
902
+ const method = payload.method;
903
+ if (typeof method !== "string" || !Object.hasOwn(bridgeParamSchemas, method)) {
904
+ json(res, 400, { error: "Unknown method" });
905
+ return;
906
+ }
907
+ const parsed = bridgeParamSchemas[method].safeParse(payload.params ?? {});
908
+ if (!parsed.success) {
909
+ json(res, 400, { error: `Invalid params: ${parsed.error.message}` });
910
+ return;
911
+ }
912
+ try {
913
+ json(res, 200, {
914
+ ok: true,
915
+ result: await bridge.request(method, parsed.data)
916
+ });
917
+ } catch (error) {
918
+ const message = error instanceof Error ? error.message : String(error);
919
+ json(res, error instanceof NoBrowserError ? 503 : 200, {
920
+ ok: false,
921
+ error: message
922
+ });
923
+ }
924
+ }
925
+ //#endregion
926
+ //#region src/utils/rotation.ts
927
+ var scratchEuler$1 = new Euler(0, 0, 0, "XYZ");
928
+ var scratchQuaternion = new Quaternion();
929
+ /**
930
+ * Every XYZ Euler triple has a second triple describing the same orientation:
931
+ * (x, y, z) and (x + 180, 180 - y, z + 180). Which one `setFromQuaternion`
932
+ * returns depends on the quaternion, so a continuous drag can suddenly switch
933
+ * representation and make the rotation sliders jump even though the pose is
934
+ * unchanged. Picking the triple closest to the previous value hides that.
935
+ */
936
+ function alternativeEuler(value) {
937
+ return {
938
+ x: normalizeDegrees(value.x + 180),
939
+ y: normalizeDegrees(180 - value.y),
940
+ z: normalizeDegrees(value.z + 180)
941
+ };
942
+ }
943
+ function toQuaternion(value, target) {
944
+ scratchEuler$1.set(value.x * DEG2RAD, value.y * DEG2RAD, value.z * DEG2RAD, "XYZ");
945
+ return target.setFromEuler(scratchEuler$1);
946
+ }
947
+ /** Sum of the shortest-arc distances per axis, used to compare two triples. */
948
+ function distanceTo(value, reference) {
949
+ return Math.abs(normalizeDegrees(value.x - reference.x)) + Math.abs(normalizeDegrees(value.y - reference.y)) + Math.abs(normalizeDegrees(value.z - reference.z));
950
+ }
951
+ function describesSameRotation(value, quaternion) {
952
+ const rebuilt = toQuaternion(value, scratchQuaternion);
953
+ return Math.abs(rebuilt.dot(quaternion)) > .999;
954
+ }
955
+ /**
956
+ * Converts a quaternion to XYZ Euler angles in degrees, each within [-180, 180).
957
+ * When `previous` is given, the equivalent triple closest to it is returned so
958
+ * that values stay continuous across a drag.
959
+ */
960
+ function eulerDegreesFromQuaternion(quaternion, previous) {
961
+ scratchEuler$1.setFromQuaternion(quaternion, "XYZ");
962
+ const candidate = {
963
+ x: normalizeDegrees(scratchEuler$1.x * RAD2DEG),
964
+ y: normalizeDegrees(scratchEuler$1.y * RAD2DEG),
965
+ z: normalizeDegrees(scratchEuler$1.z * RAD2DEG)
966
+ };
967
+ if (!previous) return candidate;
968
+ const alternative = alternativeEuler(candidate);
969
+ if (distanceTo(alternative, previous) >= distanceTo(candidate, previous)) return candidate;
970
+ return describesSameRotation(alternative, quaternion) ? alternative : candidate;
971
+ }
972
+ //#endregion
973
+ //#region src/utils/ik.ts
974
+ var MAX_PASSES = 60;
975
+ var TOLERANCE = .003;
976
+ /** Largest rotation a single CCD step may apply; keeps the solve from flipping limbs around. */
977
+ var MAX_STEP = 40 * DEG2RAD;
978
+ var scratchEuler = new Euler(0, 0, 0, "XYZ");
979
+ var X_AXIS = new Vector3(1, 0, 0);
980
+ var ONE = new Vector3(1, 1, 1);
981
+ function eulerQuaternion(rotation) {
982
+ scratchEuler.set(rotation.x * DEG2RAD, rotation.y * DEG2RAD, rotation.z * DEG2RAD, "XYZ");
983
+ return new Quaternion().setFromEuler(scratchEuler);
984
+ }
985
+ /** World matrix of every bone, in the same order and with the same transforms as the rendered mannequin. */
986
+ function forwardKinematics(frame) {
987
+ const character = new Matrix4().compose(new Vector3(frame.characterPosition.x, frame.characterPosition.y, frame.characterPosition.z), eulerQuaternion({
988
+ x: 0,
989
+ y: frame.characterYaw,
990
+ z: 0
991
+ }), ONE);
992
+ const worlds = /* @__PURE__ */ new Map();
993
+ for (const bone of BONES) {
994
+ const offset = bone.id === "hips" ? frame.rootOffset : {
995
+ x: 0,
996
+ y: 0,
997
+ z: 0
998
+ };
999
+ const local = new Matrix4().compose(new Vector3(bone.offset.x + offset.x, bone.offset.y + offset.y, bone.offset.z + offset.z), eulerQuaternion(frame.bones[bone.id]), ONE);
1000
+ const parent = bone.parent ? worlds.get(bone.parent) : character;
1001
+ worlds.set(bone.id, parent.clone().multiply(local));
1002
+ }
1003
+ return worlds;
1004
+ }
1005
+ function landmarkPosition(worlds, id) {
1006
+ const landmark = JOINT_LANDMARK_MAP[id];
1007
+ return new Vector3(landmark.offset.x, landmark.offset.y, landmark.offset.z).applyMatrix4(worlds.get(landmark.bone));
1008
+ }
1009
+ function isAncestorOrSelf(bone, of) {
1010
+ for (let current = of; current; current = BONE_MAP[current].parent) if (current === bone) return true;
1011
+ return false;
1012
+ }
1013
+ /** Returns an error message when the goal cannot be solved as given, otherwise null. */
1014
+ function checkGoal(goal, chain) {
1015
+ const landmark = JOINT_LANDMARK_MAP[goal.effector];
1016
+ if (!landmark) return `Unknown effector "${goal.effector}".`;
1017
+ if (typeof goal.target === "string" && !JOINT_LANDMARK_MAP[goal.target]) return `Unknown target "${goal.target}".`;
1018
+ if (chain.length === 0) return `${goal.effector} has no bones left to rotate; pass a chain explicitly.`;
1019
+ const stray = chain.find((bone) => !isAncestorOrSelf(bone, landmark.bone));
1020
+ if (stray) return `${stray} does not move ${goal.effector}; a chain may only contain ${landmark.bone} and its parents.`;
1021
+ return null;
1022
+ }
1023
+ function targetPosition(worlds, goal) {
1024
+ const base = typeof goal.target === "string" ? landmarkPosition(worlds, goal.target) : new Vector3(goal.target.x, goal.target.y, goal.target.z);
1025
+ if (goal.offset) base.add(new Vector3(goal.offset.x, goal.offset.y, goal.offset.z));
1026
+ return base;
1027
+ }
1028
+ function worldQuaternion(matrix) {
1029
+ const position = new Vector3();
1030
+ const quaternion = new Quaternion();
1031
+ matrix.decompose(position, quaternion, new Vector3());
1032
+ return quaternion;
1033
+ }
1034
+ function parentQuaternion(worlds, bone, frame) {
1035
+ const parent = BONE_MAP[bone].parent;
1036
+ return parent ? worldQuaternion(worlds.get(parent)) : eulerQuaternion({
1037
+ x: 0,
1038
+ y: frame.characterYaw,
1039
+ z: 0
1040
+ });
1041
+ }
1042
+ function clampBall(bone, rotation) {
1043
+ const limits = BALL_LIMITS[bone];
1044
+ if (!limits) return rotation;
1045
+ return {
1046
+ x: clamp(rotation.x, limits.x.min, limits.x.max),
1047
+ y: clamp(rotation.y, limits.y.min, limits.y.max),
1048
+ z: clamp(rotation.z, limits.z.min, limits.z.max)
1049
+ };
1050
+ }
1051
+ /** One CCD step: turn `bone` so the effector swings toward the target. */
1052
+ function stepBone(frame, bone, goal) {
1053
+ const worlds = forwardKinematics(frame);
1054
+ const pivot = new Vector3().setFromMatrixPosition(worlds.get(bone));
1055
+ const toEffector = landmarkPosition(worlds, goal.effector).sub(pivot);
1056
+ const toTarget = targetPosition(worlds, goal).sub(pivot);
1057
+ if (toEffector.lengthSq() < 1e-8 || toTarget.lengthSq() < 1e-8) return;
1058
+ const current = frame.bones[bone];
1059
+ const hinge = HINGE_LIMITS[bone];
1060
+ if (hinge) {
1061
+ const axis = X_AXIS.clone().applyQuaternion(parentQuaternion(worlds, bone, frame));
1062
+ const from = toEffector.clone().sub(axis.clone().multiplyScalar(toEffector.dot(axis)));
1063
+ const to = toTarget.clone().sub(axis.clone().multiplyScalar(toTarget.dot(axis)));
1064
+ if (from.lengthSq() < 1e-8 || to.lengthSq() < 1e-8) return;
1065
+ const limited = clamp(Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)), -MAX_STEP, MAX_STEP) * RAD2DEG;
1066
+ frame.bones[bone] = {
1067
+ ...current,
1068
+ x: clamp(current.x + limited, hinge.min, hinge.max)
1069
+ };
1070
+ return;
1071
+ }
1072
+ const delta = new Quaternion().setFromUnitVectors(toEffector.normalize(), toTarget.normalize());
1073
+ const angle = 2 * Math.acos(clamp(delta.w, -1, 1));
1074
+ if (angle > MAX_STEP) delta.slerp(new Quaternion(), 1 - MAX_STEP / angle);
1075
+ const boneWorld = worldQuaternion(worlds.get(bone));
1076
+ const local = parentQuaternion(worlds, bone, frame).invert().multiply(delta.multiply(boneWorld));
1077
+ frame.bones[bone] = clampBall(bone, eulerDegreesFromQuaternion(local, current));
1078
+ }
1079
+ /** Rotates `bone` by a world-space rotation, keeping everything else fixed. */
1080
+ function rotateInWorld(frame, bone, delta) {
1081
+ const worlds = forwardKinematics(frame);
1082
+ const boneWorld = worldQuaternion(worlds.get(bone));
1083
+ const local = parentQuaternion(worlds, bone, frame).invert().multiply(delta.clone().multiply(boneWorld));
1084
+ frame.bones[bone] = clampBall(bone, eulerDegreesFromQuaternion(local, frame.bones[bone]));
1085
+ }
1086
+ /** Direction the elbow or knee should point when the limb starts out straight. */
1087
+ function defaultPole(worlds, upper, frame) {
1088
+ const side = upper.startsWith("left") ? 1 : -1;
1089
+ return (upper.endsWith("UpperArm") ? new Vector3(side * .6, -1, -.4) : new Vector3(side * .2, 0, 1)).normalize().applyQuaternion(parentQuaternion(worlds, upper, frame));
1090
+ }
1091
+ /**
1092
+ * Analytic two-bone step for upper arm + forearm or thigh + lower leg: bend the
1093
+ * hinge until the limb is exactly as long as the distance to the target, point
1094
+ * the limb at the target, then swivel it about that line so the elbow or knee
1095
+ * faces the pole. CCD alone gets stuck here because it never twists the upper
1096
+ * bone to line the hinge up with a target off to the side.
1097
+ */
1098
+ function stepTwoBone(frame, upper, hinge, goal) {
1099
+ const limits = HINGE_LIMITS[hinge];
1100
+ let worlds = forwardKinematics(frame);
1101
+ const shoulder = new Vector3().setFromMatrixPosition(worlds.get(upper));
1102
+ const target = targetPosition(worlds, goal);
1103
+ const wanted = target.distanceTo(shoulder);
1104
+ const current = frame.bones[hinge];
1105
+ const pole = Math.abs(current.x) > 15 ? new Vector3().setFromMatrixPosition(worlds.get(hinge)).sub(shoulder) : defaultPole(worlds, upper, frame);
1106
+ const reachAt = (x) => {
1107
+ frame.bones[hinge] = {
1108
+ ...current,
1109
+ x
1110
+ };
1111
+ return landmarkPosition(forwardKinematics(frame), goal.effector).distanceTo(shoulder);
1112
+ };
1113
+ let bestX = current.x;
1114
+ let bestMiss = Infinity;
1115
+ for (let x = limits.min; x <= limits.max; x += 5) {
1116
+ const miss = Math.abs(reachAt(x) - wanted);
1117
+ if (miss < bestMiss) [bestX, bestMiss] = [x, miss];
1118
+ }
1119
+ for (let x = bestX - 5; x <= bestX + 5; x += .25) {
1120
+ const clamped = clamp(x, limits.min, limits.max);
1121
+ const miss = Math.abs(reachAt(clamped) - wanted);
1122
+ if (miss < bestMiss) [bestX, bestMiss] = [clamped, miss];
1123
+ }
1124
+ frame.bones[hinge] = {
1125
+ ...current,
1126
+ x: bestX
1127
+ };
1128
+ worlds = forwardKinematics(frame);
1129
+ const toEffector = landmarkPosition(worlds, goal.effector).sub(shoulder).normalize();
1130
+ const toTarget = target.clone().sub(shoulder);
1131
+ if (toTarget.lengthSq() < 1e-8) return;
1132
+ toTarget.normalize();
1133
+ rotateInWorld(frame, upper, new Quaternion().setFromUnitVectors(toEffector, toTarget));
1134
+ worlds = forwardKinematics(frame);
1135
+ const elbow = new Vector3().setFromMatrixPosition(worlds.get(hinge)).sub(shoulder);
1136
+ const along = (v) => v.clone().sub(toTarget.clone().multiplyScalar(v.dot(toTarget)));
1137
+ const from = along(elbow);
1138
+ const to = along(pole);
1139
+ if (from.lengthSq() < 1e-8 || to.lengthSq() < 1e-8) return;
1140
+ const swivel = Math.atan2(toTarget.dot(from.clone().cross(to)), from.dot(to));
1141
+ rotateInWorld(frame, upper, new Quaternion().setFromAxisAngle(toTarget, swivel));
1142
+ }
1143
+ /**
1144
+ * Twists `upper` about its own length (the line from its pivot to the hinge)
1145
+ * so the hinge's bend plane swings toward the target. The hinge pivot lies on
1146
+ * that line, so an elbow or knee placed by an earlier goal stays where it is;
1147
+ * only the forearm or lower leg sweeps around. Without this a hinge-only chain
1148
+ * can bend but never aim sideways.
1149
+ */
1150
+ function stepTwist(frame, upper, hinge, goal) {
1151
+ const worlds = forwardKinematics(frame);
1152
+ const pivot = new Vector3().setFromMatrixPosition(worlds.get(upper));
1153
+ const joint = new Vector3().setFromMatrixPosition(worlds.get(hinge));
1154
+ const axis = joint.clone().sub(pivot);
1155
+ if (axis.lengthSq() < 1e-8) return;
1156
+ axis.normalize();
1157
+ const across = (point) => {
1158
+ const offset = point.clone().sub(joint);
1159
+ return offset.sub(axis.clone().multiplyScalar(offset.dot(axis)));
1160
+ };
1161
+ const from = across(landmarkPosition(worlds, goal.effector));
1162
+ const to = across(targetPosition(worlds, goal));
1163
+ if (from.lengthSq() < 1e-8 || to.lengthSq() < 1e-8) return;
1164
+ const angle = clamp(Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)), -MAX_STEP, MAX_STEP);
1165
+ rotateInWorld(frame, upper, new Quaternion().setFromAxisAngle(axis, angle));
1166
+ }
1167
+ /** Hinges in the chain whose parent bone is not in it: their parent may still twist (see stepTwist). */
1168
+ function twistableHinges(chain) {
1169
+ return chain.filter((bone) => HINGE_LIMITS[bone] && !chain.includes(BONE_MAP[bone].parent)).map((hinge) => [BONE_MAP[hinge].parent, hinge]);
1170
+ }
1171
+ /** The [upper, hinge] pair a chain ends with, when the effector sits past the hinge. */
1172
+ function twoBonePair(chain, effector) {
1173
+ if (chain.length < 2) return null;
1174
+ const [upper, hinge] = chain.slice(-2);
1175
+ if (!HINGE_LIMITS[hinge] || BONE_MAP[hinge].parent !== upper) return null;
1176
+ const landmark = JOINT_LANDMARK_MAP[effector];
1177
+ const pastHinge = landmark.bone !== hinge || landmark.offset.x !== 0 || landmark.offset.y !== 0 || landmark.offset.z !== 0;
1178
+ return isAncestorOrSelf(hinge, landmark.bone) && pastHinge ? [upper, hinge] : null;
1179
+ }
1180
+ function goalError(frame, goal) {
1181
+ const worlds = forwardKinematics(frame);
1182
+ return landmarkPosition(worlds, goal.effector).distanceTo(targetPosition(worlds, goal));
1183
+ }
1184
+ /** Resolves each goal's chain: explicit, or the default chain minus bones earlier goals already use. */
1185
+ function resolveChains(goals) {
1186
+ const claimed = /* @__PURE__ */ new Set();
1187
+ return goals.map((goal) => {
1188
+ const chain = goal.chain ?? (JOINT_LANDMARK_MAP[goal.effector]?.ikChain ?? []).filter((bone) => !claimed.has(bone));
1189
+ for (const bone of chain) claimed.add(bone);
1190
+ return chain;
1191
+ });
1192
+ }
1193
+ /**
1194
+ * Cyclic coordinate descent over all goals. Goals are visited in order on every
1195
+ * pass, so an earlier goal is re-satisfied after a later one disturbs it.
1196
+ * Returns the new rotations and each goal's remaining error; check the errors,
1197
+ * because an out-of-reach target leaves the limb pointing at it but short.
1198
+ */
1199
+ function solveIk(start, goals, chains) {
1200
+ const frame = {
1201
+ ...start,
1202
+ bones: structuredClone(start.bones)
1203
+ };
1204
+ const twists = chains.map(twistableHinges);
1205
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
1206
+ goals.forEach((goal, index) => {
1207
+ const pair = twoBonePair(chains[index], goal.effector);
1208
+ const rest = pair ? chains[index].slice(0, -2) : chains[index];
1209
+ if (pair) stepTwoBone(frame, pair[0], pair[1], goal);
1210
+ for (const bone of [...rest].reverse()) stepBone(frame, bone, goal);
1211
+ for (const [upper, hinge] of twists[index]) stepTwist(frame, upper, hinge, goal);
1212
+ });
1213
+ if (goals.every((goal) => goalError(frame, goal) < TOLERANCE)) break;
1214
+ }
1215
+ const changed = [.../* @__PURE__ */ new Set([...chains.flat(), ...twists.flat().map(([upper]) => upper)])];
1216
+ for (const bone of changed) {
1217
+ const r = frame.bones[bone];
1218
+ frame.bones[bone] = {
1219
+ x: round(r.x, 1),
1220
+ y: round(r.y, 1),
1221
+ z: round(r.z, 1)
1222
+ };
1223
+ }
1224
+ return {
1225
+ bones: frame.bones,
1226
+ changed,
1227
+ goals: goals.map((goal, index) => ({
1228
+ effector: goal.effector,
1229
+ chain: chains[index],
1230
+ error: round(goalError(frame, goal))
1231
+ }))
1232
+ };
1233
+ }
1234
+ //#endregion
1235
+ //#region src/constants/posePresets.ts
1236
+ var r = (x, y, z) => ({
1237
+ x,
1238
+ y,
1239
+ z
1240
+ });
1241
+ /**
1242
+ * Pose presets in degrees (Euler XYZ per bone).
1243
+ * Conventions: limbs hang along -Y at rest. For arms, negative X swings forward,
1244
+ * positive Z raises the left arm outward (negative Z for the right arm).
1245
+ * For legs, negative X lifts the thigh forward and positive X on the lower leg bends the knee.
1246
+ * Torso/head bones point +Y: positive X leans forward, positive Y turns to the left of the character.
1247
+ */
1248
+ var POSE_PRESETS = [
1249
+ {
1250
+ id: "tPose",
1251
+ label: "Tポーズ",
1252
+ labelEn: "T-Pose",
1253
+ group: "standing",
1254
+ bones: {
1255
+ leftUpperArm: r(0, 0, 90),
1256
+ rightUpperArm: r(0, 0, -90)
1257
+ }
1258
+ },
1259
+ {
1260
+ id: "standing",
1261
+ label: "立ち",
1262
+ labelEn: "Standing",
1263
+ group: "standing",
1264
+ bones: {
1265
+ leftUpperArm: r(-3, 0, 7),
1266
+ rightUpperArm: r(-3, 0, -7),
1267
+ leftForearm: r(-10, 0, 0),
1268
+ rightForearm: r(-10, 0, 0),
1269
+ leftThigh: r(0, 0, 3),
1270
+ rightThigh: r(0, 0, -3)
1271
+ }
1272
+ },
1273
+ {
1274
+ id: "handsOnHips",
1275
+ label: "腰に手",
1276
+ labelEn: "Hands on Hips",
1277
+ group: "standing",
1278
+ bones: {
1279
+ chest: r(-3, 0, 0),
1280
+ leftUpperArm: r(15, 0, 45),
1281
+ leftForearm: r(-67, 0, -66),
1282
+ rightUpperArm: r(15, 0, -45),
1283
+ rightForearm: r(-67, 0, 66),
1284
+ leftThigh: r(0, 0, 5),
1285
+ rightThigh: r(0, 0, -5)
1286
+ }
1287
+ },
1288
+ {
1289
+ id: "armsCrossed",
1290
+ label: "腕組み",
1291
+ labelEn: "Arms Crossed",
1292
+ group: "standing",
1293
+ bones: {
1294
+ chest: r(3, 0, 0),
1295
+ leftUpperArm: r(-62, 0, -8),
1296
+ leftForearm: r(0, -12, -100),
1297
+ rightUpperArm: r(-48, 0, 8),
1298
+ rightForearm: r(0, 12, 100),
1299
+ leftThigh: r(0, 0, 3),
1300
+ rightThigh: r(0, 0, -3)
1301
+ }
1302
+ },
1303
+ {
1304
+ id: "handsBehindBack",
1305
+ label: "後ろ手",
1306
+ labelEn: "Hands Behind Back",
1307
+ group: "standing",
1308
+ bones: {
1309
+ chest: r(-4, 0, 0),
1310
+ leftUpperArm: r(25, 0, 12),
1311
+ leftForearm: r(0, 0, -72),
1312
+ rightUpperArm: r(28, 0, -12),
1313
+ rightForearm: r(0, 0, 72),
1314
+ leftThigh: r(0, 0, 3),
1315
+ rightThigh: r(0, 0, -3)
1316
+ }
1317
+ },
1318
+ {
1319
+ id: "modelPose",
1320
+ label: "モデル立ち",
1321
+ labelEn: "Model Pose",
1322
+ group: "standing",
1323
+ bones: {
1324
+ hips: r(0, 12, 5),
1325
+ chest: r(0, -10, -6),
1326
+ head: r(0, 6, 4),
1327
+ rightThigh: r(0, 0, -5),
1328
+ leftThigh: r(-10, 0, -3),
1329
+ leftLowerLeg: r(15, 0, 0),
1330
+ rightUpperArm: r(15, 0, -45),
1331
+ rightForearm: r(-67, 0, 66),
1332
+ leftUpperArm: r(-3, 0, 8),
1333
+ leftForearm: r(-10, 0, 0)
1334
+ },
1335
+ rootOffset: r(0, -.02, 0)
1336
+ },
1337
+ {
1338
+ id: "lookingBack",
1339
+ label: "振り返り",
1340
+ labelEn: "Looking Back",
1341
+ group: "standing",
1342
+ bones: {
1343
+ hips: r(0, 8, 0),
1344
+ chest: r(0, 35, 0),
1345
+ neck: r(0, 25, 0),
1346
+ head: r(-8, 45, 0),
1347
+ leftUpperArm: r(8, 0, 6),
1348
+ rightUpperArm: r(-6, 0, -8),
1349
+ leftForearm: r(-15, 0, 0),
1350
+ rightForearm: r(-12, 0, 0),
1351
+ leftThigh: r(0, 0, 3),
1352
+ rightThigh: r(0, 0, -3)
1353
+ }
1354
+ },
1355
+ {
1356
+ id: "peaceSign",
1357
+ label: "ピース",
1358
+ labelEn: "Peace Sign",
1359
+ group: "gesture",
1360
+ bones: {
1361
+ head: r(0, 0, 8),
1362
+ rightUpperArm: r(-25, 0, -50),
1363
+ rightForearm: r(-140, 0, 0),
1364
+ leftUpperArm: r(-3, 0, 8),
1365
+ leftForearm: r(-12, 0, 0),
1366
+ leftThigh: r(0, 0, 4),
1367
+ rightThigh: r(0, 0, -4)
1368
+ }
1369
+ },
1370
+ {
1371
+ id: "waving",
1372
+ label: "手を振る",
1373
+ labelEn: "Waving",
1374
+ group: "gesture",
1375
+ bones: {
1376
+ head: r(0, 0, 6),
1377
+ rightUpperArm: r(-10, 0, -95),
1378
+ rightForearm: r(0, 0, -75),
1379
+ leftUpperArm: r(-3, 0, 8),
1380
+ leftForearm: r(-10, 0, 0),
1381
+ leftThigh: r(0, 0, 4),
1382
+ rightThigh: r(0, 0, -4)
1383
+ }
1384
+ },
1385
+ {
1386
+ id: "pointing",
1387
+ label: "指差し",
1388
+ labelEn: "Pointing",
1389
+ group: "gesture",
1390
+ bones: {
1391
+ chest: r(0, -6, 0),
1392
+ head: r(0, -4, 0),
1393
+ rightUpperArm: r(-92, 0, -8),
1394
+ leftUpperArm: r(-3, 0, 8),
1395
+ leftForearm: r(-10, 0, 0),
1396
+ leftThigh: r(0, 0, 3),
1397
+ rightThigh: r(0, 0, -3)
1398
+ }
1399
+ },
1400
+ {
1401
+ id: "thinking",
1402
+ label: "考える",
1403
+ labelEn: "Thinking",
1404
+ group: "gesture",
1405
+ bones: {
1406
+ chest: r(3, 0, 0),
1407
+ head: r(8, -8, 6),
1408
+ rightUpperArm: r(-40, 0, -10),
1409
+ rightForearm: r(-157, 0, 30),
1410
+ leftUpperArm: r(-45, 0, -10),
1411
+ leftForearm: r(0, -12, -100),
1412
+ leftThigh: r(0, 0, 3),
1413
+ rightThigh: r(0, 0, -3)
1414
+ }
1415
+ },
1416
+ {
1417
+ id: "salute",
1418
+ label: "敬礼",
1419
+ labelEn: "Salute",
1420
+ group: "gesture",
1421
+ bones: {
1422
+ chest: r(-3, 0, 0),
1423
+ rightUpperArm: r(-20, 0, -80),
1424
+ rightForearm: r(-165, 0, -30),
1425
+ leftUpperArm: r(0, 0, 3)
1426
+ }
1427
+ },
1428
+ {
1429
+ id: "cheering",
1430
+ label: "バンザイ",
1431
+ labelEn: "Cheering",
1432
+ group: "gesture",
1433
+ bones: {
1434
+ chest: r(-5, 0, 0),
1435
+ head: r(-10, 0, 0),
1436
+ leftUpperArm: r(0, 0, 170),
1437
+ rightUpperArm: r(0, 0, -170),
1438
+ leftThigh: r(0, 0, 8),
1439
+ rightThigh: r(0, 0, -8)
1440
+ }
1441
+ },
1442
+ {
1443
+ id: "handsBehindHead",
1444
+ label: "後頭部に手",
1445
+ labelEn: "Hands Behind Head",
1446
+ group: "gesture",
1447
+ bones: {
1448
+ chest: r(-5, 0, 0),
1449
+ head: r(-5, 0, 0),
1450
+ leftUpperArm: r(20, 0, 110),
1451
+ leftForearm: r(156, 0, 31),
1452
+ rightUpperArm: r(20, 0, -110),
1453
+ rightForearm: r(156, 0, -31),
1454
+ leftThigh: r(0, 0, 5),
1455
+ rightThigh: r(0, 0, -5)
1456
+ }
1457
+ },
1458
+ {
1459
+ id: "walking",
1460
+ label: "歩く",
1461
+ labelEn: "Walking",
1462
+ group: "action",
1463
+ bones: {
1464
+ chest: r(3, 0, 0),
1465
+ leftThigh: r(-25, 0, 1),
1466
+ leftLowerLeg: r(15, 0, 0),
1467
+ rightThigh: r(20, 0, -1),
1468
+ rightLowerLeg: r(15, 0, 0),
1469
+ rightUpperArm: r(-25, 0, -5),
1470
+ rightForearm: r(-20, 0, 0),
1471
+ leftUpperArm: r(20, 0, 5),
1472
+ leftForearm: r(-15, 0, 0)
1473
+ },
1474
+ rootOffset: r(0, -.04, 0)
1475
+ },
1476
+ {
1477
+ id: "running",
1478
+ label: "走る",
1479
+ labelEn: "Running",
1480
+ group: "action",
1481
+ bones: {
1482
+ chest: r(15, 0, 0),
1483
+ head: r(-10, 0, 0),
1484
+ leftThigh: r(-55, 0, 2),
1485
+ leftLowerLeg: r(85, 0, 0),
1486
+ rightThigh: r(30, 0, -2),
1487
+ rightLowerLeg: r(20, 0, 0),
1488
+ rightUpperArm: r(-55, 0, -8),
1489
+ rightForearm: r(-95, 0, 0),
1490
+ leftUpperArm: r(40, 0, 8),
1491
+ leftForearm: r(-85, 0, 0)
1492
+ },
1493
+ rootOffset: r(0, -.22, 0)
1494
+ },
1495
+ {
1496
+ id: "jumping",
1497
+ label: "ジャンプ",
1498
+ labelEn: "Jumping",
1499
+ group: "action",
1500
+ bones: {
1501
+ chest: r(-5, 0, 0),
1502
+ head: r(-10, 0, 0),
1503
+ leftThigh: r(-60, 0, 8),
1504
+ rightThigh: r(-60, 0, -8),
1505
+ leftLowerLeg: r(90, 0, 0),
1506
+ rightLowerLeg: r(90, 0, 0),
1507
+ leftUpperArm: r(0, 0, 135),
1508
+ rightUpperArm: r(0, 0, -135)
1509
+ },
1510
+ rootOffset: r(0, .25, 0)
1511
+ },
1512
+ {
1513
+ id: "fightingPose",
1514
+ label: "構え",
1515
+ labelEn: "Fighting Pose",
1516
+ group: "action",
1517
+ bones: {
1518
+ hips: r(0, 30, 0),
1519
+ chest: r(8, -12, 0),
1520
+ head: r(5, -18, 0),
1521
+ rightThigh: r(-20, 0, -14),
1522
+ rightLowerLeg: r(30, 0, 0),
1523
+ leftThigh: r(12, 0, 14),
1524
+ leftLowerLeg: r(20, 0, 0),
1525
+ rightUpperArm: r(-70, 0, 10),
1526
+ rightForearm: r(-100, 0, 0),
1527
+ leftUpperArm: r(-60, 0, -25),
1528
+ leftForearm: r(-130, 0, 0)
1529
+ },
1530
+ rootOffset: r(0, -.05, 0)
1531
+ },
1532
+ {
1533
+ id: "sitting",
1534
+ label: "椅子に座る",
1535
+ labelEn: "Sitting",
1536
+ group: "sitting",
1537
+ bones: {
1538
+ chest: r(5, 0, 0),
1539
+ leftThigh: r(-90, 0, 6),
1540
+ rightThigh: r(-90, 0, -6),
1541
+ leftLowerLeg: r(90, 0, 0),
1542
+ rightLowerLeg: r(90, 0, 0),
1543
+ leftUpperArm: r(-22, 0, 6),
1544
+ rightUpperArm: r(-22, 0, -6),
1545
+ leftForearm: r(-40, 0, 0),
1546
+ rightForearm: r(-40, 0, 0)
1547
+ },
1548
+ rootOffset: r(0, -.4, 0)
1549
+ },
1550
+ {
1551
+ id: "kneeHug",
1552
+ label: "体育座り",
1553
+ labelEn: "Knee Hug",
1554
+ group: "sitting",
1555
+ bones: {
1556
+ chest: r(15, 0, 0),
1557
+ head: r(5, 0, 0),
1558
+ leftThigh: r(-150, 0, 4),
1559
+ rightThigh: r(-150, 0, -4),
1560
+ leftLowerLeg: r(150, 0, 0),
1561
+ rightLowerLeg: r(150, 0, 0),
1562
+ leftUpperArm: r(-70, 0, -5),
1563
+ leftForearm: r(43, 0, -55),
1564
+ rightUpperArm: r(-70, 0, 5),
1565
+ rightForearm: r(43, 0, 55)
1566
+ },
1567
+ rootOffset: r(0, -.75, 0)
1568
+ },
1569
+ {
1570
+ id: "kneeling",
1571
+ label: "片膝立ち",
1572
+ labelEn: "Kneeling",
1573
+ group: "sitting",
1574
+ bones: {
1575
+ chest: r(8, 0, 0),
1576
+ leftThigh: r(0, 0, 3),
1577
+ leftLowerLeg: r(90, 0, 0),
1578
+ rightThigh: r(-90, 0, -5),
1579
+ rightLowerLeg: r(90, 0, 0),
1580
+ rightUpperArm: r(-40, 0, -5),
1581
+ rightForearm: r(-50, 0, 0),
1582
+ leftUpperArm: r(-5, 0, 8),
1583
+ leftForearm: r(-15, 0, 0)
1584
+ },
1585
+ rootOffset: r(0, -.41, 0)
1586
+ },
1587
+ {
1588
+ id: "seiza",
1589
+ label: "正座",
1590
+ labelEn: "Seiza",
1591
+ group: "sitting",
1592
+ bones: {
1593
+ chest: r(2, 0, 0),
1594
+ leftThigh: r(-55, 0, 6),
1595
+ rightThigh: r(-55, 0, -6),
1596
+ leftLowerLeg: r(145, 0, 0),
1597
+ rightLowerLeg: r(145, 0, 0),
1598
+ leftUpperArm: r(-25, 0, 5),
1599
+ rightUpperArm: r(-25, 0, -5),
1600
+ leftForearm: r(-30, 0, 0),
1601
+ rightForearm: r(-30, 0, 0)
1602
+ },
1603
+ rootOffset: r(0, -.58, 0)
1604
+ },
1605
+ {
1606
+ id: "crouching",
1607
+ label: "しゃがむ",
1608
+ labelEn: "Crouching",
1609
+ group: "sitting",
1610
+ bones: {
1611
+ chest: r(25, 0, 0),
1612
+ head: r(-15, 0, 0),
1613
+ leftThigh: r(-110, 0, 15),
1614
+ rightThigh: r(-110, 0, -15),
1615
+ leftLowerLeg: r(130, 0, 0),
1616
+ rightLowerLeg: r(130, 0, 0),
1617
+ leftUpperArm: r(-60, 0, 10),
1618
+ rightUpperArm: r(-60, 0, -10),
1619
+ leftForearm: r(-40, 0, 0),
1620
+ rightForearm: r(-40, 0, 0)
1621
+ },
1622
+ rootOffset: r(0, -.56, 0)
1623
+ },
1624
+ {
1625
+ id: "lyingDown",
1626
+ label: "仰向け",
1627
+ labelEn: "Lying Down",
1628
+ group: "sitting",
1629
+ bones: {
1630
+ hips: r(-90, 0, 0),
1631
+ leftUpperArm: r(0, 0, 15),
1632
+ rightUpperArm: r(0, 0, -15),
1633
+ leftThigh: r(0, 0, 4),
1634
+ rightThigh: r(-40, 0, -4),
1635
+ rightLowerLeg: r(70, 0, 0)
1636
+ },
1637
+ rootOffset: r(0, -.82, 0)
1638
+ }
1639
+ ];
1640
+ var POSE_PRESET_MAP = Object.fromEntries(POSE_PRESETS.map((preset) => [preset.id, preset]));
1641
+ //#endregion
1642
+ //#region server/poseSpec.ts
1643
+ /** Presets quoted in full as worked examples. Chosen to cover arms, sitting and lying. */
1644
+ var EXAMPLE_PRESETS = [
1645
+ "standing",
1646
+ "handsOnHips",
1647
+ "pointing",
1648
+ "sitting",
1649
+ "kneeling",
1650
+ "lyingDown"
1651
+ ];
1652
+ var fmt = (value) => String(Math.round(value * 1e3) / 1e3);
1653
+ var vec = (v) => `(${fmt(v.x)}, ${fmt(v.y)}, ${fmt(v.z)})`;
1654
+ var range = (r) => `${r.min}..${r.max}`;
1655
+ function boneTable() {
1656
+ return [
1657
+ "| id | parent | pivot offset from parent pivot (m) | Japanese name |",
1658
+ "|---|---|---|---|",
1659
+ ...BONES.map((bone) => `| ${bone.id} | ${bone.parent ?? "(root)"} | ${vec(bone.offset)} | ${bone.label} |`)
1660
+ ].join("\n");
1661
+ }
1662
+ function presetExamples() {
1663
+ return EXAMPLE_PRESETS.map((id) => {
1664
+ const preset = POSE_PRESET_MAP[id];
1665
+ const pose = {
1666
+ bones: preset.bones,
1667
+ ...preset.rootOffset ? { rootOffset: preset.rootOffset } : {}
1668
+ };
1669
+ return `${preset.labelEn} (${preset.label}):\n\`${JSON.stringify(pose)}\``;
1670
+ }).join("\n\n");
1671
+ }
1672
+ function cameraTable() {
1673
+ return [
1674
+ "| preset | position | target |",
1675
+ "|---|---|---|",
1676
+ ...CAMERA_PRESETS.map((preset) => `| ${preset.id} (${preset.label}) | ${vec(preset.position)} | ${vec(preset.target)} |`)
1677
+ ].join("\n");
1678
+ }
1679
+ /**
1680
+ * Pose authoring guide handed to AI agents by the `get_pose_spec` tool. Bone
1681
+ * layout, examples, cameras and ranges are read from the app's own constants so
1682
+ * the guide cannot drift from the code; the axis conventions are prose.
1683
+ */
1684
+ function buildPoseSpec() {
1685
+ return `# PoseRef pose authoring guide
1686
+
1687
+ PoseRef poses a featureless mannequin and frames it with a camera to make reference images for image AIs.
1688
+ You edit the scene with \`set_scene\` and check the result with \`render_views\`.
1689
+
1690
+ ## Workflow
1691
+ 1. \`get_scene\` if you need the current state (for small edits use pose.mode "merge").
1692
+ 2. \`set_scene\` with pose, and camera if the request mentions an angle or composition.
1693
+ 3. \`render_views\` (current + front + left by default) and compare the picture with the request.
1694
+ 4. For contact (hand on hip, elbow on knee, hands on the floor), place it with \`reach\` (see "Contact with reach")
1695
+ and confirm with \`get_joint_positions\`; pictures are easy to misjudge. Feet-on-floor is handled by
1696
+ set_scene's automatic grounding.
1697
+ 5. Fix what is wrong and render again. Two or three rounds are usually enough: the user fine-tunes by hand afterwards.
1698
+ If a combined pose + camera change keeps failing, settle the pose first, then the camera.
1699
+
1700
+ ## World and character axes
1701
+ - The character stands at the origin facing +Z. +X is the CHARACTER'S LEFT, +Y is up. Units are meters.
1702
+ - Seen from a front camera (+Z), the character's left side appears on the RIGHT of the image.
1703
+ - \`character.yaw\` turns the whole body around Y; +90 makes it face +X.
1704
+
1705
+ ## Bones
1706
+ Rotations are local Euler angles in degrees, order XYZ (three.js: Z is applied first, then Y, then X),
1707
+ relative to the parent bone. A parent's rotation carries all of its children.
1708
+ Standing height: hips pivot ${fmt(HIPS_BASE_Y)} m above the floor, top of head about 1.72 m.
1709
+ The hand extends about 0.18 m past the wrist; the sole is about 0.48 m below the knee pivot.
1710
+
1711
+ ${boneTable()}
1712
+
1713
+ ## Where a hanging limb points (upper arm, forearm, hand, thigh, lower leg)
1714
+ At rest these point down (-Y). With rotation (x, y = 0, z) a limb points at
1715
+ \`dir = ( sin z, -cos z * cos x, -cos z * sin x )\` in its parent's space
1716
+ (+X = character's left, +Y = up, +Z = forward).
1717
+ - Arms: x < 0 swings the arm forward and up, x > 0 swings it back.
1718
+ Left arm z > 0 raises it sideways; the RIGHT arm uses z < 0 (mirrored).
1719
+ Straight overhead: left z = 170..180 (right z = -170..-180), or x = -170..-180.
1720
+ - Elbow: forearm x < 0 folds the forearm forward relative to the upper arm; -90 is a right angle.
1721
+ - Legs: thigh x < 0 lifts the leg forward (-90 = horizontal, as when sitting), x > 0 moves it behind.
1722
+ Left thigh z > 0 spreads it outward; the right thigh uses z < 0.
1723
+ - Knee: lower leg x > 0 folds the shin backward; 90 is a right angle.
1724
+ - y on a limb only twists it around its own length.
1725
+
1726
+ ## Torso, neck and head (they point up)
1727
+ - x > 0 bends forward, x < 0 leans back.
1728
+ - y > 0 turns toward the character's LEFT.
1729
+ - z > 0 tilts sideways toward the character's RIGHT (-X).
1730
+ - hips rotate the whole body; hips x = -90 lays it on its back.
1731
+
1732
+ ## Common mistakes
1733
+ - Bending the chest forward also swings the arms backward, because they hang from the chest.
1734
+ When the chest leans by +a degrees, subtract about a from the upper arms' x to keep them pointing the same way in the world.
1735
+ - Left and right use opposite z signs for arms and legs. Check the rendered front view: the character's
1736
+ left hand is on the image's right.
1737
+ - Poses where body parts touch (hand on chin, elbow on knee) rarely land on the first try. Measure with
1738
+ \`get_joint_positions\`: limb radii are about upper arm 0.05, forearm 0.043, thigh 0.068, shin 0.052 m and the
1739
+ hand is 0.02-0.04 m thick, so an elbow resting on a knee is ~0.11 m from it and a palm on the chin is ~0.03-0.06 m.
1740
+
1741
+ ## Composition and readability
1742
+ The picture is a reference for an image AI, so the pose must read from its silhouette in the 'current' view.
1743
+ - Decide first where the character acts toward (a target, a direction of travel, something it looks at), then
1744
+ place that direction across the picture rather than straight at or away from the camera, so the key limbs are
1745
+ seen from the side instead of foreshortened.
1746
+ - Actions aimed at a target are done side-on: drawing a bow, aiming a rifle, throwing, swinging a bat, club or
1747
+ racket. The lead side (for a right-handed person the left arm and left shoulder) faces the target, the chest
1748
+ faces 90 degrees away from it, and the head turns toward the target. Turn the character with \`character.yaw\`
1749
+ and keep the target direction across the frame.
1750
+ - Read camera words in the character's own frame: "from the front" means the camera sees the chest and face side
1751
+ of the body, "from the side" sees a shoulder, "from behind" sees the back. For a side-on action seen from the
1752
+ front, the target is therefore off to one side of the picture (for a right-handed person, on the character's
1753
+ left, which is the image's right), not behind the camera.
1754
+ - Never square the body or the head to the camera just to show the face. A head turned toward the target, even
1755
+ in profile, is part of the stance and reads better than a face looking into the lens.
1756
+ - In render_views, check that the arms and legs that carry the action do not overlap the torso or each other and
1757
+ do not point at the camera. If they do, turn the character (often 15-30 degrees of yaw is enough) or move the
1758
+ camera before fine-tuning angles. Do not finish with a readability problem you have noticed; fix it first.
1759
+
1760
+ ## Contact with reach (inverse kinematics)
1761
+ \`reach\` moves a landmark (effector) onto another landmark or a world point by rotating a chain of bones, so you
1762
+ do not have to work out the angles. Elbows and knees only bend the natural way; chest, neck and head stay within
1763
+ natural limits.
1764
+ - Default chains: elbow -> upper arm; wrist, palm, fingertips -> upper arm + forearm; knee -> thigh;
1765
+ ankle, sole, toe -> thigh + lower leg; chin, nose, head -> neck + head; neckBase -> chest.
1766
+ - When a chain has an elbow or knee but not the bone above it (e.g. \`["rightForearm"]\` after an earlier goal
1767
+ used the upper arm), the upper arm or thigh may still twist about its own length to aim the bend. That twist
1768
+ keeps the elbow or knee where the earlier goal put it.
1769
+ - Goals are solved in order and re-checked on every pass. Bones used by an earlier goal are left out of later
1770
+ goals' default chains, so list the contact that must hold most firmly first.
1771
+ - \`offset\` is added to the target in world space. Joint landmarks sit inside the limbs, so leave the limb radii
1772
+ between them: an elbow resting on top of a knee is
1773
+ \`{"effector":"rightElbow","target":"rightKnee","offset":{"x":0,"y":0.11,"z":0}}\`.
1774
+ - If a goal is reported NOT reached, the body is too far away for that chain. Change the rough pose, add a parent
1775
+ bone to its chain (e.g. \`["chest","rightUpperArm"]\`), or add a second goal that moves the other body part
1776
+ toward the first.
1777
+ - Two goals that only point at each other can meet anywhere. Anchor one to a place: hands together in front of the
1778
+ chest is rightPalm -> a world point such as \`{"x":0,"y":1.25,"z":0.3}\`, then leftPalm -> rightPalm.
1779
+ - Straight limbs bend with the elbow down and slightly out and back, and the knee forward; an already bent limb
1780
+ keeps its bend direction, so pre-bend it in the rough pose if you want another direction.
1781
+ - Hand on hip: \`{"goals":[{"effector":"leftPalm","target":"pelvis","offset":{"x":0.17,"y":0.02,"z":0}}]}\`
1782
+ (offsets are in world space; rotate them when the character is turned).
1783
+
1784
+ ## Grounding (pose.rootOffset)
1785
+ \`set_scene\` grounds the body for you: after applying a pose it raises or lowers the hips so the lowest body
1786
+ point (sole, knee, buttocks or back) rests on the floor, and reports how far it moved them. You only need a
1787
+ rough rootOffset.y. For a chair pose the feet end up on the floor and the body sits in the air, because there is
1788
+ no chair. For jumps or other airborne poses pass \`pose.ground: false\` and set rootOffset.y yourself (a mid-jump
1789
+ preset uses +0.25).
1790
+
1791
+ ## Camera
1792
+ \`camera.position\` and \`camera.target\` are in meters, \`camera.fov\` is the vertical field of view in degrees
1793
+ (default 45). High angle: raise position.y to 3..4. Low angle: position.y 0.2..0.4 with target.y about 1.0.
1794
+ A lower fov from farther away flattens perspective.
1795
+
1796
+ ${cameraTable()}
1797
+
1798
+ Aspect ratios: ${ASPECT_RATIOS.map((option) => option.id).join(", ")}.
1799
+
1800
+ ## Accepted ranges
1801
+ Bone angles ${range(RANGES.angle)} (normalized to -180..180), rootOffset ${range(RANGES.rootOffset)},
1802
+ character position x ${range(RANGES.characterX)} / y ${range(RANGES.characterY)} / z ${range(RANGES.characterZ)},
1803
+ camera position ${range(RANGES.cameraPosition)}, camera target ${range(RANGES.cameraTarget)}, fov ${range(RANGES.fov)}.
1804
+
1805
+ ## Examples (pose.bones and rootOffset of built-in presets)
1806
+ ${presetExamples()}
1807
+ `;
1808
+ }
1809
+ //#endregion
1810
+ //#region server/version.ts
1811
+ /**
1812
+ * The folder holding PoseRef's package.json, found by walking up from this
1813
+ * file. That works from server/*.ts (development), dist-server/cli.js (the
1814
+ * published build, including inside npx's cache) and Vite's bundled config in
1815
+ * node_modules/.vite-temp alike. Built with path functions rather than
1816
+ * `new URL(..., import.meta.url)`, which Vite would rewrite into an asset.
1817
+ */
1818
+ function findPackageRoot() {
1819
+ let dir = dirname(fileURLToPath(import.meta.url));
1820
+ for (let depth = 0; depth < 6; depth++) {
1821
+ const manifest = join(dir, "package.json");
1822
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "poseref") return dir;
1823
+ dir = dirname(dir);
1824
+ }
1825
+ throw new Error("Could not find the PoseRef package.json.");
1826
+ }
1827
+ var PACKAGE_ROOT = findPackageRoot();
1828
+ var POSEREF_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
1829
+ //#endregion
1830
+ //#region server/mcp.ts
1831
+ var DEFAULT_VIEWS = [
1832
+ "current",
1833
+ "front",
1834
+ "left"
1835
+ ];
1836
+ var DEFAULT_VIEW_SIZE = 480;
1837
+ /** A contact within 2 cm reads as touching in the render. */
1838
+ var REACHED_WITHIN = .02;
1839
+ var INSTRUCTIONS = `PoseRef controls a posable mannequin and camera in the user's browser to build pose and composition reference images.
1840
+ Call get_pose_spec once before your first set_scene: it explains the bone axes and sign conventions, which are easy to get wrong.
1841
+ Before posing, decide which way the character acts (a target, a direction) and keep that direction across the picture so the action reads in silhouette; aiming, throwing and swinging are done side-on (see the spec's "Composition and readability").
1842
+ Then loop: set_scene -> render_views -> compare with the request and check the silhouette from the 'current' view -> adjust. When the request has body parts touching (hand on hip, elbow on knee, hands together), set the rough pose first and then place the contacts with reach (inverse kinematics) instead of guessing angles; confirm with get_joint_positions and only report contact the numbers show. set_scene puts the body on the floor automatically. Stop after two or three rounds when the pose is roughly right; the user fine-tunes by hand and can undo your changes in the app.`;
1843
+ function text(value) {
1844
+ return { content: [{
1845
+ type: "text",
1846
+ text: value
1847
+ }] };
1848
+ }
1849
+ /** One-line JSON without zero rotations; bones left out are 0, matching set_scene's 'replace' mode. */
1850
+ function compactScene(scene) {
1851
+ const bones = Object.fromEntries(Object.entries(scene.pose?.bones ?? {}).filter(([, r]) => r && (r.x !== 0 || r.y !== 0 || r.z !== 0)));
1852
+ return JSON.stringify({
1853
+ ...scene,
1854
+ pose: {
1855
+ ...scene.pose,
1856
+ bones
1857
+ }
1858
+ });
1859
+ }
1860
+ function failure(error) {
1861
+ return {
1862
+ content: [{
1863
+ type: "text",
1864
+ text: error instanceof Error ? error.message : String(error)
1865
+ }],
1866
+ isError: true
1867
+ };
1868
+ }
1869
+ /**
1870
+ * Saves the review image to a file as well. Clients that cannot show MCP image
1871
+ * content to the model (some Codex versions) can open the file instead.
1872
+ */
1873
+ async function saveRender(pngBase64) {
1874
+ const dir = rendersDir();
1875
+ const path = join(dir, `review-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.png`);
1876
+ await writeFile(path, Buffer.from(pngBase64, "base64"));
1877
+ const files = (await readdir(dir)).filter((name) => name.endsWith(".png")).sort();
1878
+ await Promise.all(files.slice(0, -20).map((name) => rm(join(dir, name), { force: true })));
1879
+ return path;
1880
+ }
1881
+ /** The PoseRef tools, working through any bridge: in-process for HTTP, remote for stdio. */
1882
+ function createPoseRefMcpServer(bridge) {
1883
+ const server = new McpServer({
1884
+ name: "poseref",
1885
+ version: POSEREF_VERSION
1886
+ }, { instructions: INSTRUCTIONS });
1887
+ server.registerTool("get_pose_spec", {
1888
+ title: "Get pose authoring guide",
1889
+ description: "Returns the guide to PoseRef bones, axis conventions, grounding, camera and example poses. Read it before the first set_scene.",
1890
+ annotations: { readOnlyHint: true }
1891
+ }, async () => text(buildPoseSpec()));
1892
+ server.registerTool("get_scene", {
1893
+ title: "Get current scene",
1894
+ description: "Returns the current pose, character transform, camera and aspect ratio from the open PoseRef tab as JSON. Bones not listed have zero rotation.",
1895
+ annotations: { readOnlyHint: true }
1896
+ }, async () => {
1897
+ try {
1898
+ return text(compactScene(await bridge.request("getScene", {})));
1899
+ } catch (error) {
1900
+ return failure(error);
1901
+ }
1902
+ });
1903
+ server.registerTool("set_scene", {
1904
+ title: "Set pose and camera",
1905
+ description: "Applies a pose, character transform, camera and/or aspect ratio to the open PoseRef tab in one undoable step. Omitted sections are left unchanged. When a pose is given, the hips are moved so the body rests on the floor (turn off with pose.ground = false). Returns the resulting scene. Follow up with render_views to check the result.",
1906
+ inputSchema: sceneSchema
1907
+ }, async (scene) => {
1908
+ try {
1909
+ const { scene: applied, groundShift } = await bridge.request("setScene", {
1910
+ version: 1,
1911
+ ...scene
1912
+ });
1913
+ return text(`Applied.${groundShift === null ? "" : groundShift === 0 ? " Already resting on the floor." : ` Grounded: hips moved ${groundShift > 0 ? "up" : "down"} by ${Math.abs(groundShift)} m so the lowest body point rests on the floor.`} Current scene (bones not listed are 0): ${compactScene(applied)}`);
1914
+ } catch (error) {
1915
+ return failure(error);
1916
+ }
1917
+ });
1918
+ server.registerTool("render_views", {
1919
+ title: "Render review views",
1920
+ description: "Renders the scene from several views side by side in one labeled PNG so you can check the pose. 'current' shows the user's camera and composition. The image is also saved to a file whose path is returned.",
1921
+ inputSchema: renderViewsSchema,
1922
+ annotations: { readOnlyHint: true }
1923
+ }, async ({ views, size }) => {
1924
+ try {
1925
+ const result = await bridge.request("renderViews", {
1926
+ views: views ?? [...DEFAULT_VIEWS],
1927
+ size: size ?? DEFAULT_VIEW_SIZE
1928
+ });
1929
+ const path = await saveRender(result.pngBase64);
1930
+ return { content: [{
1931
+ type: "image",
1932
+ data: result.pngBase64,
1933
+ mimeType: "image/png"
1934
+ }, {
1935
+ type: "text",
1936
+ text: `Rendered ${result.width}x${result.height} px. Saved to ${path}`
1937
+ }] };
1938
+ } catch (error) {
1939
+ return failure(error);
1940
+ }
1941
+ });
1942
+ server.registerTool("get_joint_positions", {
1943
+ title: "Get joint positions",
1944
+ description: `Returns world positions in meters of joint landmarks (${JOINT_IDS.join(", ")}), the height of the lowest body point (lowestY, 0 = on the floor), and distances for the requested pairs. Use it to check contact instead of judging from the picture: joint landmarks sit inside the limbs, so two limbs touch when their distance is about the sum of the limb radii (elbow resting on knee ~0.11 m); chin and nose are on the head surface, so a palm touches the chin at ~0.03-0.06 m.`,
1945
+ inputSchema: jointPositionsSchema,
1946
+ annotations: { readOnlyHint: true }
1947
+ }, async ({ pairs }) => {
1948
+ try {
1949
+ const report = await bridge.request("getJoints", {});
1950
+ const distances = (pairs ?? []).map(([a, b]) => {
1951
+ const p = report.joints[a];
1952
+ const q = report.joints[b];
1953
+ const d = p && q ? Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z) : NaN;
1954
+ return {
1955
+ from: a,
1956
+ to: b,
1957
+ distance: Math.round(d * 1e3) / 1e3
1958
+ };
1959
+ });
1960
+ return text(JSON.stringify({
1961
+ lowestY: report.lowestY,
1962
+ distances,
1963
+ joints: report.joints
1964
+ }));
1965
+ } catch (error) {
1966
+ return failure(error);
1967
+ }
1968
+ });
1969
+ server.registerTool("reach", {
1970
+ title: "Move joints to targets (IK)",
1971
+ description: "Inverse kinematics: rotates bones so a landmark (effector) moves onto another landmark or a world point, e.g. an elbow onto a knee or a palm under the chin. Elbows and knees only bend the natural way and the torso and head stay within natural limits. Only the chain bones change, except that the upper arm or thigh above a chained elbow or knee may twist about its own length, which keeps that joint in place; the result is one undoable step. Set the rough pose with set_scene first, then use reach for contacts, then render_views to check.",
1972
+ inputSchema: reachSchema
1973
+ }, async ({ goals, ground }) => {
1974
+ try {
1975
+ const scene = await bridge.request("getScene", {});
1976
+ const frame = {
1977
+ bones: scene.pose.bones,
1978
+ rootOffset: scene.pose.rootOffset,
1979
+ characterPosition: scene.character.position,
1980
+ characterYaw: scene.character.yaw
1981
+ };
1982
+ const chains = resolveChains(goals);
1983
+ for (const [index, goal] of goals.entries()) {
1984
+ const problem = checkGoal(goal, chains[index]);
1985
+ if (problem) return failure(new Error(problem));
1986
+ }
1987
+ const result = solveIk(frame, goals, chains);
1988
+ const bones = Object.fromEntries(result.changed.map((bone) => [bone, result.bones[bone]]));
1989
+ const { groundShift } = await bridge.request("setScene", {
1990
+ version: 1,
1991
+ pose: {
1992
+ mode: "merge",
1993
+ bones,
1994
+ ground
1995
+ }
1996
+ });
1997
+ const lines = result.goals.map((goal) => `- ${goal.effector}: ${goal.error <= REACHED_WITHIN ? "reached" : "NOT reached"}, ${goal.error} m from the target (rotated ${goal.chain.join(", ")})`);
1998
+ const advice = result.goals.some((goal) => goal.error > REACHED_WITHIN) ? "\nA target out of reach leaves the limb pointing at it but short. Move the body closer first (lean the chest, bend the legs), add a parent bone such as chest to that goal's chain, or add a goal that moves the other body part toward this one." : "";
1999
+ const grounding = groundShift ? `\nGrounded: hips moved ${groundShift > 0 ? "up" : "down"} by ${Math.abs(groundShift)} m.` : "";
2000
+ return text(`${lines.join("\n")}${advice}${grounding}\nNew rotations: ${JSON.stringify(bones)}`);
2001
+ } catch (error) {
2002
+ return failure(error);
2003
+ }
2004
+ });
2005
+ return server;
2006
+ }
2007
+ /** Stateless Streamable HTTP handler: every request gets a fresh McpServer bound to the shared bridge. */
2008
+ function createPoseRefMcpHandler(bridge) {
2009
+ return createMcpHandler(() => createPoseRefMcpServer(bridge));
2010
+ }
2011
+ //#endregion
2012
+ //#region server/app.ts
2013
+ var MCP_PATH = "/mcp";
2014
+ /** Loopback only: the server drives the user's browser tab and must not be reachable from the network. */
2015
+ var HOST = "127.0.0.1";
2016
+ var CONTENT_TYPES = {
2017
+ ".html": "text/html; charset=utf-8",
2018
+ ".js": "text/javascript; charset=utf-8",
2019
+ ".css": "text/css; charset=utf-8",
2020
+ ".json": "application/json",
2021
+ ".svg": "image/svg+xml",
2022
+ ".png": "image/png",
2023
+ ".ico": "image/x-icon",
2024
+ ".woff2": "font/woff2"
2025
+ };
2026
+ async function serveStatic(root, req, res) {
2027
+ const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
2028
+ let file = normalize(join(root, pathname));
2029
+ if (file !== root && !file.startsWith(root + sep)) {
2030
+ res.writeHead(403).end();
2031
+ return;
2032
+ }
2033
+ const info = await stat(file).catch(() => null);
2034
+ if (!info || info.isDirectory()) file = join(root, "index.html");
2035
+ const type = CONTENT_TYPES[extname(file)] ?? "application/octet-stream";
2036
+ const isHtml = type.startsWith("text/html");
2037
+ res.writeHead(200, {
2038
+ "Content-Type": type,
2039
+ "Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable"
2040
+ });
2041
+ createReadStream(file).pipe(res);
2042
+ }
2043
+ /**
2044
+ * One local process that serves the PoseRef app, the WebSocket the app connects
2045
+ * back on (`/ws`), and two ways in for AI agents: the Streamable HTTP MCP
2046
+ * endpoint (`/mcp`) for agents registered by URL, and the internal endpoints
2047
+ * (`/poseref/*`) that the stdio MCP processes started by `poseref mcp` use.
2048
+ */
2049
+ function createPoseRefServer(options) {
2050
+ const appUrl = `http://${HOST}:${options.port}`;
2051
+ const allowedOrigins = /* @__PURE__ */ new Set([
2052
+ appUrl,
2053
+ `http://localhost:${options.port}`,
2054
+ ...options.extraOrigins ?? []
2055
+ ]);
2056
+ const bridge = new BrowserBridge(appUrl);
2057
+ const mcp = toNodeHandler(createPoseRefMcpHandler(bridge));
2058
+ const staticRoot = options.staticDir ? resolve(options.staticDir) : null;
2059
+ const validateHost = localhostHostValidation();
2060
+ const validateOrigin = localhostOriginValidation();
2061
+ const internal = createInternalHandler({
2062
+ secret: options.secret,
2063
+ version: options.version,
2064
+ bridge,
2065
+ onShutdown: options.onShutdown
2066
+ });
2067
+ const server = createServer((req, res) => {
2068
+ if (!validateHost(req, res)) return;
2069
+ if (internal(req, res)) return;
2070
+ if (new URL(req.url ?? "/", "http://localhost").pathname === "/mcp") {
2071
+ if (!validateOrigin(req, res)) return;
2072
+ mcp(req, res);
2073
+ return;
2074
+ }
2075
+ if (staticRoot) {
2076
+ serveStatic(staticRoot, req, res).catch(() => {
2077
+ if (!res.headersSent) res.writeHead(500);
2078
+ res.end();
2079
+ });
2080
+ return;
2081
+ }
2082
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("PoseRef MCP server. The app is served by Vite.");
2083
+ });
2084
+ const sockets = new WebSocketServer({ noServer: true });
2085
+ server.on("upgrade", (req, socket, head) => {
2086
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws" || !allowedOrigins.has(req.headers.origin ?? "")) {
2087
+ socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
2088
+ return;
2089
+ }
2090
+ sockets.handleUpgrade(req, socket, head, (ws) => bridge.attach(ws));
2091
+ });
2092
+ return {
2093
+ appUrl,
2094
+ mcpUrl: `${appUrl}${MCP_PATH}`,
2095
+ /** The port actually bound; differs from options.port only when that was 0 (tests). */
2096
+ port() {
2097
+ return server.address().port;
2098
+ },
2099
+ listen() {
2100
+ return new Promise((resolveListen, rejectListen) => {
2101
+ server.once("error", rejectListen);
2102
+ server.listen(options.port, HOST, () => {
2103
+ server.off("error", rejectListen);
2104
+ resolveListen();
2105
+ });
2106
+ });
2107
+ },
2108
+ close() {
2109
+ bridge.closeAll();
2110
+ sockets.close();
2111
+ return new Promise((resolveClose) => server.close(() => resolveClose()));
2112
+ }
2113
+ };
2114
+ }
2115
+ //#endregion
2116
+ //#region server/browser.ts
2117
+ /**
2118
+ * Opens the PoseRef page in the default browser. The URL is always one PoseRef
2119
+ * built itself (http://127.0.0.1:<port>), and no shell is involved, so nothing
2120
+ * from outside can reach the command line.
2121
+ */
2122
+ function openBrowser(url, log) {
2123
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/?$/.test(url)) throw new Error(`Refusing to open unexpected URL: ${url}`);
2124
+ const [command, args] = process.platform === "win32" ? ["explorer.exe", [url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
2125
+ const child = spawn(command, args, {
2126
+ detached: true,
2127
+ stdio: "ignore",
2128
+ windowsHide: true
2129
+ });
2130
+ child.on("error", () => log(`ブラウザで ${url} を開いてください。`));
2131
+ child.unref();
2132
+ }
2133
+ //#endregion
2134
+ //#region server/port.ts
2135
+ /** Fixed default so the MCP URL registered with agents stays valid across restarts. */
2136
+ var DEFAULT_PORT = 47173;
2137
+ function resolvePort() {
2138
+ const value = Number(process.env.POSEREF_PORT);
2139
+ return Number.isInteger(value) && value > 0 && value < 65536 ? value : DEFAULT_PORT;
2140
+ }
2141
+ //#endregion
2142
+ //#region server/remote.ts
2143
+ var START_TIMEOUT_MS = 15e3;
2144
+ var TAB_WAIT_MS = 2e4;
2145
+ var POLL_MS = 300;
2146
+ var REQUEST_TIMEOUT_MS = 3e4;
2147
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2148
+ /**
2149
+ * The bridge used by `poseref mcp` (the stdio MCP server an agent starts). The
2150
+ * tools run in this process; only browser requests go to the long-running
2151
+ * PoseRef server. Nothing is started until a tool actually needs the browser,
2152
+ * because agents launch their MCP servers at the start of every session.
2153
+ *
2154
+ * Before any data is sent, the listener on the port must prove it holds the
2155
+ * per-user secret; a different program on that port is reported, never trusted.
2156
+ */
2157
+ var RemoteBridge = class {
2158
+ options;
2159
+ baseUrl;
2160
+ ready = null;
2161
+ browserOpened = false;
2162
+ constructor(options) {
2163
+ this.options = options;
2164
+ this.baseUrl = `http://127.0.0.1:${options.port}`;
2165
+ }
2166
+ async request(method, params) {
2167
+ try {
2168
+ await this.ensureReady();
2169
+ return await this.call(method, params);
2170
+ } catch (error) {
2171
+ if (!(error instanceof TransportError)) throw error;
2172
+ this.ready = null;
2173
+ await this.ensureReady();
2174
+ return await this.call(method, params);
2175
+ }
2176
+ }
2177
+ ensureReady() {
2178
+ this.ready ??= this.prepare().catch((error) => {
2179
+ this.ready = null;
2180
+ throw error;
2181
+ });
2182
+ return this.ready;
2183
+ }
2184
+ async prepare() {
2185
+ let probe = await this.probe();
2186
+ if (probe.kind === "foreign") throw this.foreignError();
2187
+ if (probe.kind === "absent") {
2188
+ this.options.log("Starting the PoseRef server…");
2189
+ this.options.startServer();
2190
+ probe = await this.waitFor((next) => next.kind !== "absent", START_TIMEOUT_MS);
2191
+ if (probe.kind === "foreign") throw this.foreignError();
2192
+ if (probe.kind === "absent") throw new Error("The PoseRef server did not start. Run `npx poseref start` in a terminal to see why.");
2193
+ }
2194
+ if (this.options.openBrowser && probe.kind === "poseref" && probe.tabs === 0 && !this.browserOpened) {
2195
+ this.browserOpened = true;
2196
+ openBrowser(this.baseUrl, this.options.log);
2197
+ await this.waitFor((next) => next.kind === "poseref" && next.tabs > 0, TAB_WAIT_MS);
2198
+ }
2199
+ }
2200
+ async waitFor(done, timeoutMs) {
2201
+ const deadline = Date.now() + timeoutMs;
2202
+ let probe = await this.probe();
2203
+ while (!done(probe) && Date.now() < deadline) {
2204
+ await sleep(POLL_MS);
2205
+ probe = await this.probe();
2206
+ }
2207
+ return probe;
2208
+ }
2209
+ /** Asks the listener to prove it is PoseRef. Any unexpected answer counts as a foreign program. */
2210
+ async probe() {
2211
+ const nonce = newNonce();
2212
+ let response;
2213
+ try {
2214
+ response = await fetch(`${this.baseUrl}/poseref/identify?nonce=${nonce}`, { signal: AbortSignal.timeout(3e3) });
2215
+ } catch {
2216
+ return { kind: "absent" };
2217
+ }
2218
+ try {
2219
+ const body = await response.json();
2220
+ if (response.ok && body.app === "poseref" && verifyIdentityProof(this.options.secret, nonce, body.proof)) return {
2221
+ kind: "poseref",
2222
+ tabs: typeof body.tabs === "number" ? body.tabs : 0
2223
+ };
2224
+ } catch {}
2225
+ return { kind: "foreign" };
2226
+ }
2227
+ foreignError() {
2228
+ return /* @__PURE__ */ new Error(`Port ${this.options.port} is used by another program, so PoseRef did not send it anything. Ask the user to close that program, or to set POSEREF_PORT to a free port for both the agent and PoseRef.`);
2229
+ }
2230
+ async call(method, params) {
2231
+ let response;
2232
+ try {
2233
+ response = await fetch(`${this.baseUrl}/poseref/bridge`, {
2234
+ method: "POST",
2235
+ headers: {
2236
+ "Content-Type": "application/json",
2237
+ Authorization: `Bearer ${bearerToken(this.options.secret)}`
2238
+ },
2239
+ body: JSON.stringify({
2240
+ method,
2241
+ params
2242
+ }),
2243
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2244
+ });
2245
+ } catch (error) {
2246
+ throw new TransportError(error instanceof Error ? error.message : String(error));
2247
+ }
2248
+ const body = await response.json().catch(() => ({}));
2249
+ if (body.ok === true) return body.result;
2250
+ if (body.ok === false && body.error) throw new Error(body.error);
2251
+ throw new Error(`The PoseRef server rejected the request (HTTP ${response.status}${body.error ? `: ${body.error}` : ""}).`);
2252
+ }
2253
+ };
2254
+ var TransportError = class extends Error {};
2255
+ /**
2256
+ * Starts `poseref start --no-open` detached from the agent, so the server (and
2257
+ * the user's open tab) outlives the agent session that happened to start it.
2258
+ */
2259
+ function spawnDetachedServer(cliPath, log) {
2260
+ const child = spawn(process.execPath, [
2261
+ cliPath,
2262
+ "start",
2263
+ "--no-open"
2264
+ ], {
2265
+ detached: true,
2266
+ stdio: "ignore",
2267
+ windowsHide: true,
2268
+ env: process.env
2269
+ });
2270
+ child.on("error", (error) => log(`Could not start the PoseRef server: ${error.message}`));
2271
+ child.unref();
2272
+ }
2273
+ //#endregion
2274
+ //#region server/setup.ts
2275
+ /** Tools that change the scene. Codex asks before each call unless they are approved in config.toml. */
2276
+ var WRITE_TOOLS = ["set_scene", "reach"];
2277
+ /**
2278
+ * The command agents run to start PoseRef's stdio MCP server. The version is
2279
+ * pinned so a later release (or a compromised one) never runs without the user
2280
+ * re-running setup. Windows needs `cmd /c` because npx is a .cmd script there.
2281
+ */
2282
+ function mcpLaunchCommand(version, platform = process.platform) {
2283
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(version)) throw new Error(`Unexpected version: ${version}`);
2284
+ const npx = [
2285
+ "npx",
2286
+ "-y",
2287
+ `poseref@${version}`,
2288
+ "mcp"
2289
+ ];
2290
+ return platform === "win32" ? [
2291
+ "cmd",
2292
+ "/c",
2293
+ ...npx
2294
+ ] : npx;
2295
+ }
2296
+ /**
2297
+ * Appends approval tables for the write tools that are not configured yet.
2298
+ * Append-only on purpose: rewriting the file with a TOML library would drop the
2299
+ * user's comments and ordering. Existing tables are left exactly as they are.
2300
+ */
2301
+ function appendCodexApprovals(toml, server = "poseref") {
2302
+ const added = WRITE_TOOLS.filter((tool) => {
2303
+ return !new RegExp(`^\\s*\\[\\s*mcp_servers\\.${server}\\.tools\\.${tool}\\s*\\]`, "m").test(toml);
2304
+ });
2305
+ if (added.length === 0) return {
2306
+ text: toml,
2307
+ added
2308
+ };
2309
+ const blocks = added.map((tool) => `[mcp_servers.${server}.tools.${tool}]\napproval_mode = "approve"\n`);
2310
+ return {
2311
+ text: `${toml}${toml.length === 0 || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n"}# Added by \`poseref setup\`: these tools only change the PoseRef tab, and the change can be undone there.
2312
+ ${blocks.join("\n")}`,
2313
+ added: [...added]
2314
+ };
2315
+ }
2316
+ /**
2317
+ * Environment flags for the registration. Only a non-default POSEREF_PORT is
2318
+ * passed on, so agents start and look for PoseRef on the same port as the user.
2319
+ * The flags go after the server name: `claude mcp add --env` takes several
2320
+ * values and would swallow the name, while `--` ends the list.
2321
+ */
2322
+ function registrationEnv(port = resolvePort()) {
2323
+ return port === 47173 ? [] : ["--env", `POSEREF_PORT=${port}`];
2324
+ }
2325
+ /** Every argument PoseRef passes to another CLI is a fixed word or a version, never user text. */
2326
+ var SAFE_ARG = /^[\w@.:/=-]+$/;
2327
+ function run(command, args) {
2328
+ for (const arg of [command, ...args]) if (!SAFE_ARG.test(arg)) throw new Error(`Refusing to run a command with an unexpected argument: ${arg}`);
2329
+ const result = spawnSync(command, args, {
2330
+ encoding: "utf8",
2331
+ shell: process.platform === "win32",
2332
+ windowsHide: true,
2333
+ timeout: 6e4
2334
+ });
2335
+ return {
2336
+ ok: result.status === 0,
2337
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
2338
+ };
2339
+ }
2340
+ function codexConfigPath() {
2341
+ return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
2342
+ }
2343
+ /** Replacing removes the old entry first (the CLIs refuse duplicates), so a failed add leaves none. */
2344
+ var REMOVED_NOTE = " (the previous \"poseref\" entry was already removed; run `npx poseref setup` again)";
2345
+ function claudeSteps(launch, env, log) {
2346
+ if (!run("claude", ["--version"]).ok) {
2347
+ log("- Claude Code: not found (skipped)");
2348
+ return [];
2349
+ }
2350
+ const exists = run("claude", [
2351
+ "mcp",
2352
+ "get",
2353
+ "poseref"
2354
+ ]).ok;
2355
+ return [{
2356
+ describe: `${exists ? "Replace" : "Add"} the "poseref" MCP server for all projects: claude mcp add --scope user ${["poseref", ...env].join(" ")} -- ${launch.join(" ")}`,
2357
+ apply: () => {
2358
+ if (exists) run("claude", [
2359
+ "mcp",
2360
+ "remove",
2361
+ "poseref",
2362
+ "--scope",
2363
+ "user"
2364
+ ]);
2365
+ const added = run("claude", [
2366
+ "mcp",
2367
+ "add",
2368
+ "--scope",
2369
+ "user",
2370
+ "poseref",
2371
+ ...env,
2372
+ "--",
2373
+ ...launch
2374
+ ]);
2375
+ if (!added.ok) throw new Error(`claude mcp add failed${exists ? REMOVED_NOTE : ""}:\n${added.output}`);
2376
+ }
2377
+ }];
2378
+ }
2379
+ function codexSteps(launch, env, log) {
2380
+ if (!run("codex", ["--version"]).ok) {
2381
+ log("- Codex: not found (skipped)");
2382
+ return [];
2383
+ }
2384
+ const exists = run("codex", [
2385
+ "mcp",
2386
+ "get",
2387
+ "poseref"
2388
+ ]).ok;
2389
+ const configPath = codexConfigPath();
2390
+ return [{
2391
+ describe: `${exists ? "Replace" : "Add"} the "poseref" MCP server: codex mcp add ${["poseref", ...env].join(" ")} -- ${launch.join(" ")}`,
2392
+ apply: () => {
2393
+ if (exists) run("codex", [
2394
+ "mcp",
2395
+ "remove",
2396
+ "poseref"
2397
+ ]);
2398
+ const added = run("codex", [
2399
+ "mcp",
2400
+ "add",
2401
+ "poseref",
2402
+ ...env,
2403
+ "--",
2404
+ ...launch
2405
+ ]);
2406
+ if (!added.ok) throw new Error(`codex mcp add failed${exists ? REMOVED_NOTE : ""}:\n${added.output}`);
2407
+ }
2408
+ }, {
2409
+ describe: `Let ${WRITE_TOOLS.join(" and ")} run without a prompt each time: append approval settings to ${configPath} (a backup is kept next to it)`,
2410
+ apply: () => {
2411
+ const { text, added } = appendCodexApprovals(existsSync(configPath) ? readFileSync(configPath, "utf8") : "");
2412
+ if (added.length === 0) return;
2413
+ const backup = `${configPath}.poseref-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
2414
+ if (existsSync(configPath)) copyFileSync(configPath, backup);
2415
+ writeFileSync(configPath, text);
2416
+ if (!run("codex", [
2417
+ "mcp",
2418
+ "get",
2419
+ "poseref"
2420
+ ]).ok) {
2421
+ if (existsSync(backup)) copyFileSync(backup, configPath);
2422
+ throw new Error(`Codex could not read ${configPath} after the change, so it was restored. Add the approval settings by hand (see the README).`);
2423
+ }
2424
+ if (existsSync(backup)) log(` backup: ${backup}`);
2425
+ }
2426
+ }];
2427
+ }
2428
+ async function confirm(question) {
2429
+ const rl = createInterface({
2430
+ input: process.stdin,
2431
+ output: process.stderr
2432
+ });
2433
+ try {
2434
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
2435
+ } finally {
2436
+ rl.close();
2437
+ }
2438
+ }
2439
+ /**
2440
+ * `poseref setup`: registers PoseRef with the agents found on this machine.
2441
+ * Shows every change first and asks before making any.
2442
+ */
2443
+ async function runSetup(options) {
2444
+ const { log } = options;
2445
+ const launch = mcpLaunchCommand(options.version);
2446
+ log(`PoseRef ${options.version} setup`);
2447
+ log("");
2448
+ const env = registrationEnv();
2449
+ const steps = [...claudeSteps(launch, env, log), ...codexSteps(launch, env, log)];
2450
+ if (steps.length === 0) {
2451
+ log("Neither Claude Code (`claude`) nor Codex (`codex`) was found on PATH. Install one, then run this again.");
2452
+ return 1;
2453
+ }
2454
+ log("This will:");
2455
+ for (const step of steps) log(`- ${step.describe}`);
2456
+ log("");
2457
+ if (options.dryRun) {
2458
+ log("Dry run: nothing was changed.");
2459
+ return 0;
2460
+ }
2461
+ if (!options.yes) {
2462
+ if (!process.stdin.isTTY) {
2463
+ log("Not running in an interactive terminal. Re-run with --yes to apply these changes.");
2464
+ return 1;
2465
+ }
2466
+ if (!await confirm("Apply these changes? [y/N] ")) {
2467
+ log("Nothing was changed.");
2468
+ return 1;
2469
+ }
2470
+ }
2471
+ for (const step of steps) step.apply();
2472
+ log("");
2473
+ log("Done. Restart Claude Code / Codex, then ask for a pose, e.g. \"PoseRefで、腕を組んで立っているポーズを作って\".");
2474
+ log("PoseRef and the browser tab start the first time an agent uses a PoseRef tool.");
2475
+ return 0;
2476
+ }
2477
+ //#endregion
2478
+ //#region server/cli.ts
2479
+ var CLI_PATH = fileURLToPath(import.meta.url);
2480
+ var DIST_DIR = join(PACKAGE_ROOT, "dist");
2481
+ var HELP = `PoseRef ${POSEREF_VERSION}: pose and composition reference maker for image AIs
2482
+
2483
+ Usage:
2484
+ npx poseref setup [--yes] [--dry-run] Register PoseRef with Claude Code and Codex
2485
+ npx poseref [start] [--no-open] Start PoseRef and open it in the browser
2486
+ npx poseref stop Stop a running PoseRef
2487
+ npx poseref mcp MCP server over stdio (agents run this; not for direct use)
2488
+
2489
+ Environment:
2490
+ POSEREF_PORT Port to use (default 47173)
2491
+ POSEREF_NO_OPEN Set to any value to never open the browser automatically
2492
+ `;
2493
+ /** stderr only: in `mcp` mode stdout carries the MCP protocol and must stay clean. */
2494
+ var log = (message) => process.stderr.write(`${message}\n`);
2495
+ async function isPoseRefRunning(port, secret) {
2496
+ const nonce = newNonce();
2497
+ try {
2498
+ const body = await (await fetch(`http://127.0.0.1:${port}/poseref/identify?nonce=${nonce}`, { signal: AbortSignal.timeout(3e3) })).json();
2499
+ return body.app === "poseref" && verifyIdentityProof(secret, nonce, body.proof);
2500
+ } catch {
2501
+ return false;
2502
+ }
2503
+ }
2504
+ async function start(args) {
2505
+ const open = !args.includes("--no-open") && !process.env.POSEREF_NO_OPEN;
2506
+ if (!existsSync(DIST_DIR)) {
2507
+ log("The app build (dist/) is missing. In a clone of the repository, run `npm run build` first.");
2508
+ return 1;
2509
+ }
2510
+ const port = resolvePort();
2511
+ const secret = loadOrCreateSecret();
2512
+ const server = createPoseRefServer({
2513
+ port,
2514
+ staticDir: DIST_DIR,
2515
+ secret,
2516
+ version: POSEREF_VERSION,
2517
+ onShutdown: () => void server.close().then(() => process.exit(0))
2518
+ });
2519
+ try {
2520
+ await server.listen();
2521
+ } catch (error) {
2522
+ if (error.code !== "EADDRINUSE") throw error;
2523
+ if (await isPoseRefRunning(port, secret)) {
2524
+ log(`PoseRef is already running: ${server.appUrl}`);
2525
+ if (open) openBrowser(server.appUrl, log);
2526
+ return 0;
2527
+ }
2528
+ log(`Port ${port} is used by another program. Close it, or set POSEREF_PORT to a free port (and re-run \`npx poseref setup\`).`);
2529
+ return 1;
2530
+ }
2531
+ log(`PoseRef: ${server.appUrl}`);
2532
+ log(`MCP URL: ${server.mcpUrl}`);
2533
+ log("Press Ctrl+C to stop.");
2534
+ if (open) openBrowser(server.appUrl, log);
2535
+ const shutdown = () => void server.close().then(() => process.exit(0));
2536
+ process.on("SIGINT", shutdown);
2537
+ process.on("SIGTERM", shutdown);
2538
+ return new Promise(() => {});
2539
+ }
2540
+ async function mcp() {
2541
+ console.log = console.error;
2542
+ console.info = console.error;
2543
+ const bridge = new RemoteBridge({
2544
+ port: resolvePort(),
2545
+ secret: loadOrCreateSecret(),
2546
+ startServer: () => spawnDetachedServer(CLI_PATH, log),
2547
+ openBrowser: !process.env.POSEREF_NO_OPEN,
2548
+ log
2549
+ });
2550
+ serveStdio(() => createPoseRefMcpServer(bridge));
2551
+ return new Promise(() => {});
2552
+ }
2553
+ async function stop() {
2554
+ const port = resolvePort();
2555
+ const secret = loadOrCreateSecret();
2556
+ if (!await isPoseRefRunning(port, secret)) {
2557
+ log("PoseRef is not running.");
2558
+ return 0;
2559
+ }
2560
+ const response = await fetch(`http://127.0.0.1:${port}/poseref/shutdown`, {
2561
+ method: "POST",
2562
+ headers: { Authorization: `Bearer ${bearerToken(secret)}` }
2563
+ });
2564
+ log(response.ok ? "PoseRef stopped." : `Could not stop PoseRef (HTTP ${response.status}).`);
2565
+ return response.ok ? 0 : 1;
2566
+ }
2567
+ async function main(argv) {
2568
+ const [command = "start", ...args] = argv;
2569
+ switch (command) {
2570
+ case "start": return start(args);
2571
+ case "--no-open": return start(argv);
2572
+ case "mcp": return mcp();
2573
+ case "setup": return runSetup({
2574
+ version: POSEREF_VERSION,
2575
+ yes: args.includes("--yes"),
2576
+ dryRun: args.includes("--dry-run"),
2577
+ log
2578
+ });
2579
+ case "stop": return stop();
2580
+ case "--version":
2581
+ case "-v":
2582
+ log(POSEREF_VERSION);
2583
+ return 0;
2584
+ case "help":
2585
+ case "--help":
2586
+ case "-h":
2587
+ log(HELP);
2588
+ return 0;
2589
+ default:
2590
+ log(`Unknown command: ${command}\n\n${HELP}`);
2591
+ return 1;
2592
+ }
2593
+ }
2594
+ main(process.argv.slice(2)).then((code) => {
2595
+ process.exitCode = code;
2596
+ }, (error) => {
2597
+ log(error instanceof Error ? error.message : String(error));
2598
+ process.exitCode = 1;
2599
+ });
2600
+ //#endregion
2601
+ export {};