@kubohiroya/turbowarp-realtime-motion-capture 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -14,13 +14,13 @@ multiview pose estimationを使うcamera/fusion application向けの複合Turb
14
14
  - WebGPU MoveNet MultiPoseで最大6人を追跡し、PoseFrame2D v1/v2 JSONを返します。
15
15
  - このpackageが所有するmultiview-pose JSON contractを検証します。
16
16
  - chessboardとOpenCV.jsで共有cameraをcalibrationします。
17
- - 外部PoseFrame3D v1を宣言的なA-Frame avatar rigへretargetします。
17
+ - 外部PoseFrame3D v1をA-FrameのVRMアバターへretargetします。
18
18
  - camera timingを計測し、同期した2D poseを3Dへ統合して、サイリウムで演者を識別します。
19
19
 
20
20
  ## 要件と安全性
21
21
 
22
22
  - 信頼できるunsandboxed custom extensionを利用できるTurboWarp
23
- - QR/時刻用WebRTC 0.3.0、video用Camera Source 0.5.0、avatar用A-Frame 0.3.0
23
+ - QR/時刻用WebRTC 0.3.0、video用Camera Source 0.5.0、avatar用A-Frame 0.5.0
24
24
  - 姿勢推定用TensorFlow.js WebGPUとcalibration用WebAssembly
25
25
 
26
26
  全機能は起動時固定・既定OFFです。MoveNetにCPU/WASM/WebGL fallbackはなく、初回model loadは
@@ -37,7 +37,7 @@ custom extensionとして読み込みます。通常の順序はCamera Source、
37
37
  version固定CDN URL:
38
38
 
39
39
  ```text
40
- https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.2.0/dist/turbowarp-realtime-motion-capture.js
40
+ https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.5.0/dist/turbowarp-realtime-motion-capture.js
41
41
  ```
42
42
 
43
43
  npm packageが公開するのはbrowser向けstandalone bundle、schema、文書です。Composition APIは
package/README.md CHANGED
@@ -14,13 +14,13 @@ Composite TurboWarp blocks for camera and fusion applications using multiview po
14
14
  - Runs WebGPU MoveNet MultiPose for up to six people and reports PoseFrame2D v1/v2 JSON.
15
15
  - Validates the owned multiview-pose JSON contracts.
16
16
  - Calibrates shared cameras with a chessboard and OpenCV.js.
17
- - Retargets external PoseFrame3D v1 data to declarative A-Frame avatar rigs.
17
+ - Retargets external PoseFrame3D v1 data to VRM avatars in A-Frame.
18
18
  - Measures camera timing, fuses synchronized 2D poses into 3D, and identifies performers by glow sticks.
19
19
 
20
20
  ## Requirements and safety
21
21
 
22
22
  - TurboWarp with trusted unsandboxed custom extensions enabled.
23
- - WebRTC 0.3.0 for QR/time, Camera Source 0.5.0 for video, and A-Frame 0.3.0 for avatars.
23
+ - WebRTC 0.3.0 for QR/time, Camera Source 0.5.0 for video, and A-Frame 0.5.0 for avatars.
24
24
  - TensorFlow.js WebGPU support for pose and WebAssembly support for calibration.
25
25
 
26
26
  All features are startup-fixed and OFF by default. MoveNet has no CPU, WASM, or WebGL fallback and
@@ -35,7 +35,7 @@ extension. The usual order is Camera Source, WebRTC, A-Frame, then Realtime Moti
35
35
  not used may be omitted. A version-pinned package URL is:
36
36
 
37
37
  ```text
38
- https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.3.0/dist/turbowarp-realtime-motion-capture.js
38
+ https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.5.0/dist/turbowarp-realtime-motion-capture.js
39
39
  ```
40
40
 
41
41
  The npm package distributes a standalone browser bundle, schemas, and documentation. It does not
@@ -31,6 +31,16 @@
31
31
  "blockType": "REPORTER",
32
32
  "arguments": []
33
33
  },
34
+ {
35
+ "opcode": "avatarExpressionNames",
36
+ "blockType": "REPORTER",
37
+ "arguments": [
38
+ {
39
+ "id": "PERSON_ID",
40
+ "type": "STRING"
41
+ }
42
+ ]
43
+ },
34
44
  {
35
45
  "opcode": "avatarRetargetError",
36
46
  "blockType": "REPORTER",
@@ -525,7 +535,7 @@
525
535
  "type": "STRING"
526
536
  },
527
537
  {
528
- "id": "TEMPLATE_JSON",
538
+ "id": "VRM_URL",
529
539
  "type": "STRING"
530
540
  }
531
541
  ]
@@ -535,6 +545,24 @@
535
545
  "blockType": "COMMAND",
536
546
  "arguments": []
537
547
  },
