@kubohiroya/turbowarp-realtime-motion-capture 0.3.0 → 0.4.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.4.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.4.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.4.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.4.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
@@ -525,7 +525,7 @@
525
525
  "type": "STRING"
526
526
  },
527
527
  {
528
- "id": "TEMPLATE_JSON",
528
+ "id": "VRM_URL",
529
529
  "type": "STRING"
530
530
  }
531
531
  ]
@@ -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",
@@ -78248,19 +78248,33 @@
78248
78248
  "loadTemplate",
78249
78249
  "createFromTemplate",
78250
78250
  "setPosition",
78251
- "setRotation",
78252
78251
  "emitEvent",
78253
78252
  "deleteSelector",
78254
78253
  "countSelector",
78254
+ "loadVrm",
78255
+ "setVrmBoneRotation",
78255
78256
  "requireVersion"
78256
78257
  ];
78257
- function requireAFramePublicBlocks(runtime) {
78258
+ /**
78259
+ * Returns TurboWarp-A-Frame capability v2, the only version avatar retargeting accepts.
78260
+ * A build that cannot provide v2 is refused rather than driven through an older contract.
78261
+ */
78262
+ function requireAFrameCapability(runtime) {
78258
78263
  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);
78264
+ if (typeof candidate !== "object" || candidate === null) throw new Error("TurboWarp-A-Frame capability v2 must be loaded before avatar retargeting.");
78265
+ const requireVersion = Reflect.get(candidate, "requireVersion");
78266
+ if (typeof requireVersion !== "function") throw new Error("TurboWarp-A-Frame capability v2 is missing requireVersion().");
78267
+ let capability;
78268
+ try {
78269
+ capability = requireVersion.call(candidate, 2);
78270
+ } catch (error) {
78271
+ throw new Error(`TurboWarp-A-Frame capability v2 is required: ${error instanceof Error ? error.message : String(error)}`);
78272
+ }
78273
+ if (typeof capability !== "object" || capability === null) throw new Error("TurboWarp-A-Frame capability v2 is required.");
78274
+ for (const method of METHODS) if (typeof Reflect.get(capability, method) !== "function") throw new Error(`TurboWarp-A-Frame capability v2 is missing ${method}().`);
78275
+ const version = Reflect.get(capability, "version");
78276
+ if (version !== 2) throw new Error(`TurboWarp-A-Frame capability v2 is required; found version ${String(version)}.`);
78277
+ return capability;
78264
78278
  }
78265
78279
  //#endregion
78266
78280
  //#region node_modules/.pnpm/kalidokit@1.1.5/node_modules/kalidokit/dist/utils/helpers.js
@@ -79815,18 +79829,42 @@
79815
79829
  //#region src/avatar/controller.ts
79816
79830
  var identifiers = /^[A-Za-z0-9._-]{1,64}$/u;
79817
79831
  var eventNames = /^[A-Za-z0-9._:-]{1,80}$/u;
79818
- var KALIDOKIT_RIG_KEY_SET = new Set(KALIDOKIT_RIG_KEYS);
79832
+ /** Every avatar instance is an empty node that the VRM is loaded onto. */
79833
+ var HOLDER_TEMPLATE = JSON.stringify({ type: "empty" });
79834
+ /**
79835
+ * Kalidokit solves a mirrored selfie view: its `Right*` outputs come from the performer's
79836
+ * left landmarks. A stage avatar moves the performer's own side, so each output drives the
79837
+ * opposite VRM humanoid bone.
79838
+ */
79839
+ var VRM_BONES = {
79840
+ RightUpperArm: "leftUpperArm",
79841
+ RightLowerArm: "leftLowerArm",
79842
+ LeftUpperArm: "rightUpperArm",
79843
+ LeftLowerArm: "rightLowerArm",
79844
+ RightHand: "leftHand",
79845
+ LeftHand: "rightHand",
79846
+ RightUpperLeg: "leftUpperLeg",
79847
+ RightLowerLeg: "leftLowerLeg",
79848
+ LeftUpperLeg: "rightUpperLeg",
79849
+ LeftLowerLeg: "rightLowerLeg",
79850
+ Spine: "spine",
79851
+ Hips: "hips"
79852
+ };
79853
+ /**
79854
+ * The joints each Kalidokit output is computed from. Its `Right*` outputs read the
79855
+ * performer's left landmarks (BlazePose 11, 13, 15, 23, 25, 27), and `Left*` the right.
79856
+ */
79819
79857
  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"],
