@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 +3 -3
- package/README.md +3 -3
- package/dist/extension-manifest.json +29 -1
- package/dist/turbowarp-realtime-motion-capture.js +201 -98
- package/docs/architecture.ja.md +26 -11
- package/docs/architecture.md +29 -14
- package/docs/index.html +4 -4
- package/docs/turbowarp-extension-api.ja.md +22 -16
- package/docs/turbowarp-extension-api.md +50 -23
- package/package.json +2 -2
- package/src/avatar/aframe-port.ts +36 -11
- package/src/avatar/controller.ts +146 -90
- package/src/avatar/types.ts +14 -11
- package/src/block-definitions.json +40 -6
- package/src/extension.ts +24 -4
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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": "
|
|
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]
|
|
545
|
-
"description": "Registers
|
|
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
|
-
"
|
|
551
|
+
"VRM_URL": {
|
|
552
552
|
"type": "STRING",
|
|
553
|
-
"defaultValue": "
|
|
553
|
+
"defaultValue": "avatar.vrm"
|
|
554
554
|
},
|
|
555
555
|
"RIG_JSON": {
|
|
556
556
|
"type": "STRING",
|
|
557
|
-
"defaultValue": "{\"
|
|
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
|
-
|
|
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
|
|
78260
|
-
|
|
78261
|
-
|
|
78262
|
-
|
|
78263
|
-
|
|
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
|
-
|
|
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: ["
|
|
79821
|
-
RightLowerArm: ["
|
|
79822
|
-
LeftUpperArm: ["
|
|
79823
|
-
LeftLowerArm: ["
|
|
79824
|
-
RightHand: ["
|
|
79825
|
-
LeftHand: ["
|
|
79826
|
-
RightUpperLeg: ["
|
|
79827
|
-
RightLowerLeg: ["
|
|
79828
|
-
LeftUpperLeg: ["
|
|
79829
|
-
LeftLowerLeg: ["
|
|
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,
|
|
79920
|
+
registerAsset(assetIdValue, vrmUrlValue, rigJson) {
|
|
79849
79921
|
const assetId = identifier(assetIdValue, "avatar asset ID");
|
|
79850
|
-
|
|
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 =
|
|
79925
|
+
const aframe = requireAFrameCapability(this.runtime);
|
|
79853
79926
|
const templateId = `twmp-avatar-${assetId}`;
|
|
79854
|
-
aframe.loadTemplate(templateId,
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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()])
|
|
79914
|
-
if (
|
|
79915
|
-
|
|
79916
|
-
|
|
79917
|
-
|
|
79918
|
-
|
|
79919
|
-
|
|
79920
|
-
|
|
79921
|
-
|
|
79922
|
-
|
|
79923
|
-
|
|
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 =
|
|
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
|
|
80084
|
+
for (const key of KALIDOKIT_RIG_KEYS) this.applyBone(aframe, binding, key, rig, worldKeypoints, screenKeypoints);
|
|
79977
80085
|
}
|
|
79978
|
-
applyBone(aframe, binding,
|
|
79979
|
-
if (!requiredJoints(
|
|
79980
|
-
const
|
|
79981
|
-
|
|
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
|
-
],
|
|
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
|
-
|
|
80121
|
-
|
|
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.
|
|
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
|
}
|
package/docs/architecture.ja.md
CHANGED
|
@@ -134,32 +134,47 @@ detectorのdisposeで解放します。
|
|
|
134
134
|
## PoseFrame3D avatar retarget
|
|
135
135
|
|
|
136
136
|
`avatarRetargetV1`は独立した起動時固定・既定OFF flagです。runtime key
|
|
137
|
-
`turbowarpAFrameCapability`へ`requireVersion(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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とし、
|
|
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へ接続できます。
|
|
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
|
|
177
|
+
このCOCO-17拡張は明示的な精度制約です。release前に実際の記録でretargetを検証します。
|
|
163
178
|
|
|
164
179
|
## フレーム同期パターンの縦切り
|
|
165
180
|
|