548
+ {
549
+ "opcode": "setAvatarExpression",
550
+ "blockType": "COMMAND",
551
+ "arguments": [
552
+ {
553
+ "id": "NAME",
554
+ "type": "STRING"
555
+ },
556
+ {
557
+ "id": "PERSON_ID",
558
+ "type": "STRING"
559
+ },
560
+ {
561
+ "id": "WEIGHT",
562
+ "type": "NUMBER"
563
+ }
564
+ ]
565
+ },
538
566
  {
539
567
  "opcode": "setFrameSyncDisplayRefresh",
540
568
  "blockType": "COMMAND",
@@ -541,20 +541,20 @@
541
541
  "opcode": "registerAvatarAsset",
542
542
  "feature": "avatarRetargetV1",
543
543
  "blockType": "COMMAND",
544
- "text": "register avatar asset [ASSET_ID] template JSON [TEMPLATE_JSON] rig JSON [RIG_JSON]",
545
- "description": "Registers an A-Frame 0.3.0 template and its Kalidokit rig-output selector mapping.",
544
+ "text": "register avatar asset [ASSET_ID] VRM [VRM_URL] rig JSON [RIG_JSON]",
545
+ "description": "Registers a VRM model and its root placement for avatars driven through TurboWarp-A-Frame capability v2.",
546
546
  "arguments": {
547
547
  "ASSET_ID": {
548
548
  "type": "STRING",
549
549
  "defaultValue": "actor"
550
550
  },
551
- "TEMPLATE_JSON": {
551
+ "VRM_URL": {
552
552
  "type": "STRING",
553
- "defaultValue": "{\"type\":\"group\",\"children\":[]}"
553
+ "defaultValue": "avatar.vrm"
554
554
  },
555
555
  "RIG_JSON": {
556
556
  "type": "STRING",
557
- "defaultValue": "{\"bones\":[{\"selector\":\"#{avatar}-left-arm\",\"rig\":\"LeftUpperArm\"}]}"
557
+ "defaultValue": "{\"rootScale\":1,\"rootOffset\":[0,0,0]}"
558
558
  }
559
559
  }
560
560
  },
@@ -563,7 +563,7 @@
563
563
  "feature": "avatarRetargetV1",
564
564
  "blockType": "COMMAND",
565
565
  "text": "bind person [PERSON_ID] to avatar [INSTANCE_ID] asset [ASSET_ID] under [PARENT] confidence [CONFIDENCE]",
566
- "description": "Creates an avatar instance and binds one PoseFrame3D person ID to it.",
566
+ "description": "Creates an avatar instance, loads its VRM, and binds one PoseFrame3D person ID to it. The block waits until the VRM is ready.",
567
567
  "arguments": {
568
568
  "PERSON_ID": {
569
569
  "type": "STRING",
@@ -615,6 +615,38 @@
615
615
  }
616
616
  }
617
617
  },
618
+ {
619
+ "opcode": "setAvatarExpression",
620
+ "feature": "avatarRetargetV1",
621
+ "blockType": "COMMAND",
622
+ "text": "set avatar expression [NAME] to [WEIGHT] for person [PERSON_ID]",
623
+ "description": "Sets a VRM expression, weight clamped to 0 through 1, on the avatar bound to a PoseFrame3D person ID. An avatar whose VRM is still loading is skipped.",
624
+ "arguments": {
625
+ "NAME": {
626
+ "type": "STRING",
627
+ "defaultValue": "happy"
628
+ },
629
+ "WEIGHT": {
630
+ "type": "NUMBER",
631
+ "defaultValue": 1
632
+ },
633
+ "PERSON_ID": {
634
+ "type": "STRING",
635
+ "defaultValue": "performer-1"
636
+ }
637
+ }
638
+ },
639
+ {
640
+ "opcode": "avatarExpressionNames",
641
+ "feature": "avatarRetargetV1",
642
+ "blockType": "REPORTER",
643
+ "text": "avatar expressions for person [PERSON_ID]",
644
+ "description": "Returns the VRM expression names of the avatar bound to a person as a JSON array, or an empty array while it loads.",
645
+ "arguments": { "PERSON_ID": {
646
+ "type": "STRING",
647
+ "defaultValue": "performer-1"
648
+ } }
649
+ },
618
650
  {
619
651
  "opcode": "resetAvatarRetarget",
620
652
  "feature": "avatarRetargetV1",
@@ -78248,19 +78280,35 @@
78248
78280
  "loadTemplate",
78249
78281
  "createFromTemplate",
78250
78282
  "setPosition",
78251
- "setRotation",
78252
78283
  "emitEvent",
78253
78284
  "deleteSelector",
78254
78285
  "countSelector",
78286
+ "loadVrm",
78287
+ "setVrmBoneRotation",
78288
+ "setVrmExpression",
78289
+ "vrmExpressionNames",
78255
78290
  "requireVersion"
78256
78291
  ];