79858
+ RightUpperArm: ["left_shoulder", "left_elbow"],
79859
+ RightLowerArm: ["left_elbow", "left_wrist"],
79860
+ LeftUpperArm: ["right_shoulder", "right_elbow"],
79861
+ LeftLowerArm: ["right_elbow", "right_wrist"],
79862
+ RightHand: ["left_wrist"],
79863
+ LeftHand: ["right_wrist"],
79864
+ RightUpperLeg: ["left_hip", "left_knee"],
79865
+ RightLowerLeg: ["left_knee", "left_ankle"],
79866
+ LeftUpperLeg: ["right_hip", "right_knee"],
79867
+ LeftLowerLeg: ["right_knee", "right_ankle"],
79830
79868
  Spine: [
79831
79869
  "left_shoulder",
79832
79870
  "right_shoulder",
@@ -79845,21 +79883,23 @@
79845
79883
  this.runtime = runtime;
79846
79884
  this.solver = solver;
79847
79885
  }
79848
- registerAsset(assetIdValue, templateJson, rigJson) {
79886
+ registerAsset(assetIdValue, vrmUrlValue, rigJson) {
79849
79887
  const assetId = identifier(assetIdValue, "avatar asset ID");
79850
- parseJsonObject(templateJson, "Avatar template JSON");
79888
+ const vrmUrl = nonEmpty(vrmUrlValue, "VRM URL");
79889
+ if (vrmUrl.length > 2048) throw new Error("VRM URL exceeds 2048 characters.");
79851
79890
  const rig = parseRigMapping(rigJson);
79852
- const aframe = requireAFramePublicBlocks(this.runtime);
79891
+ const aframe = requireAFrameCapability(this.runtime);
79853
79892
  const templateId = `twmp-avatar-${assetId}`;
79854
- aframe.loadTemplate(templateId, templateJson);
79893
+ aframe.loadTemplate(templateId, HOLDER_TEMPLATE);
79855
79894
  this.assets.set(assetId, {
79856
79895
  id: assetId,
79857
79896
  templateId,
79897
+ vrmUrl,
79858
79898
  rig
79859
79899
  });
79860
79900
  this.succeed("configured");
79861
79901
  }
79862
- bind(personIdValue, instanceIdValue, assetIdValue, parentSelectorValue, confidenceValue) {
79902
+ async bind(personIdValue, instanceIdValue, assetIdValue, parentSelectorValue, confidenceValue) {
79863
79903
  const personId = identifier(personIdValue, "person ID");
79864
79904
  const instanceId = identifier(instanceIdValue, "avatar instance ID");
79865
79905
  const assetId = identifier(assetIdValue, "avatar asset ID");
@@ -79871,25 +79911,41 @@
79871
79911
  const existingInstance = [...this.bindings.values()].find((binding) => binding.instanceId === instanceId);
79872
79912
  if (existingInstance && existingInstance.personId !== personId) throw new Error(`Avatar instance is already bound: ${instanceId}`);
79873
79913
  if (!existingPerson && this.bindings.size >= 6) throw new Error("Avatar retargeting supports at most six people.");
79874
- const aframe = requireAFramePublicBlocks(this.runtime);
79914
+ const aframe = requireAFrameCapability(this.runtime);
79875
79915
  if (instanceId !== existingPerson?.instanceId && aframe.countSelector(`#${instanceId}`) > 0) throw new Error(`A-Frame node already exists: ${instanceId}`);
79876
79916
  if (existingPerson) this.removeBinding(aframe, existingPerson);
79877
79917
  if (aframe.countSelector(`#${instanceId}`) > 0) throw new Error(`A-Frame node already exists: ${instanceId}`);
79878
79918
  aframe.createFromTemplate(asset.templateId, instanceId, parentSelector);
79879
- this.bindings.set(personId, {
79919
+ const binding = {
79880
79920
  personId,
79881
79921
  instanceId,
79882
79922
  assetId,
79883
79923
  confidence,
79884
- recognized: false
79885
- });
79924
+ recognized: false,
79925
+ loaded: false
79926
+ };
79927
+ this.bindings.set(personId, binding);
79928
+ this.lastState = "loading";
79929
+ try {
79930
+ await aframe.loadVrm(asset.vrmUrl, `#${instanceId}`);
79931
+ } catch (error) {
79932
+ if (this.bindings.get(personId) === binding) {
79933
+ this.bindings.delete(personId);
79934
+ aframe.deleteSelector(`#${instanceId}`);
79935
+ this.lastState = "error";
79936
+ this.lastError = `${personId}: ${message(error)}`;
79937
+ }
79938
+ throw error;
79939
+ }
79940
+ if (this.bindings.get(personId) !== binding) return;
79941
+ binding.loaded = true;
79886
79942
  this.succeed("bound");
79887
79943
  }
79888
79944
  unbind(personIdValue) {
79889
79945
  const personId = identifier(personIdValue, "person ID");
79890
79946
  const binding = this.bindings.get(personId);
79891
79947
  if (!binding) return;
79892
- this.removeBinding(requireAFramePublicBlocks(this.runtime), binding);
79948
+ this.removeBinding(requireAFrameCapability(this.runtime), binding);
79893
79949
  this.succeed("configured");
79894
79950
  }
79895
79951
  apply(frameJson, pose2dJson) {
@@ -79901,7 +79957,7 @@
79901
79957
  try {
79902
79958
  frame = parseFrame(frameJson);
79903
79959
  pose2d = parsePoseFrame2D(pose2dJson);
79904
- aframe = requireAFramePublicBlocks(this.runtime);
79960
+ aframe = requireAFrameCapability(this.runtime);
79905
79961
  } catch (error) {
79906
79962
  this.lastState = "error";
79907
79963
  this.lastError = message(error);
@@ -79910,23 +79966,26 @@
79910
79966
  const people = new Map(frame.persons.map((person) => [person.personId, person]));
79911
79967
  const screenPeople = new Map(pose2d.persons.map((person) => [person.trackingId, person]));
79912
79968
  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;
79969
+ for (const binding of [...this.bindings.values()]) {
79970
+ if (!binding.loaded) continue;
79971
+ try {
79972
+ if (aframe.countSelector(`#${binding.instanceId}`) !== 1) {
79973
+ this.bindings.delete(binding.personId);
79974
+ errors.push(`${binding.personId}: avatar instance is missing after scene reset`);
79975
+ continue;
79976
+ }
79977
+ const person = people.get(binding.personId);
79978
+ const screenPerson = screenPeople.get(binding.personId);
79979
+ if (!person || !screenPerson || person.score < binding.confidence || screenPerson.score < binding.confidence) {
79980
+ this.setRecognized(aframe, binding, false, frame.timestampUs);
79981
+ continue;
79982
+ }
79983
+ this.applyPerson(aframe, binding, person, screenPerson, pose2d);
79984
+ this.setRecognized(aframe, binding, true, frame.timestampUs);
79985
+ this.lastUpdated += 1;
79986
+ } catch (error) {
79987
+ errors.push(`${binding.personId}: ${message(error)}`);
79924
79988
  }
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
79989
  }
79931
79990
  if (errors.length > 0) {
79932
79991
  this.lastState = "partial";
@@ -79936,7 +79995,7 @@
79936
79995
  reset() {
79937
79996
  const candidate = this.runtime.turbowarpAFrameCapability;
79938
79997
  if (typeof candidate === "object" && candidate !== null) try {
79939
- const aframe = requireAFramePublicBlocks(this.runtime);
79998
+ const aframe = requireAFrameCapability(this.runtime);
79940
79999
  for (const binding of [...this.bindings.values()]) this.removeBinding(aframe, binding);
79941
80000
  } catch {
79942
80001
  this.bindings.clear();
@@ -79973,15 +80032,12 @@
79973
80032
  const [offsetX, offsetY, offsetZ] = asset.rig.rootOffset;
79974
80033
  aframe.setPosition(`#${binding.instanceId}`, root.x * asset.rig.rootScale + offsetX, root.y * asset.rig.rootScale + offsetY, root.z * asset.rig.rootScale + offsetZ);
79975
80034
  }
79976
- for (const bone of asset.rig.bones) this.applyBone(aframe, binding, bone, rig, worldKeypoints, screenKeypoints);
80035
+ for (const key of KALIDOKIT_RIG_KEYS) this.applyBone(aframe, binding, key, rig, worldKeypoints, screenKeypoints);
79977
80036
  }
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);
80037
+ applyBone(aframe, binding, key, rig, worldKeypoints, screenKeypoints) {
80038
+ if (!requiredJoints(key).every((id) => (worldKeypoints.get(id)?.score ?? 0) >= binding.confidence && (screenKeypoints.get(id)?.score ?? 0) >= binding.confidence)) return;
80039
+ const [x, y, z] = vrmBoneDegrees(rotationForRig(rig, key));
80040
+ aframe.setVrmBoneRotation(`#${binding.instanceId}`, VRM_BONES[key], x, y, z);
79985
80041
  }
79986
80042
  setRecognized(aframe, binding, recognized, timestampUs) {
79987
80043
  if (binding.recognized === recognized) return;
@@ -80022,48 +80078,24 @@
80022
80078
  }
80023
80079
  function parseRigMapping(source) {
80024
80080
  const value = parseJsonObject(source, "Avatar rig mapping JSON");
80081
+ if ("bones" in value) throw new Error("Rig mapping no longer takes bones: VRM humanoid bones are driven directly.");
80025
80082
  rejectUnknownKeys(value, /* @__PURE__ */ new Set([
80026
80083
  "rootScale",
80027
80084
  "rootOffset",
80028
80085
  "recognitionStartEvent",
80029
- "recognitionEndEvent",
80030
- "bones"
80086
+ "recognitionEndEvent"
80031
80087
  ]), "rig mapping");
80032
80088
  const rootScale = finite(value.rootScale ?? 1, "rootScale");
80033
80089
  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
80090
  return {
80043
80091
  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 ?? [
80092
+ rootOffset: vector3(value.rootOffset ?? [
80063
80093
  0,
80064
80094
  0,
80065
80095
  0
80066
- ], `bones[${index}].offsetDegrees`)
80096
+ ], "rootOffset"),
80097
+ recognitionStartEvent: eventName(value.recognitionStartEvent ?? "twmp-recognition-start", "recognitionStartEvent"),
80098
+ recognitionEndEvent: eventName(value.recognitionEndEvent ?? "twmp-recognition-end", "recognitionEndEvent")
80067
80099
  };
80068
80100
  }
80069
80101
  function parseJsonObject(source, label) {
@@ -80083,10 +80115,6 @@
80083
80115
  const unknown = Object.keys(value).find((key) => !allowed.has(key));
80084
80116
  if (unknown) throw new Error(`${label} contains unknown field: ${unknown}`);
80085
80117
  }
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
80118
  function vector3(value, label) {
80091
80119
  if (!Array.isArray(value) || value.length !== 3) throw new Error(`${label} must contain exactly three numbers.`);
80092
80120
  return [
@@ -80117,8 +80145,20 @@
80117
80145
  if (!eventNames.test(normalized)) throw new Error(`Invalid ${label}.`);
80118
80146
  return normalized;
80119
80147
  }
80120
- function expandSelector(selector, instanceId) {
80121
- return selector.replaceAll("{avatar}", instanceId);
80148
+ /**
80149
+ * Converts a Kalidokit rotation in radians to Euler degrees on a VRM normalized bone.
80150
+ *
80151
+ * Kalidokit's rotations are for VRM 0.x bone axes, which face -Z; normalized bones use the
80152
+ * VRM 1.0 axes, a half turn about Y away, which negates x and z. Driving the opposite side
80153
+ * (see VRM_BONES) also mirrors the rotation across the body's midline, which negates y and z.
80154
+ * Together the signs become (-x, -y, z).
80155
+ */
80156
+ function vrmBoneDegrees(rotation) {
80157
+ return [
80158
+ -radiansToDegrees(rotation.x),
80159
+ -radiansToDegrees(rotation.y),
80160
+ radiansToDegrees(rotation.z)
80161
+ ];
80122
80162
  }
80123
80163
  function radiansToDegrees(value) {
80124
80164
  return value * 180 / Math.PI;
@@ -82954,11 +82994,11 @@
82954
82994
  }
82955
82995
  registerAvatarAsset(args) {
82956
82996
  this.requireAvatarEnabled();
82957
- this.avatar.registerAsset(Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.TEMPLATE_JSON), Scratch.Cast.toString(args.RIG_JSON));
82997
+ this.avatar.registerAsset(Scratch.Cast.toString(args.ASSET_ID), Scratch.Cast.toString(args.VRM_URL), Scratch.Cast.toString(args.RIG_JSON));
82958
82998
  }
82959
82999
  bindAvatarPerson(args) {
82960
83000
  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));
83001
+ 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
83002
  }
82963
83003
  unbindAvatarPerson(args) {
82964
83004
  this.requireAvatarEnabled();
@@ -134,23 +134,35 @@ 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`で公開済みです。
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`で公開します。
140
141
 
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ではありません。
142
+ asset登録ではVRMのURLと検証済みのroot配置を保持します。personをbindすると、holder templateから
143
+ 空のnodeを作ってVRMを読み込み、準備ができるまで待ちます。読み込みに失敗したらnodeとbindingを
144
+ 取り除きます。適用時は対応するexact-v1 PoseFrame3DとPoseFrame2Dの両方を要求します。
145
+ PoseFrame3Dの`personId`とPoseFrame2Dの`trackingId`が一致するpersonだけを結合しますが、これは
146
+ 時刻alignmentではありません。
145
147
 
146
148
  adapterは両方のCOCO-17 recordを、exact pinした`kalidokit@1.1.5`が要求する33 positionへ
147
149
  決定論的に変換します。screen座標にはPoseFrame2Dの`frameWidth`/`frameHeight`を使い、world座標は
148
150
  外部serviceの値を維持します。不足するBlazePose face/hand/foot pointは低visibilityで中点補間
149
151
  または複製します。`runtime: "tfjs"`、`enableLegs: true`のKalidokit `Pose.solve`だけをrotation
150
- solverとし、radian出力をA-Frame degreeへ変換します。hips結果にroot scale/offsetを適用し、
152
+ solverとし、hips結果にroot scale/offsetを適用し、
151
153
  自前rotation fallbackは持ちません。joint/personがbinding threshold未満なら該当transformだけを
152
154
  skipし、直前値を維持します。
153
155
 
156
+ Kalidokitは、VRM 0.xのボーンの軸で、鏡に映した自撮りの視点を解きます。`Right*`の出力は演者の
157
+ 左のlandmarkから来るため、各出力は反対側のVRMヒューマノイドのボーンを回し、演者自身の側を
158
+ 動かします。回転は度で正規化されたボーンへ書きます。正規化されたボーンはVRM 0.xからYまわりに
159
+ 半回転したVRM 1.0の軸を使うためxとzの符号が反転し、反対側を動かすことで正中面について鏡映する
160
+ ためyとzの符号が反転します。合わせて符号は(-x, -y, z)になります。これはKalidokit 1.1.5と
161
+ VRMに合成の姿勢を入れて確かめました。腕を30°下ろす、片腕を水平に保つ、膝を上げる、前腕を前へ
162
+ 伸ばす、のいずれも演者自身の手足が同じ向きに動きました。Kalidokitの腕の出力は、水平より上げた
163
+ 腕と下げた腕をほとんど区別せず、体を傾けてもspineの傾きを返さなかったため、spineとhipsの符号は
164
+ 同じ導出によるもので、まだ観測していません。
165
+
154
166
  最大6 person IDを一意なtemplate instanceへbindします。recognition遷移は設定可能なA-Frame
155
167
  eventで通知し、application側がPerformance DSLのstart/end effectへ接続できます。1人の
156
168
  capability失敗は`partial`診断へ集約し、他avatarを継続します。rebind、明示reset、project
@@ -159,7 +171,7 @@ lifecycle reset、disposeでは可能ならend eventを送り、生成instance
159
171
  PoseFrame3Dは別実装の3D serviceから届くexact v1境界dataです。`timestampUs`は不透明値として
160
172
  recognition event dataへcopyするだけです。frame alignment、履歴保持/query、triangulation、
161
173
  3D solveは行いません。Kalidokitは上流でdeprecatedでありnative BlazePose landmarkを想定するため、
162
- このCOCO-17拡張は明示的な精度制約です。release前に対象GLTF rigを実browserで検証します。
174
+ このCOCO-17拡張は明示的な精度制約です。release前に実際の記録でretargetを検証します。
163
175
 
164
176
  ## フレーム同期パターンの縦切り
165
177
 
@@ -154,23 +154,34 @@ camera/board geometry and WebAssembly startup remain browser E2E responsibilitie
154
154
  ## PoseFrame3D avatar retargeting
155
155
 
156
156
  `avatarRetargetV1` is an independent startup-fixed, default-OFF flag. It requires runtime key
157
- `turbowarpAFrameCapability`, calls `requireVersion(1)`, and uses only the seven public synchronous
158
- scene operations from the TurboWarp-A-Frame capability. The consumer never accesses A-Frame DOM,
159
- Three.js `object3D`, or GLTF bone internals. Capability v1 is published in
160
- `@kubohiroya/turbowarp-aframe@0.3.0`.
157
+ `turbowarpAFrameCapability` and calls `requireVersion(2)`; it accepts no other version, so an
158
+ A-Frame build without capability v2, such as 0.3.0, is refused. It uses the public scene
159
+ operations and the VRM operations of that capability, and never accesses A-Frame DOM, Three.js
160
+ objects, or GLTF internals. Capability v2 is published in `@kubohiroya/turbowarp-aframe@0.4.0`.
161
161
 
162
- An asset registration sends declarative template JSON to A-Frame and retains a validated rig map.
163
- Each bone maps one supported Kalidokit pose rig output to a selector containing `{avatar}`, plus
164
- optional Euler offset degrees. Applying a frame requires corresponding exact-v1 PoseFrame3D and
165
- PoseFrame2D values. A person is joined only by PoseFrame3D `personId` equal to PoseFrame2D
166
- `trackingId`; this is not temporal alignment.
162
+ An asset registration records a VRM URL and a validated root placement. Binding a person creates
163
+ an empty node from a holder template, loads the VRM onto it, and waits until it is ready; a failed
164
+ load removes the node and the binding. Applying a frame requires corresponding exact-v1
165
+ PoseFrame3D and PoseFrame2D values. A person is joined only by PoseFrame3D `personId` equal to
166
+ PoseFrame2D `trackingId`; this is not temporal alignment.
167
167
 
168
168
  The adapter maps both COCO-17 records deterministically to the 33 positions required by
169
169
  exact-pinned `kalidokit@1.1.5`. Screen coordinates use PoseFrame2D `frameWidth` and `frameHeight`;
170
170
  world coordinates retain the external service coordinate values. Missing BlazePose face, hand, and
171
171
  foot points are midpoint-interpolated or duplicated with reduced visibility. Kalidokit `Pose.solve`
172
- with `runtime: "tfjs"` and `enableLegs: true` is the sole rotation solver. Its radians are converted
173
- to A-Frame degrees, and its hips result drives the configured root scale and offset. There is no
172
+ with `runtime: "tfjs"` and `enableLegs: true` is the sole rotation solver. Its hips result drives
173
+ the configured root scale and offset.
174
+
175
+ Kalidokit solves a mirrored selfie view for VRM 0.x bone axes. Its `Right*` outputs come from the
176
+ performer's left landmarks, so each output turns the opposite VRM humanoid bone, which moves the
177
+ performer's own side. The rotation is written in degrees to the normalized bone: normalized bones
178
+ use the VRM 1.0 axes, a half turn about Y from VRM 0.x, which negates x and z, and driving the
179
+ opposite side mirrors the rotation across the midline, which negates y and z; together the signs
180
+ become (-x, -y, z). This was checked on a VRM with Kalidokit 1.1.5 and synthetic poses: an arm
181
+ lowered 30°, one arm held level, a raised knee, and a forearm reaching forward each moved the
182
+ performer's own limb in the same direction. Kalidokit's arm output barely separates an arm raised
183
+ above level from one lowered below it, and it reported no spine lean for a leaning torso, so the
184
+ spine and hips signs follow from the same derivation but are not yet observed. There is no
174
185
  custom rotation fallback. Joint or person confidence below the binding threshold skips only that
175
186
  transform and preserves its prior value.
176
187
 
@@ -184,7 +195,7 @@ PoseFrame3D is exact v1 boundary data from a separate 3D service. Its `timestamp
184
195
  only copied into recognition event data. This extension performs no frame alignment, history
185
196
  retention/query, triangulation, or 3D solve. Kalidokit is deprecated upstream and expects native
186
197
  BlazePose landmarks; the deterministic COCO-17 expansion is therefore an explicit accuracy
187
- constraint, and intended GLTF rigs require real-browser validation before release.
198
+ constraint, and retargeting still requires validation with real recordings before release.
188
199
 
189
200
  ## Frame sync pattern vertical slice
190
201
 
package/docs/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  <h2>Install and configure</h2>
27
27
  <p>Load the provider extensions you use first, then load the version-pinned bundle as an unsandboxed custom extension:</p>
28
- <pre>https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.3.0/dist/turbowarp-realtime-motion-capture.js</pre>
28
+ <pre>https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-realtime-motion-capture@0.4.0/dist/turbowarp-realtime-motion-capture.js</pre>
29
29
  <p>All feature groups are OFF by default. Set the complete startup configuration before loading the bundle:</p>
30
30
  <pre><code>globalThis.__TWMP_FEATURE_FLAGS__ = {
31
31
  qrCourierPairing: true,
@@ -66,9 +66,9 @@ globalThis.__TWMP_QR_CONFIG__ = {errorCorrectionLevel: "M"};</code></pre>
66
66
  <p>The bundled, exact-pinned OpenCV.js 4.12 WebAssembly backend is the sole solver. It estimates intrinsic, distortion, and world-from-camera values and rejects insufficient samples, resolution changes, weak views, excessive reprojection RMS, unknown profile versions, and pairing credentials.</p>
67
67
 
68
68
  <h2>PoseFrame3D avatar retargeting</h2>
69
- <p>The independent <code>avatarRetargetV1</code> flag is OFF by default. The adapter requires TurboWarp-A-Frame 0.3.0 scene capability v1 and uses only its public template, selector transform, event, count, and delete operations.</p>
70
- <p>Corresponding external PoseFrame3D and PoseFrame2D v1 values can drive up to six declarative rigs. The adapter matches person IDs, expands COCO-17 to BlazePose-33, and uses exact-pinned Kalidokit 1.1.5 as its only rotation solver before sending public selector transforms to A-Frame.</p>
71
- <p>The deterministic expansion uses reduced-visibility duplicates for unavailable hand, foot, and face landmarks and is less precise than native BlazePose input. Kalidokit is deprecated upstream, so intended GLTF rigs require real-browser validation. This extension provides no custom solver fallback and does not align frames, query history, triangulate, or solve 3D data.</p>
69
+ <p>The independent <code>avatarRetargetV1</code> flag is OFF by default. The adapter requires TurboWarp-A-Frame 0.4.0 scene capability v2, refuses any other version, and uses only its public template, transform, event, count, delete, and VRM operations.</p>
70
+ <p>Corresponding external PoseFrame3D and PoseFrame2D v1 values can drive up to six VRM avatars. The adapter matches person IDs, expands COCO-17 to BlazePose-33, and uses exact-pinned Kalidokit 1.1.5 as its only rotation solver before turning the VRM humanoid bones on the performer's own side.</p>
71
+ <p>The deterministic expansion uses reduced-visibility duplicates for unavailable hand, foot, and face landmarks and is less precise than native BlazePose input. Kalidokit is deprecated upstream, so retargeting requires validation with real recordings. This extension provides no custom solver fallback and does not align frames, query history, triangulate, or solve 3D data.</p>
72
72
  <h2>Multi-camera 3D pose fusion</h2>
73
73
  <p>The independent <code>poseFusion3D</code> flag is OFF by default. When enabled, blocks buffer PoseFrame2D JSON per camera in timestamp-ordered ring buffers, resample every camera at one past instant behind a configured delay, and triangulate the synchronized 2D sets into PoseFrame3D version 1 JSON.</p>
74
74
  <p>Out-of-order frames inside the jitter window are reordered, while duplicates, late arrivals, and frames that no loaded calibration profile describes are counted as dropped. Occluded keypoints are filled from the visible side of their bracket, disagreeing views are resolved by the largest consensus set, and a keypoint without two confident views holds its last triangulated position with score zero.</p>