78257
- function requireAFramePublicBlocks(runtime) {
78292
+ /**
78293
+ * Returns TurboWarp-A-Frame capability v2, the only version avatar retargeting accepts.
78294
+ * A build that cannot provide v2 is refused rather than driven through an older contract.
78295
+ */
78296
+ function requireAFrameCapability(runtime) {
78258
78297
  const candidate = runtime[AFRAME_CAPABILITY_KEY];
78259
- if (typeof candidate !== "object" || candidate === null) throw new Error("TurboWarp-A-Frame capability v1 must be loaded before avatar retargeting.");
78260
- for (const method of METHODS) if (typeof Reflect.get(candidate, method) !== "function") throw new Error(`TurboWarp-A-Frame capability v1 is missing ${method}().`);
78261
- const capability = candidate;
78262
- if (capability.version !== 1) throw new Error(`TurboWarp-A-Frame capability v1 is required; found version ${String(capability.version)}.`);
78263
- return capability.requireVersion(1);
78298
+ if (typeof candidate !== "object" || candidate === null) throw new Error("TurboWarp-A-Frame capability v2 must be loaded before avatar retargeting.");
78299
+ const requireVersion = Reflect.get(candidate, "requireVersion");
78300
+ if (typeof requireVersion !== "function") throw new Error("TurboWarp-A-Frame capability v2 is missing requireVersion().");
78301
+ let capability;
78302
+ try {
78303
+ capability = requireVersion.call(candidate, 2);
78304
+ } catch (error) {
78305
+ throw new Error(`TurboWarp-A-Frame capability v2 is required: ${error instanceof Error ? error.message : String(error)}`);
78306
+ }
78307
+ if (typeof capability !== "object" || capability === null) throw new Error("TurboWarp-A-Frame capability v2 is required.");
78308
+ for (const method of METHODS) if (typeof Reflect.get(capability, method) !== "function") throw new Error(`TurboWarp-A-Frame capability v2 is missing ${method}(); TurboWarp-A-Frame 0.5.0 or later provides it.`);
78309
+ const version = Reflect.get(capability, "version");
78310
+ if (version !== 2) throw new Error(`TurboWarp-A-Frame capability v2 is required; found version ${String(version)}.`);
78311
+ return capability;
78264
78312
  }
78265
78313
  //#endregion
78266
78314
  //#region node_modules/.pnpm/kalidokit@1.1.5/node_modules/kalidokit/dist/utils/helpers.js
@@ -79815,18 +79863,42 @@
79815
79863
  //#region src/avatar/controller.ts
79816
79864
  var identifiers = /^[A-Za-z0-9._-]{1,64}$/u;
79817
79865
  var eventNames = /^[A-Za-z0-9._:-]{1,80}$/u;
79818
- var KALIDOKIT_RIG_KEY_SET = new Set(KALIDOKIT_RIG_KEYS);
79866
+ /** Every avatar instance is an empty node that the VRM is loaded onto. */
79867
+ var HOLDER_TEMPLATE = JSON.stringify({ type: "empty" });
79868
+ /**
79869
+ * Kalidokit solves a mirrored selfie view: its `Right*` outputs come from the performer's
79870
+ * left landmarks. A stage avatar moves the performer's own side, so each output drives the
79871
+ * opposite VRM humanoid bone.
79872
+ */
79873
+ var VRM_BONES = {
79874
+ RightUpperArm: "leftUpperArm",
79875
+ RightLowerArm: "leftLowerArm",
79876
+ LeftUpperArm: "rightUpperArm",
79877
+ LeftLowerArm: "rightLowerArm",
79878
+ RightHand: "leftHand",
79879
+ LeftHand: "rightHand",
79880
+ RightUpperLeg: "leftUpperLeg",
79881
+ RightLowerLeg: "leftLowerLeg",
79882
+ LeftUpperLeg: "rightUpperLeg",
79883
+ LeftLowerLeg: "rightLowerLeg",
79884
+ Spine: "spine",
79885
+ Hips: "hips"
79886
+ };
79887
+ /**
79888
+ * The joints each Kalidokit output is computed from. Its `Right*` outputs read the
79889
+ * performer's left landmarks (BlazePose 11, 13, 15, 23, 25, 27), and `Left*` the right.
79890
+ */
79819
79891
  var RIG_REQUIRED_JOINTS = {
79820
- RightUpperArm: ["right_shoulder", "right_elbow"],
79821
- RightLowerArm: ["right_elbow", "right_wrist"],
79822
- LeftUpperArm: ["left_shoulder", "left_elbow"],
79823
- LeftLowerArm: ["left_elbow", "left_wrist"],
79824
- RightHand: ["right_wrist"],
79825
- LeftHand: ["left_wrist"],
79826
- RightUpperLeg: ["right_hip", "right_knee"],
79827
- RightLowerLeg: ["right_knee", "right_ankle"],
79828
- LeftUpperLeg: ["left_hip", "left_knee"],
79829
- LeftLowerLeg: ["left_knee", "left_ankle"],
79892
+ RightUpperArm: ["left_shoulder", "left_elbow"],
79893
+ RightLowerArm: ["left_elbow", "left_wrist"],
79894
+ LeftUpperArm: ["right_shoulder", "right_elbow"],
79895
+ LeftLowerArm: ["right_elbow", "right_wrist"],
79896
+ RightHand: ["left_wrist"],
79897
+ LeftHand: ["right_wrist"],
79898
+ RightUpperLeg: ["left_hip", "left_knee"],
79899
+ RightLowerLeg: ["left_knee", "left_ankle"],
79900
+ LeftUpperLeg: ["right_hip", "right_knee"],
79901
+ LeftLowerLeg: ["right_knee", "right_ankle"],
79830
79902
  Spine: [
79831
79903
  "left_shoulder",
79832
79904
  "right_shoulder",
@@ -79845,21 +79917,23 @@
79845
79917
  this.runtime = runtime;
79846
79918
  this.solver = solver;
79847
79919
  }
79848
- registerAsset(assetIdValue, templateJson, rigJson) {
79920
+ registerAsset(assetIdValue, vrmUrlValue, rigJson) {
79849
79921
  const assetId = identifier(assetIdValue, "avatar asset ID");
79850
- parseJsonObject(templateJson, "Avatar template JSON");
79922
+ const vrmUrl = nonEmpty(vrmUrlValue, "VRM URL");
79923
+ if (vrmUrl.length > 2048) throw new Error("VRM URL exceeds 2048 characters.");
79851
79924
  const rig = parseRigMapping(rigJson);
79852
- const aframe = requireAFramePublicBlocks(this.runtime);
79925
+ const aframe = requireAFrameCapability(this.runtime);
79853
79926
  const templateId = `twmp-avatar-${assetId}`;
79854
- aframe.loadTemplate(templateId, templateJson);
79927
+ aframe.loadTemplate(templateId, HOLDER_TEMPLATE);
79855
79928
  this.assets.set(assetId, {
79856
79929
  id: assetId,
79857
79930
  templateId,
79931
+ vrmUrl,
79858
79932
  rig
79859
79933
  });
79860
79934
  this.succeed("configured");
79861
79935
  }
79862
- bind(personIdValue, instanceIdValue, assetIdValue, parentSelectorValue, confidenceValue) {
79936
+ async bind(personIdValue, instanceIdValue, assetIdValue, parentSelectorValue, confidenceValue) {
79863
79937
  const personId = identifier(personIdValue, "person ID");
79864
79938
  const instanceId = identifier(instanceIdValue, "avatar instance ID");
79865
79939
  const assetId = identifier(assetIdValue, "avatar asset ID");
@@ -79871,25 +79945,41 @@
79871
79945
  const existingInstance = [...this.bindings.values()].find((binding) => binding.instanceId === instanceId);
79872
79946
  if (existingInstance && existingInstance.personId !== personId) throw new Error(`Avatar instance is already bound: ${instanceId}`);
79873
79947
  if (!existingPerson && this.bindings.size >= 6) throw new Error("Avatar retargeting supports at most six people.");
79874
- const aframe = requireAFramePublicBlocks(this.runtime);
79948
+ const aframe = requireAFrameCapability(this.runtime);
79875
79949
  if (instanceId !== existingPerson?.instanceId && aframe.countSelector(`#${instanceId}`) > 0) throw new Error(`A-Frame node already exists: ${instanceId}`);
79876
79950
  if (existingPerson) this.removeBinding(aframe, existingPerson);
79877
79951
  if (aframe.countSelector(`#${instanceId}`) > 0) throw new Error(`A-Frame node already exists: ${instanceId}`);
79878
79952
  aframe.createFromTemplate(asset.templateId, instanceId, parentSelector);
79879
- this.bindings.set(personId, {
79953
+ const binding = {
79880
79954
  personId,
79881
79955
  instanceId,
79882
79956
  assetId,
79883
79957
  confidence,
79884
- recognized: false
79885
- });
79958
+ recognized: false,
79959
+ loaded: false
79960
+ };
79961
+ this.bindings.set(personId, binding);
79962
+ this.lastState = "loading";
79963
+ try {
79964
+ await aframe.loadVrm(asset.vrmUrl, `#${instanceId}`);
79965
+ } catch (error) {
79966
+ if (this.bindings.get(personId) === binding) {
79967
+ this.bindings.delete(personId);
79968
+ aframe.deleteSelector(`#${instanceId}`);
79969
+ this.lastState = "error";
79970
+ this.lastError = `${personId}: ${message(error)}`;
79971
+ }
79972
+ throw error;
79973
+ }
79974
+ if (this.bindings.get(personId) !== binding) return;
79975
+ binding.loaded = true;
79886
79976
  this.succeed("bound");
79887
79977
  }
79888
79978
  unbind(personIdValue) {
79889
79979
  const personId = identifier(personIdValue, "person ID");
79890
79980
  const binding = this.bindings.get(personId);
79891
79981
  if (!binding) return;
79892
- this.removeBinding(requireAFramePublicBlocks(this.runtime), binding);
79982
+ this.removeBinding(requireAFrameCapability(this.runtime), binding);
79893
79983
  this.succeed("configured");
79894
79984
  }
79895
79985
  apply(frameJson, pose2dJson) {
@@ -79901,7 +79991,7 @@
79901
79991
  try {
79902
79992
  frame = parseFrame(frameJson);
79903
79993
  pose2d = parsePoseFrame2D(pose2dJson);
79904
- aframe = requireAFramePublicBlocks(this.runtime);
79994
+ aframe = requireAFrameCapability(this.runtime);
79905
79995
  } catch (error) {
79906
79996
  this.lastState = "error";
79907
79997
  this.lastError = message(error);
@@ -79910,23 +80000,26 @@
79910
80000
  const people = new Map(frame.persons.map((person) => [person.personId, person]));
79911
80001
  const screenPeople = new Map(pose2d.persons.map((person) => [person.trackingId, person]));
79912
80002
  const errors = [];
79913
- for (const binding of [...this.bindings.values()]) try {
79914
- if (aframe.countSelector(`#${binding.instanceId}`) !== 1) {
79915
- this.bindings.delete(binding.personId);
79916
- errors.push(`${binding.personId}: avatar instance is missing after scene reset`);
79917
- continue;
79918
- }
79919
- const person = people.get(binding.personId);
79920
- const screenPerson = screenPeople.get(binding.personId);
79921
- if (!person || !screenPerson || person.score < binding.confidence || screenPerson.score < binding.confidence) {
79922
- this.setRecognized(aframe, binding, false, frame.timestampUs);
79923
- continue;
80003
+ for (const binding of [...this.bindings.values()]) {
80004
+ if (!binding.loaded) continue;
80005
+ try {
80006
+ if (aframe.countSelector(`#${binding.instanceId}`) !== 1) {
80007
+ this.bindings.delete(binding.personId);
80008
+ errors.push(`${binding.personId}: avatar instance is missing after scene reset`);
80009
+ continue;
80010
+ }
80011
+ const person = people.get(binding.personId);
80012
+ const screenPerson = screenPeople.get(binding.personId);
80013
+ if (!person || !screenPerson || person.score < binding.confidence || screenPerson.score < binding.confidence) {
80014
+ this.setRecognized(aframe, binding, false, frame.timestampUs);
80015
+ continue;
80016
+ }
80017
+ this.applyPerson(aframe, binding, person, screenPerson, pose2d);
80018
+ this.setRecognized(aframe, binding, true, frame.timestampUs);
80019
+ this.lastUpdated += 1;
80020
+ } catch (error) {
80021
+ errors.push(`${binding.personId}: ${message(error)}`);
79924
80022
  }
79925
- this.applyPerson(aframe, binding, person, screenPerson, pose2d);
79926
- this.setRecognized(aframe, binding, true, frame.timestampUs);
79927
- this.lastUpdated += 1;
79928
- } catch (error) {
79929
- errors.push(`${binding.personId}: ${message(error)}`);
79930
80023
  }
79931
80024
  if (errors.length > 0) {
79932
80025
  this.lastState = "partial";
@@ -79936,7 +80029,7 @@
79936
80029
  reset() {
79937
80030
  const candidate = this.runtime.turbowarpAFrameCapability;
79938
80031
  if (typeof candidate === "object" && candidate !== null) try {
79939
- const aframe = requireAFramePublicBlocks(this.runtime);
80032
+ const aframe = requireAFrameCapability(this.runtime);
79940
80033
  for (const binding of [...this.bindings.values()]) this.removeBinding(aframe, binding);
79941
80034
  } catch {
79942
80035
  this.bindings.clear();
@@ -79946,6 +80039,21 @@
79946
80039
  this.lastUpdated = 0;
79947
80040
  this.succeed("idle");
79948
80041
  }
80042
+ /**
80043
+ * Sets an expression on the VRM bound to a person. Connecting Performance DSL effects to
80044
+ * expressions belongs to the application; this only reaches the right avatar. A binding
80045
+ * whose VRM is still loading is skipped, as frames are.
80046
+ */
80047
+ setExpression(personIdValue, name, weight) {
80048
+ const binding = this.requireBinding(personIdValue);
80049
+ if (!binding.loaded) return;
80050
+ requireAFrameCapability(this.runtime).setVrmExpression(`#${binding.instanceId}`, nonEmpty(name, "expression name"), weight);
80051
+ }
80052
+ expressionNames(personIdValue) {
80053
+ const binding = this.requireBinding(personIdValue);
80054
+ if (!binding.loaded) return [];
80055
+ return requireAFrameCapability(this.runtime).vrmExpressionNames(`#${binding.instanceId}`);
80056
+ }
79949
80057
  bindingCount() {
79950
80058
  return this.bindings.size;
79951
80059
  }
@@ -79973,15 +80081,12 @@
79973
80081
  const [offsetX, offsetY, offsetZ] = asset.rig.rootOffset;
79974
80082
  aframe.setPosition(`#${binding.instanceId}`, root.x * asset.rig.rootScale + offsetX, root.y * asset.rig.rootScale + offsetY, root.z * asset.rig.rootScale + offsetZ);
79975
80083
  }
79976
- for (const bone of asset.rig.bones) this.applyBone(aframe, binding, bone, rig, worldKeypoints, screenKeypoints);
80084
+ for (const key of KALIDOKIT_RIG_KEYS) this.applyBone(aframe, binding, key, rig, worldKeypoints, screenKeypoints);
79977
80085
  }
79978
- applyBone(aframe, binding, bone, rig, worldKeypoints, screenKeypoints) {
79979
- if (!requiredJoints(bone.rig).every((id) => (worldKeypoints.get(id)?.score ?? 0) >= binding.confidence && (screenKeypoints.get(id)?.score ?? 0) >= binding.confidence)) return;
79980
- const selector = expandSelector(bone.selector, binding.instanceId);
79981
- if (aframe.countSelector(selector) !== 1) throw new Error(`Rig selector must match exactly one node: ${selector}`);
79982
- const rotation = rotationForRig(rig, bone.rig);
79983
- const [offsetX, offsetY, offsetZ] = bone.offsetDegrees;
79984
- aframe.setRotation(selector, radiansToDegrees(rotation.x) + offsetX, radiansToDegrees(rotation.y) + offsetY, radiansToDegrees(rotation.z) + offsetZ);
80086
+ applyBone(aframe, binding, key, rig, worldKeypoints, screenKeypoints) {
80087
+ if (!requiredJoints(key).every((id) => (worldKeypoints.get(id)?.score ?? 0) >= binding.confidence && (screenKeypoints.get(id)?.score ?? 0) >= binding.confidence)) return;
80088
+ const [x, y, z] = vrmBoneDegrees(rotationForRig(rig, key));
80089
+ aframe.setVrmBoneRotation(`#${binding.instanceId}`, VRM_BONES[key], x, y, z);
79985
80090
  }
79986
80091
  setRecognized(aframe, binding, recognized, timestampUs) {
79987
80092
  if (binding.recognized === recognized) return;
@@ -79999,6 +80104,12 @@
79999
80104
  aframe.deleteSelector(`#${binding.instanceId}`);
80000
80105
  this.bindings.delete(binding.personId);
80001
80106
  }
80107
+ requireBinding(personIdValue) {
80108
+ const personId = identifier(personIdValue, "person ID");
80109
+ const binding = this.bindings.get(personId);
80110
+ if (!binding) throw new Error(`No avatar is bound to person: ${personId}`);
80111
+ return binding;
80112
+ }
80002
80113
  succeed(state) {
80003
80114
  this.lastState = state;
80004
80115
  this.lastError = "";
@@ -80022,48 +80133,24 @@
80022
80133
  }
80023
80134
  function parseRigMapping(source) {
80024
80135
  const value = parseJsonObject(source, "Avatar rig mapping JSON");
80136
+ if ("bones" in value) throw new Error("Rig mapping no longer takes bones: VRM humanoid bones are driven directly.");
80025
80137
  rejectUnknownKeys(value, /* @__PURE__ */ new Set([
80026
80138
  "rootScale",
80027
80139
  "rootOffset",
80028
80140
  "recognitionStartEvent",
80029
- "recognitionEndEvent",
80030
- "bones"
80141
+ "recognitionEndEvent"
80031
80142
  ]), "rig mapping");
80032
80143
  const rootScale = finite(value.rootScale ?? 1, "rootScale");
80033
80144
  if (rootScale <= 0) throw new Error("rootScale must be greater than zero.");
80034
- const rootOffset = vector3(value.rootOffset ?? [
80035
- 0,
80036
- 0,
80037
- 0
80038
- ], "rootOffset");
80039
- const recognitionStartEvent = eventName(value.recognitionStartEvent ?? "twmp-recognition-start", "recognitionStartEvent");
80040
- const recognitionEndEvent = eventName(value.recognitionEndEvent ?? "twmp-recognition-end", "recognitionEndEvent");
80041
- if (!Array.isArray(value.bones) || value.bones.length === 0 || value.bones.length > 32) throw new Error("Rig mapping bones must contain between 1 and 32 entries.");
80042
80145
  return {
80043
80146
  rootScale,
80044
- rootOffset,
80045
- recognitionStartEvent,
80046
- recognitionEndEvent,
80047
- bones: value.bones.map((entry, index) => parseBone(entry, index))
80048
- };
80049
- }
80050
- function parseBone(value, index) {
80051
- const bone = record(value, `bones[${index}]`);
80052
- rejectUnknownKeys(bone, /* @__PURE__ */ new Set([
80053
- "selector",
80054
- "rig",
80055
- "offsetDegrees"
80056
- ]), `bones[${index}]`);
80057
- const selector = nonEmpty(bone.selector, `bones[${index}].selector`);
80058
- if (!selector.includes("{avatar}")) throw new Error(`bones[${index}].selector must contain {avatar}.`);
80059
- return {
80060
- selector,
80061
- rig: rigKey(bone.rig, `bones[${index}].rig`),
80062
- offsetDegrees: vector3(bone.offsetDegrees ?? [
80147
+ rootOffset: vector3(value.rootOffset ?? [
80063
80148
  0,
80064
80149
  0,
80065
80150
  0
80066
- ], `bones[${index}].offsetDegrees`)
80151
+ ], "rootOffset"),
80152
+ recognitionStartEvent: eventName(value.recognitionStartEvent ?? "twmp-recognition-start", "recognitionStartEvent"),
80153
+ recognitionEndEvent: eventName(value.recognitionEndEvent ?? "twmp-recognition-end", "recognitionEndEvent")
80067
80154
  };
80068
80155
  }
80069
80156
  function parseJsonObject(source, label) {
@@ -80083,10 +80170,6 @@
80083
80170
  const unknown = Object.keys(value).find((key) => !allowed.has(key));
80084
80171
  if (unknown) throw new Error(`${label} contains unknown field: ${unknown}`);
80085
80172
  }
80086
- function rigKey(value, label) {
80087
- if (typeof value !== "string" || !KALIDOKIT_RIG_KEY_SET.has(value)) throw new Error(`${label} must be a supported Kalidokit pose rig key.`);
80088
- return value;
80089
- }
80090
80173
  function vector3(value, label) {
80091
80174
  if (!Array.isArray(value) || value.length !== 3) throw new Error(`${label} must contain exactly three numbers.`);
80092
80175
  return [
@@ -80117,8 +80200,20 @@
80117
80200
  if (!eventNames.test(normalized)) throw new Error(`Invalid ${label}.`);
80118
80201
  return normalized;
80119
80202
  }
80120
- function expandSelector(selector, instanceId) {
80121
- return selector.replaceAll("{avatar}", instanceId);
80203
+ /**
80204
+ * Converts a Kalidokit rotation in radians to Euler degrees on a VRM normalized bone.
80205
+ *
80206
+ * Kalidokit's rotations are for VRM 0.x bone axes, which face -Z; normalized bones use the
80207
+ * VRM 1.0 axes, a half turn about Y away, which negates x and z. Driving the opposite side
80208
+ * (see VRM_BONES) also mirrors the rotation across the body's midline, which negates y and z.
80209
+ * Together the signs become (-x, -y, z).
80210
+ */
80211
+ function vrmBoneDegrees(rotation) {
80212
+ return [
80213
+ -radiansToDegrees(rotation.x),
80214
+ -radiansToDegrees(rotation.y),
80215
+ radiansToDegrees(rotation.z)
80216
+ ];
80122
80217
  }
80123
80218
  function radiansToDegrees(value) {
80124
80219
  return value * 180 / Math.PI;
@@ -82954,11 +83049,11 @@
82954
83049
  }
82955
83050
  registerAvatarAsset(args) {
82956
83051
  this.requireAvatarEnabled();
82957
- this.avatar.registerAsset(Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.TEMPLATE_JSON), Scratch.Cast.toString(args.RIG_JSON));
83052
+ this.avatar.registerAsset(Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.VRM_URL), Scratch.Cast.toString(args.RIG_JSON));
82958
83053
  }
82959
83054
  bindAvatarPerson(args) {
82960
83055
  this.requireAvatarEnabled();
82961
- this.avatar.bind(Scratch.Cast.toString(args.PERSON_ID), Scratch.Cast.toString(args.INSTANCE_ID), Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.PARENT), Scratch.Cast.toNumber(args.CONFIDENCE));
83056
+ return this.avatar.bind(Scratch.Cast.toString(args.PERSON_ID), Scratch.Cast.toString(args.INSTANCE_ID), Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.PARENT), Scratch.Cast.toNumber(args.CONFIDENCE));
82962
83057
  }
82963
83058
  unbindAvatarPerson(args) {
82964
83059
  this.requireAvatarEnabled();
@@ -82968,6 +83063,14 @@
82968
83063
  this.requireAvatarEnabled();
82969
83064
  this.avatar.apply(Scratch.Cast.toString(args.POSE3D_JSON), Scratch.Cast.toString(args.POSE2D_JSON));
82970
83065
  }
83066
+ setAvatarExpression(args) {
83067
+ this.requireAvatarEnabled();
83068
+ this.avatar.setExpression(Scratch.Cast.toString(args.PERSON_ID), Scratch.Cast.toString(args.NAME), Scratch.Cast.toNumber(args.WEIGHT));
83069
+ }
83070
+ avatarExpressionNames(args) {
83071
+ this.requireAvatarEnabled();
83072
+ return JSON.stringify(this.avatar.expressionNames(Scratch.Cast.toString(args.PERSON_ID)));
83073
+ }
82971
83074
  resetAvatarRetarget() {
82972
83075
  this.avatar.reset();
82973
83076
  }
@@ -134,32 +134,47 @@ detectorのdisposeで解放します。
134
134
  ## PoseFrame3D avatar retarget
135
135
 
136
136
  `avatarRetargetV1`は独立した起動時固定・既定OFF flagです。runtime key
137
- `turbowarpAFrameCapability`へ`requireVersion(1)`を呼び、TurboWarp-A-Frame capabilityの公開同期
138
- scene操作7種だけを利用します。A-Frame DOM、Three.js `object3D`、GLTF内部boneへはアクセス
139
- しません。capability v1は`@kubohiroya/turbowarp-aframe@0.3.0`で公開済みです。
140
-
141
- asset登録では宣言的template JSONをA-Frameへ送り、検証済みrig mappingを保持します。各boneは
142
- 対応するKalidokit pose rig出力、`{avatar}`を含むselector、任意Euler offset degreeで定義します。
143
- 適用時は対応するexact-v1 PoseFrame3DとPoseFrame2Dの両方を要求します。PoseFrame3Dの`personId`と
144
- PoseFrame2Dの`trackingId`が一致するpersonだけを結合しますが、これは時刻alignmentではありません。
137
+ `turbowarpAFrameCapability`へ`requireVersion(2)`を呼び、それ以外のversionは受け付けません。
138
+ capability v2を持たないA-Frame(0.3.0など)は拒否します。そのcapabilityのscene操作とVRM操作だけを
139
+ 利用し、A-Frame DOM、Three.js object、GLTF内部へはアクセスしません。capability v2は
140
+ `@kubohiroya/turbowarp-aframe@0.4.0`で公開され、adapterが同じく要求する表情の操作は0.5.0で
141
+ 加わったため、peerは0.5.0に固定しています。
142
+
143
+ asset登録ではVRMのURLと検証済みのroot配置を保持します。personをbindすると、holder templateから
144
+ 空のnodeを作ってVRMを読み込み、準備ができるまで待ちます。読み込みに失敗したらnodeとbindingを
145
+ 取り除きます。適用時は対応するexact-v1 PoseFrame3DとPoseFrame2Dの両方を要求します。
146
+ PoseFrame3Dの`personId`とPoseFrame2Dの`trackingId`が一致するpersonだけを結合しますが、これは
147
+ 時刻alignmentではありません。
145
148
 
146
149
  adapterは両方のCOCO-17 recordを、exact pinした`kalidokit@1.1.5`が要求する33 positionへ
147
150
  決定論的に変換します。screen座標にはPoseFrame2Dの`frameWidth`/`frameHeight`を使い、world座標は
148
151
  外部serviceの値を維持します。不足するBlazePose face/hand/foot pointは低visibilityで中点補間
149
152
  または複製します。`runtime: "tfjs"`、`enableLegs: true`のKalidokit `Pose.solve`だけをrotation
150
- solverとし、radian出力をA-Frame degreeへ変換します。hips結果にroot scale/offsetを適用し、
153
+ solverとし、hips結果にroot scale/offsetを適用し、
151
154
  自前rotation fallbackは持ちません。joint/personがbinding threshold未満なら該当transformだけを
152
155
  skipし、直前値を維持します。
153
156
 
157
+ Kalidokitは、VRM 0.xのボーンの軸で、鏡に映した自撮りの視点を解きます。`Right*`の出力は演者の
158
+ 左のlandmarkから来るため、各出力は反対側のVRMヒューマノイドのボーンを回し、演者自身の側を
159
+ 動かします。回転は度で正規化されたボーンへ書きます。正規化されたボーンはVRM 0.xからYまわりに
160
+ 半回転したVRM 1.0の軸を使うためxとzの符号が反転し、反対側を動かすことで正中面について鏡映する
161
+ ためyとzの符号が反転します。合わせて符号は(-x, -y, z)になります。これはKalidokit 1.1.5と
162
+ VRMに合成の姿勢を入れて確かめました。腕を30°下ろす、片腕を水平に保つ、膝を上げる、前腕を前へ
163
+ 伸ばす、のいずれも演者自身の手足が同じ向きに動きました。Kalidokitの腕の出力は、水平より上げた
164
+ 腕と下げた腕をほとんど区別せず、体を傾けてもspineの傾きを返さなかったため、spineとhipsの符号は
165
+ 同じ導出によるもので、まだ観測していません。
166
+
154
167
  最大6 person IDを一意なtemplate instanceへbindします。recognition遷移は設定可能なA-Frame
155
- eventで通知し、application側がPerformance DSLのstart/end effectへ接続できます。1人の
168
+ eventで通知し、application側がPerformance DSLのstart/end effectへ接続できます。effectを
169
+ アバター自身に表せるよう、adapterはperson IDにbindしたアバターへVRMの表情を設定します。どの
170
+ 表情をいつ使うかはapplicationが決めます。1人の
156
171
  capability失敗は`partial`診断へ集約し、他avatarを継続します。rebind、明示reset、project
157
172
  lifecycle reset、disposeでは可能ならend eventを送り、生成instanceと一時状態をcleanupします。
158
173
 
159
174
  PoseFrame3Dは別実装の3D serviceから届くexact v1境界dataです。`timestampUs`は不透明値として
160
175
  recognition event dataへcopyするだけです。frame alignment、履歴保持/query、triangulation、
161
176
  3D solveは行いません。Kalidokitは上流でdeprecatedでありnative BlazePose landmarkを想定するため、
162
- このCOCO-17拡張は明示的な精度制約です。release前に対象GLTF rigを実browserで検証します。
177
+ このCOCO-17拡張は明示的な精度制約です。release前に実際の記録でretargetを検証します。
163
178
 
164
179
  ## フレーム同期パターンの縦切り
165
180