@bitmagic/cli 0.1.66-dev.6 → 0.1.66-dev.7
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.md +36 -1
- package/assets/character-skin-authoring/SKILL.md +43 -0
- package/assets/character-skin-authoring/references/exporters.md +134 -0
- package/assets/character-skin-authoring/references/eyes.md +215 -0
- package/assets/character-skin-authoring/references/materials.md +115 -0
- package/assets/character-skin-authoring/references/playground.md +78 -0
- package/assets/character-skin-authoring/references/profiles.md +97 -0
- package/assets/character-skin-authoring/references/projection.md +190 -0
- package/assets/character-skin-authoring/references/regional-detail.md +72 -0
- package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
- package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
- package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
- package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
- package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
- package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
- package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
- package/assets/character-skin-authoring/scripts/character_build.py +54 -0
- package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
- package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
- package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
- package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
- package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
- package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
- package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
- package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
- package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
- package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
- package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
- package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
- package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
- package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
- package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
- package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
- package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
- package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
- package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
- package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
- package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
- package/assets/metahuman/tools/lipsync/make_line.py +181 -0
- package/assets/metahuman/tools/metahuman/README.md +21 -0
- package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
- package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
- package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
- package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
- package/assets/metahuman/tools/metahuman/package.json +13 -0
- package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
- package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
- package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
- package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
- package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
- package/dist/commands/generate.d.ts +49 -0
- package/dist/commands/generate.js +168 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/generate/audio-file.d.ts +6 -0
- package/dist/generate/audio-file.js +42 -0
- package/dist/generate/audio-file.js.map +1 -0
- package/dist/generate/music.d.ts +4 -3
- package/dist/generate/music.js +6 -23
- package/dist/generate/music.js.map +1 -1
- package/dist/generate/stream.d.ts +13 -0
- package/dist/generate/stream.js +14 -1
- package/dist/generate/stream.js.map +1 -1
- package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
- package/dist/scaffold/character-skin-skill-files.js +49 -0
- package/dist/scaffold/character-skin-skill-files.js.map +1 -0
- package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
- package/dist/scaffold/metahuman-skill-files.js +306 -0
- package/dist/scaffold/metahuman-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +4 -4
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +4 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +3 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/tools/bundles.js +8 -0
- package/dist/tools/bundles.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Step 2: merge the export's duplicate skeleton hierarchies (face / outfit / body) into ONE.
|
|
2
|
+
// The pipeline exports every skinned mesh with its own copy of the rig: the face hierarchy carries
|
|
3
|
+
// torso / neck / head + the facial joints but stops at the upper arms; the body hierarchy carries the
|
|
4
|
+
// full limbs. Limb subtrees are grafted from the body hierarchy onto the face hierarchy (same names,
|
|
5
|
+
// same rest transforms — the script asserts they match), every other skin is re-pointed at the merged
|
|
6
|
+
// joints (same joint order, so the inverse-bind accessors stay valid) and the duplicate roots dropped.
|
|
7
|
+
// Skins are found by the export's naming: SKM_<name>_FaceMesh, SKM_<name>_BodyMesh, <name>_Outfits.
|
|
8
|
+
// node merge-skeletons.mjs in.glb out.glb
|
|
9
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
10
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
11
|
+
import { prune } from '@gltf-transform/functions';
|
|
12
|
+
const [,, input, output] = process.argv;
|
|
13
|
+
if (!input || !output) { console.error('usage: node merge-skeletons.mjs in.glb out.glb'); process.exit(2); }
|
|
14
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
15
|
+
const doc = await io.read(input);
|
|
16
|
+
const root = doc.getRoot();
|
|
17
|
+
const skins = root.listSkins();
|
|
18
|
+
const face = skins.find((s) => /^SKM_.*_FaceMesh$/i.test(s.getName()));
|
|
19
|
+
const body = skins.find((s) => /^SKM_.*_BodyMesh$/i.test(s.getName()));
|
|
20
|
+
if (!face || !body) throw new Error(`need a SKM_<name>_FaceMesh and a SKM_<name>_BodyMesh skin; found: ${skins.map((s) => s.getName()).join(', ')}`);
|
|
21
|
+
const others = skins.filter((s) => s !== face);
|
|
22
|
+
const key = (n) => n.getName().toLowerCase();
|
|
23
|
+
const merged = new Map(face.listJoints().map((j) => [key(j), j]));
|
|
24
|
+
const parentOf = (n) => n.listParents().find((p) => p.propertyType === 'Node') ?? null;
|
|
25
|
+
const registerSubtree = (n) => { merged.set(key(n), n); for (const c of n.listChildren()) registerSubtree(c); };
|
|
26
|
+
// Graft: walk the body hierarchy top-down; any joint missing from the merged map whose parent IS
|
|
27
|
+
// mapped gets re-parented (with its whole subtree) under the mapped parent.
|
|
28
|
+
let grafted = 0, maxRestDiff = 0;
|
|
29
|
+
const bodyRoot = body.listJoints().find((j) => key(j) === 'root');
|
|
30
|
+
if (!bodyRoot) throw new Error('the body skin has no joint named "root"');
|
|
31
|
+
const walk = (n) => {
|
|
32
|
+
const k = key(n);
|
|
33
|
+
if (!merged.has(k)) {
|
|
34
|
+
const p = parentOf(n);
|
|
35
|
+
const mp = p ? merged.get(key(p)) : null;
|
|
36
|
+
if (!mp) throw new Error(`cannot graft ${n.getName()}: parent ${p?.getName()} unmapped`);
|
|
37
|
+
p.removeChild(n); mp.addChild(n); registerSubtree(n); grafted++;
|
|
38
|
+
return; // subtree moved wholesale
|
|
39
|
+
} else if (merged.get(k) !== n) {
|
|
40
|
+
const a = n.getTranslation(), b = merged.get(k).getTranslation();
|
|
41
|
+
for (let i = 0; i < 3; i++) maxRestDiff = Math.max(maxRestDiff, Math.abs(a[i] - b[i]));
|
|
42
|
+
}
|
|
43
|
+
for (const c of [...n.listChildren()]) walk(c);
|
|
44
|
+
};
|
|
45
|
+
walk(bodyRoot);
|
|
46
|
+
console.log(`grafted ${grafted} limb subtrees from the body hierarchy; max rest-translation diff of duplicates = ${maxRestDiff.toExponential(2)}`);
|
|
47
|
+
if (maxRestDiff > 1e-4) console.warn('WARNING: duplicate joints do not share a rest pose — the merge assumes they do; inspect before using this file');
|
|
48
|
+
for (const skin of others) {
|
|
49
|
+
const joints = skin.listJoints();
|
|
50
|
+
const mapped = joints.map((j) => { const m = merged.get(key(j)); if (!m) throw new Error(`unmapped joint ${j.getName()} in ${skin.getName()}`); return m; });
|
|
51
|
+
for (const j of joints) skin.removeJoint(j);
|
|
52
|
+
for (const j of mapped) skin.addJoint(j);
|
|
53
|
+
skin.setSkeleton(merged.get('root'));
|
|
54
|
+
}
|
|
55
|
+
let detached = 0;
|
|
56
|
+
for (const node of root.listNodes()) {
|
|
57
|
+
if (key(node) === 'root' && node !== merged.get('root')) {
|
|
58
|
+
for (const p of node.listParents().filter((p) => p.propertyType === 'Node')) { p.removeChild(node); detached++; }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await doc.transform(prune());
|
|
62
|
+
const jointSets = root.listSkins().map((s) => s.getName() + ':' + s.listJoints().length);
|
|
63
|
+
const uniqueJoints = new Set(root.listSkins().flatMap((s) => s.listJoints()));
|
|
64
|
+
console.log(`detached ${detached} duplicate roots; nodes now ${root.listNodes().length}; skins ${jointSets.join(' ')}; unique joint nodes ${uniqueJoints.size}`);
|
|
65
|
+
const reachable = new Set(); const dfs = (n) => { reachable.add(n); for (const c of n.listChildren()) dfs(c); }; dfs(merged.get('root'));
|
|
66
|
+
const unreachable = [...uniqueJoints].filter((j) => !reachable.has(j)).map((j) => j.getName());
|
|
67
|
+
if (unreachable.length) throw new Error(`${unreachable.length} joints unreachable from root: ${unreachable.slice(0, 10).join(', ')}`);
|
|
68
|
+
await io.write(output, doc);
|
|
69
|
+
console.log('written', output);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "metahuman-tools",
|
|
3
|
+
"private": true,
|
|
4
|
+
"description": "GLB preparation and verification rig for Epic MetaHuman exports — see the importing-metahumans skill",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@gltf-transform/core": "^4.5.0",
|
|
8
|
+
"@gltf-transform/extensions": "^4.5.0",
|
|
9
|
+
"@gltf-transform/functions": "^4.5.0",
|
|
10
|
+
"draco3dgltf": "^1.5.7",
|
|
11
|
+
"playwright-core": "^1.59.1"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/bin/zsh
|
|
2
|
+
# Prepare a MetaHuman glTF export (smorchj/metahuman-to-glb) for the Bitmagic engine, in five steps:
|
|
3
|
+
# 1 strip Draco (the engine's loader has no decoder) 2 merge the three skeleton copies into one
|
|
4
|
+
# 3 body mesh first (the engine reads the bind pose from the first skin)
|
|
5
|
+
# 4 Laplacian-smooth the garment weights (shorts 6 × λ0.5, shirt 12 × λ0.6)
|
|
6
|
+
# 5 give the garment the skin's weights wherever skin lies under it (no more poke-through)
|
|
7
|
+
# Usage: tools/metahuman/prepare.sh <export.glb> <out.glb> (env SHORT_RE / SHIRT_RE override the garment
|
|
8
|
+
# material regexes; KEEP_STEPS=1 keeps the intermediate files next to <out.glb>)
|
|
9
|
+
set -e
|
|
10
|
+
HERE="${0:A:h}"
|
|
11
|
+
IN="${1:A}"; OUT="${2:A}"
|
|
12
|
+
[[ -f "$IN" && -n "$2" ]] || { echo "usage: tools/metahuman/prepare.sh <export.glb> <out.glb>"; exit 2; }
|
|
13
|
+
mkdir -p "${OUT:h}"
|
|
14
|
+
cd "$HERE"
|
|
15
|
+
[[ -d node_modules ]] || npm install --no-audit --no-fund
|
|
16
|
+
T=$(mktemp -d)
|
|
17
|
+
node strip-draco.mjs "$IN" "$T/1-nodraco.glb"
|
|
18
|
+
node merge-skeletons.mjs "$T/1-nodraco.glb" "$T/2-merged.glb"
|
|
19
|
+
node reorder-body-first.mjs "$T/2-merged.glb" "$T/3-bodyfirst.glb"
|
|
20
|
+
node smooth-outfit-weights.mjs "$T/3-bodyfirst.glb" "$T/4-smooth-a.glb" 6 0.5 "${SHORT_RE:-Short}"
|
|
21
|
+
node smooth-outfit-weights.mjs "$T/4-smooth-a.glb" "$T/5-smooth-b.glb" 12 0.6 "${SHIRT_RE:-Shirt}"
|
|
22
|
+
node transfer-skin-weights.mjs "$T/5-smooth-b.glb" "$OUT" 0.02 0.045
|
|
23
|
+
if [[ -n "$KEEP_STEPS" ]]; then cp "$T"/*.glb "${OUT:h}/"; fi
|
|
24
|
+
rm -rf "$T"
|
|
25
|
+
ls -la "$OUT" | awk '{printf "prepared %s (%.1f MB)\n", $9, $5/1e6}'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Step 3: put the full-body skinned mesh FIRST in the glTF scene. The engine's CharacterLoader captures a
|
|
2
|
+
// rig's bind pose from the first skinned mesh it traverses; the face skin stops at the upper arms, so with
|
|
3
|
+
// the face first the forearms, hands and legs get raw clip rotations (split legs, twisted arms).
|
|
4
|
+
// Order: SKM_<name>_BodyMesh, <name>_Outfits, everything else, SKM_<name>_FaceMesh.
|
|
5
|
+
// node reorder-body-first.mjs in.glb out.glb
|
|
6
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
7
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
8
|
+
const [,, input, output] = process.argv;
|
|
9
|
+
if (!input || !output) { console.error('usage: node reorder-body-first.mjs in.glb out.glb'); process.exit(2); }
|
|
10
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
11
|
+
const doc = await io.read(input);
|
|
12
|
+
const scene = doc.getRoot().listScenes()[0];
|
|
13
|
+
const kids = scene.listChildren();
|
|
14
|
+
const rank = (n) => (/^SKM_.*_BodyMesh$/i.test(n.getName()) ? 0 : /_Outfits$/i.test(n.getName()) ? 1 : /^SKM_.*_FaceMesh$/i.test(n.getName()) ? 3 : 2);
|
|
15
|
+
const sorted = [...kids].sort((a, b) => rank(a) - rank(b));
|
|
16
|
+
if (!sorted.some((n) => rank(n) === 0)) throw new Error(`no SKM_<name>_BodyMesh node at the scene root; roots: ${kids.map((n) => n.getName()).join(', ')}`);
|
|
17
|
+
for (const k of kids) scene.removeChild(k);
|
|
18
|
+
for (const k of sorted) scene.addChild(k);
|
|
19
|
+
console.log('scene order now:', scene.listChildren().map((n) => n.getName()).join(' → '));
|
|
20
|
+
await io.write(output, doc);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Step 4: Laplacian-smooth the skin weights of the outfit mesh so hard weight seams (a torso helper joint
|
|
2
|
+
// next to arm twist correctives at the armpit) become gradients. Keeps the 4 strongest influences.
|
|
3
|
+
// Run once per garment with its own strength: a short garment needs less than a shirt.
|
|
4
|
+
// node smooth-outfit-weights.mjs in.glb out.glb [iterations=6] [lambda=0.5] [materialRegex=.] [meshRegex=_Outfits$]
|
|
5
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
6
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
7
|
+
const [,, input, output, iterationsArg = '6', lambdaArg = '0.5', onlyArg = '.', meshArg = '_Outfits$'] = process.argv;
|
|
8
|
+
if (!input || !output) { console.error('usage: node smooth-outfit-weights.mjs in.glb out.glb [iterations] [lambda] [materialRegex] [meshRegex]'); process.exit(2); }
|
|
9
|
+
const only = new RegExp(onlyArg, 'i'); const meshRe = new RegExp(meshArg, 'i');
|
|
10
|
+
const iterations = +iterationsArg, lambda = +lambdaArg;
|
|
11
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
12
|
+
const doc = await io.read(input);
|
|
13
|
+
let done = 0;
|
|
14
|
+
for (const mesh of doc.getRoot().listMeshes()) {
|
|
15
|
+
if (!meshRe.test(mesh.getName())) continue;
|
|
16
|
+
for (const prim of mesh.listPrimitives()) {
|
|
17
|
+
if (!only.test(prim.getMaterial()?.getName() ?? '')) continue;
|
|
18
|
+
const J = prim.getAttribute('JOINTS_0'), W = prim.getAttribute('WEIGHTS_0'), I = prim.getIndices();
|
|
19
|
+
if (!J || !W || !I) continue;
|
|
20
|
+
const n = J.getCount(); const j = new Uint32Array(4), w = new Float32Array(4);
|
|
21
|
+
const maps = [];
|
|
22
|
+
for (let v = 0; v < n; v++) { J.getElement(v, j); W.getElement(v, w); const m = new Map(); for (let k = 0; k < 4; k++) if (w[k] > 0) m.set(j[k], (m.get(j[k]) || 0) + w[k]); maps.push(m); }
|
|
23
|
+
const adj = Array.from({ length: n }, () => new Set());
|
|
24
|
+
const ia = I.getArray();
|
|
25
|
+
for (let t = 0; t < ia.length; t += 3) { const a = ia[t], b = ia[t + 1], c = ia[t + 2]; adj[a].add(b); adj[a].add(c); adj[b].add(a); adj[b].add(c); adj[c].add(a); adj[c].add(b); }
|
|
26
|
+
let cur = maps;
|
|
27
|
+
for (let it = 0; it < iterations; it++) {
|
|
28
|
+
const next = [];
|
|
29
|
+
for (let v = 0; v < n; v++) {
|
|
30
|
+
const nb = adj[v]; const acc = new Map();
|
|
31
|
+
for (const [k, x] of cur[v]) acc.set(k, (1 - lambda) * x);
|
|
32
|
+
if (nb.size) { const s = lambda / nb.size; for (const u of nb) for (const [k, x] of cur[u]) acc.set(k, (acc.get(k) || 0) + s * x); }
|
|
33
|
+
next.push(acc);
|
|
34
|
+
}
|
|
35
|
+
cur = next;
|
|
36
|
+
}
|
|
37
|
+
const jOut = new Uint16Array(n * 4), wOut = new Float32Array(n * 4);
|
|
38
|
+
for (let v = 0; v < n; v++) {
|
|
39
|
+
const top = [...cur[v].entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
|
|
40
|
+
const sum = top.reduce((s, [, x]) => s + x, 0) || 1;
|
|
41
|
+
top.forEach(([k, x], i) => { jOut[v * 4 + i] = k; wOut[v * 4 + i] = x / sum; });
|
|
42
|
+
}
|
|
43
|
+
J.setArray(jOut); W.setArray(wOut); done++;
|
|
44
|
+
console.log(`smoothed ${mesh.getName()} / ${prim.getMaterial()?.getName()}: ${n} vertices, ${iterations} iterations, lambda ${lambda}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!done) console.warn(`nothing matched mesh /${meshArg}/ material /${onlyArg}/`);
|
|
48
|
+
await io.write(output, doc);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Step 1: rewrite a MetaHuman export without Draco compression — the engine's `engine.loader` is a plain
|
|
2
|
+
// GLTFLoader (no Draco / KTX2 decoders), so a compressed export loads as nothing at all. Prints an
|
|
3
|
+
// inventory (morph targets, skins, the joints the later steps rely on) so you can see what the export is.
|
|
4
|
+
// node strip-draco.mjs in.glb out.glb
|
|
5
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
6
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
7
|
+
import draco3d from 'draco3dgltf';
|
|
8
|
+
const [,, input, output] = process.argv;
|
|
9
|
+
if (!input || !output) { console.error('usage: node strip-draco.mjs in.glb out.glb'); process.exit(2); }
|
|
10
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
|
|
11
|
+
'draco3d.decoder': await draco3d.createDecoderModule(),
|
|
12
|
+
'draco3d.encoder': await draco3d.createEncoderModule(),
|
|
13
|
+
});
|
|
14
|
+
const doc = await io.read(input);
|
|
15
|
+
let stripped = 0;
|
|
16
|
+
for (const ext of doc.getRoot().listExtensionsUsed()) {
|
|
17
|
+
if (ext.extensionName === 'KHR_draco_mesh_compression') { ext.dispose(); stripped++; }
|
|
18
|
+
}
|
|
19
|
+
console.log(stripped ? 'Draco stripped' : 'no Draco extension present');
|
|
20
|
+
for (const mesh of doc.getRoot().listMeshes()) {
|
|
21
|
+
for (const prim of mesh.listPrimitives()) {
|
|
22
|
+
const t = prim.listTargets();
|
|
23
|
+
if (t.length) console.log(`mesh ${mesh.getName()} prim mat=${prim.getMaterial()?.getName()} targets=${t.length} targetAttrs=${t[0].listSemantics().join(',')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const skin of doc.getRoot().listSkins()) {
|
|
27
|
+
const names = skin.listJoints().map((j) => j.getName());
|
|
28
|
+
console.log(`skin ${skin.getName()} joints=${names.length} first=${names.slice(0, 8).join(',')}`);
|
|
29
|
+
console.log(` body/head joints present: ${names.filter((n) => /^(head|neck_0[12]|spine_0[1-5]|pelvis|root|clavicle_[lr]|upperarm_[lr]|hand_[lr]|thigh_[lr]|foot_[lr])$/.test(n)).join(',')}`);
|
|
30
|
+
}
|
|
31
|
+
await io.write(output, doc);
|
|
32
|
+
console.log('written', output);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Step 5: give the outfit meshes the SKIN's weights wherever skin exists under them (shoulders, collar, arm
|
|
2
|
+
// tops from the body + the face mesh's neck skirt), so cloth and skin deform identically and the
|
|
3
|
+
// skin can no longer poke through. Blends back to the outfit's own weights away from any skin.
|
|
4
|
+
// node transfer-skin-weights.mjs in.glb out.glb [rFull=0.02] [rMax=0.045]
|
|
5
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
6
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
7
|
+
const [,, input, output, rFullArg = '0.02', rMaxArg = '0.045'] = process.argv;
|
|
8
|
+
const R_FULL = +rFullArg, R_MAX = +rMaxArg;
|
|
9
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
10
|
+
const doc = await io.read(input);
|
|
11
|
+
const root = doc.getRoot();
|
|
12
|
+
const nodeFor = (re) => root.listNodes().find((n) => re.test(n.getMesh()?.getName() ?? ''));
|
|
13
|
+
const outfitNode = nodeFor(/_Outfits$/i), bodyNode = nodeFor(/^SKM_.*_BodyMesh$/i), faceNode = nodeFor(/^SKM_.*_FaceMesh$/i);
|
|
14
|
+
if (!outfitNode || !bodyNode || !faceNode) throw new Error(`nodes: outfit ${!!outfitNode} body ${!!bodyNode} face ${!!faceNode}`);
|
|
15
|
+
const outSkin = outfitNode.getSkin();
|
|
16
|
+
const jointNames = (skin) => skin.listJoints().map((j) => j.getName());
|
|
17
|
+
const outNames = jointNames(outSkin); const outIndex = new Map(outNames.map((n, i) => [n, i]));
|
|
18
|
+
|
|
19
|
+
// Gather skin sources: [position, Map<jointName, weight>] per vertex, from every skin-material primitive.
|
|
20
|
+
const sources = [];
|
|
21
|
+
for (const [node, re] of [[bodyNode, /Body/], [faceNode, /Face_Skin/]]) {
|
|
22
|
+
const skin = node.getSkin(); const names = jointNames(skin);
|
|
23
|
+
for (const prim of node.getMesh().listPrimitives()) {
|
|
24
|
+
if (!re.test(prim.getMaterial()?.getName() ?? '')) continue;
|
|
25
|
+
const P = prim.getAttribute('POSITION'), J = prim.getAttribute('JOINTS_0'), W = prim.getAttribute('WEIGHTS_0');
|
|
26
|
+
const p = new Float32Array(3), j = new Uint32Array(4), w = new Float32Array(4);
|
|
27
|
+
for (let v = 0; v < P.getCount(); v++) {
|
|
28
|
+
P.getElement(v, p); J.getElement(v, j); W.getElement(v, w);
|
|
29
|
+
const m = new Map(); for (let k = 0; k < 4; k++) if (w[k] > 0.001) m.set(names[j[k]], (m.get(names[j[k]]) || 0) + w[k]);
|
|
30
|
+
sources.push({ x: p[0], y: p[1], z: p[2], m });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Spatial hash.
|
|
35
|
+
const CELL = R_MAX; const grid = new Map(); const key = (x, y, z) => `${Math.floor(x / CELL)},${Math.floor(y / CELL)},${Math.floor(z / CELL)}`;
|
|
36
|
+
sources.forEach((s, i) => { const k = key(s.x, s.y, s.z); (grid.get(k) || grid.set(k, []).get(k)).push(i); });
|
|
37
|
+
const near = (x, y, z) => {
|
|
38
|
+
const out = []; const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL), cz = Math.floor(z / CELL);
|
|
39
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
|
|
40
|
+
const cell = grid.get(`${cx + dx},${cy + dy},${cz + dz}`); if (!cell) continue;
|
|
41
|
+
for (const i of cell) { const s = sources[i]; const d = Math.hypot(s.x - x, s.y - y, s.z - z); if (d <= R_MAX) out.push([d, i]); }
|
|
42
|
+
}
|
|
43
|
+
out.sort((a, b) => a[0] - b[0]); return out.slice(0, 3);
|
|
44
|
+
};
|
|
45
|
+
const smoothstep = (e0, e1, x) => { const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); return t * t * (3 - 2 * t); };
|
|
46
|
+
// Joints the outfit skin lacks (the face's neck correctives): add them, with the source skin's IBMs.
|
|
47
|
+
const needed = new Set();
|
|
48
|
+
for (const s of sources) for (const n of s.m.keys()) if (!outIndex.has(n)) needed.add(n);
|
|
49
|
+
if (needed.size) {
|
|
50
|
+
const faceSkin = faceNode.getSkin(); const faceNames = jointNames(faceSkin);
|
|
51
|
+
const ibmAcc = outSkin.getInverseBindMatrices(); const faceIbm = faceSkin.getInverseBindMatrices();
|
|
52
|
+
const old = ibmAcc.getArray(); const added = [...needed];
|
|
53
|
+
const arr = new Float32Array(old.length + added.length * 16); arr.set(old);
|
|
54
|
+
const m = new Float32Array(16);
|
|
55
|
+
added.forEach((name, k) => {
|
|
56
|
+
const fi = faceNames.indexOf(name); if (fi < 0) throw new Error(`joint ${name} not in face skin`);
|
|
57
|
+
faceIbm.getElement(fi, m); arr.set(m, old.length + k * 16);
|
|
58
|
+
outSkin.addJoint(faceSkin.listJoints()[fi]); outIndex.set(name, outNames.length + k);
|
|
59
|
+
});
|
|
60
|
+
ibmAcc.setArray(arr);
|
|
61
|
+
console.log(`added ${added.length} joints to the outfit skin (${added.slice(0, 4).join(', ')}…)`);
|
|
62
|
+
}
|
|
63
|
+
let full = 0, blended = 0, untouched = 0;
|
|
64
|
+
for (const prim of outfitNode.getMesh().listPrimitives()) {
|
|
65
|
+
const P = prim.getAttribute('POSITION'), J = prim.getAttribute('JOINTS_0'), W = prim.getAttribute('WEIGHTS_0');
|
|
66
|
+
const n = P.getCount(); const p = new Float32Array(3), j = new Uint32Array(4), w = new Float32Array(4);
|
|
67
|
+
const jOut = new Uint16Array(n * 4), wOut = new Float32Array(n * 4);
|
|
68
|
+
const curNames = jointNames(outSkin);
|
|
69
|
+
for (let v = 0; v < n; v++) {
|
|
70
|
+
P.getElement(v, p); J.getElement(v, j); W.getElement(v, w);
|
|
71
|
+
const own = new Map(); for (let k = 0; k < 4; k++) if (w[k] > 0.001) own.set(curNames[j[k]], (own.get(curNames[j[k]]) || 0) + w[k]);
|
|
72
|
+
const nb = near(p[0], p[1], p[2]);
|
|
73
|
+
let mix = own;
|
|
74
|
+
if (nb.length) {
|
|
75
|
+
const skinMap = new Map(); let tot = 0;
|
|
76
|
+
for (const [d, i] of nb) { const wgt = 1 / (d * d + 1e-6); tot += wgt; for (const [name, x] of sources[i].m) skinMap.set(name, (skinMap.get(name) || 0) + wgt * x); }
|
|
77
|
+
for (const [name, x] of skinMap) skinMap.set(name, x / tot);
|
|
78
|
+
const t = 1 - smoothstep(R_FULL, R_MAX, nb[0][0]);
|
|
79
|
+
if (t >= 0.999) full++; else blended++;
|
|
80
|
+
mix = new Map(); for (const [name, x] of skinMap) mix.set(name, t * x); for (const [name, x] of own) mix.set(name, (mix.get(name) || 0) + (1 - t) * x);
|
|
81
|
+
} else untouched++;
|
|
82
|
+
const top = [...mix.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
|
|
83
|
+
const sum = top.reduce((s, [, x]) => s + x, 0) || 1;
|
|
84
|
+
top.forEach(([name, x], i) => { const idx = outIndex.get(name); if (idx === undefined) throw new Error(`no joint ${name}`); jOut[v * 4 + i] = idx; wOut[v * 4 + i] = x / sum; });
|
|
85
|
+
}
|
|
86
|
+
J.setArray(jOut); W.setArray(wOut);
|
|
87
|
+
console.log(`${prim.getMaterial()?.getName()}: ${n} vertices`);
|
|
88
|
+
}
|
|
89
|
+
console.log(`transfer: ${full} full, ${blended} blended, ${untouched} untouched (R_FULL ${R_FULL} m, R_MAX ${R_MAX} m); skin sources ${sources.length}`);
|
|
90
|
+
await io.write(output, doc);
|
|
@@ -49,6 +49,25 @@ export interface MusicArgs {
|
|
|
49
49
|
* request is made — the plan's verification line 7.
|
|
50
50
|
*/
|
|
51
51
|
export declare function buildMusicBody(args: MusicArgs): Record<string, unknown>;
|
|
52
|
+
export interface SpeechArgs {
|
|
53
|
+
text: string;
|
|
54
|
+
voice?: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
stability?: string;
|
|
57
|
+
similarityBoost?: string;
|
|
58
|
+
style?: string;
|
|
59
|
+
speed?: string;
|
|
60
|
+
assetId?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The same omission contract as {@link buildSoundBody}: a voice setting left alone is left out
|
|
64
|
+
* entirely, so Eleven Labs' own default stands rather than the CLI pinning a copy of it.
|
|
65
|
+
*
|
|
66
|
+
* The character cap is restated from the generator schema so an over-long line is refused here,
|
|
67
|
+
* before a request is made — this lane is billed by the length of the audio, and a 20,000
|
|
68
|
+
* character paste is an expensive way to learn about a 400.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildSpeechBody(args: SpeechArgs): Record<string, unknown>;
|
|
52
71
|
export interface ImageArgs {
|
|
53
72
|
name: string;
|
|
54
73
|
prompt: string;
|
|
@@ -220,6 +239,36 @@ export declare function runMusicGeneration(options: {
|
|
|
220
239
|
json: boolean;
|
|
221
240
|
deps?: GenerateRunDeps;
|
|
222
241
|
}): Promise<MusicRunResult>;
|
|
242
|
+
export interface SpeechRunResult {
|
|
243
|
+
speechUrl: string;
|
|
244
|
+
originalUrl: string;
|
|
245
|
+
speechId: string;
|
|
246
|
+
durationSeconds: number;
|
|
247
|
+
patchesApplied: number;
|
|
248
|
+
/** Where the MP3 was saved, when `--out` asked for it. */
|
|
249
|
+
file?: string;
|
|
250
|
+
bytes?: number;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* `generate speech` has the same two-outputs shape as `generate music`, and for a sharper reason:
|
|
254
|
+
* the world.json asset is a line an NPC speaks (`engine.playSound`), while the MP3 is the input
|
|
255
|
+
* `tools/lipsync/make_line.py --audio` needs to cut mouth cues from. A creator rigging a talking
|
|
256
|
+
* character wants the file and NOT the asset, so `--no-world` is a first-class path here rather
|
|
257
|
+
* than an escape hatch.
|
|
258
|
+
*
|
|
259
|
+
* Cannot go through {@link runAssetGeneration} because that always applies the patches and never
|
|
260
|
+
* downloads; it shares {@link requestAssetWithJobRecord} so the request, the job record and the
|
|
261
|
+
* "world.json is unchanged" failure contract are the same as every other lane's.
|
|
262
|
+
*/
|
|
263
|
+
export declare function runSpeechGeneration(options: {
|
|
264
|
+
body: Record<string, unknown>;
|
|
265
|
+
/** Save the MP3 here (relative to the current directory). */
|
|
266
|
+
out?: string;
|
|
267
|
+
/** Apply the asset to world.json. The default; `--no-world` turns it off. */
|
|
268
|
+
world: boolean;
|
|
269
|
+
json: boolean;
|
|
270
|
+
deps?: GenerateRunDeps;
|
|
271
|
+
}): Promise<SpeechRunResult>;
|
|
223
272
|
/**
|
|
224
273
|
* Refuses `--voxel-grid` alongside `--mesh`, rather than ignoring it.
|
|
225
274
|
*
|
|
@@ -24,7 +24,8 @@ import { DEFAULT_MASTER_GRID, generateAndInstallProp } from '../generate/prop.js
|
|
|
24
24
|
import { generateAndInstallVehicle } from '../generate/vehicle.js';
|
|
25
25
|
import { generateAndInstallModel } from '../generate/model.js';
|
|
26
26
|
import { downloadMusicFile } from '../generate/music.js';
|
|
27
|
-
import {
|
|
27
|
+
import { downloadAudioFile } from '../generate/audio-file.js';
|
|
28
|
+
import { MUSIC_MAX_SECONDS, MUSIC_MIN_SECONDS, SPEECH_MAX_CHARS, SPEECH_VOICES, VIDEO_MAX_SECONDS, VIDEO_MIN_SECONDS, } from '@bitmagic/asset-core';
|
|
28
29
|
import { generateAndInstallAnimationFromVideo, isLocomotionState, isVideoToMotionModel, } from '../generate/animation-from-video.js';
|
|
29
30
|
import { generateAndInstallVideo, IMAGE_EXTENSIONS, } from '../generate/video.js';
|
|
30
31
|
import { reviewGeneratedClip } from '../generate/video-review.js';
|
|
@@ -98,6 +99,30 @@ export function buildMusicBody(args) {
|
|
|
98
99
|
assetId: args.assetId,
|
|
99
100
|
});
|
|
100
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The same omission contract as {@link buildSoundBody}: a voice setting left alone is left out
|
|
104
|
+
* entirely, so Eleven Labs' own default stands rather than the CLI pinning a copy of it.
|
|
105
|
+
*
|
|
106
|
+
* The character cap is restated from the generator schema so an over-long line is refused here,
|
|
107
|
+
* before a request is made — this lane is billed by the length of the audio, and a 20,000
|
|
108
|
+
* character paste is an expensive way to learn about a 400.
|
|
109
|
+
*/
|
|
110
|
+
export function buildSpeechBody(args) {
|
|
111
|
+
if (args.text.length > SPEECH_MAX_CHARS) {
|
|
112
|
+
throw new CliError(`--text is ${args.text.length} characters; the limit is ${SPEECH_MAX_CHARS}. `
|
|
113
|
+
+ 'Split the line and generate it in parts. world.json is unchanged.');
|
|
114
|
+
}
|
|
115
|
+
return withoutAbsentFlags({
|
|
116
|
+
text: args.text,
|
|
117
|
+
voice: args.voice,
|
|
118
|
+
model: args.model,
|
|
119
|
+
stability: parseNumberFlag('stability', args.stability, { min: 0, max: 1 }),
|
|
120
|
+
similarityBoost: parseNumberFlag('similarity-boost', args.similarityBoost, { min: 0, max: 1 }),
|
|
121
|
+
style: parseNumberFlag('style', args.style, { min: 0, max: 1 }),
|
|
122
|
+
speed: parseNumberFlag('speed', args.speed, { min: 0.7, max: 1.2 }),
|
|
123
|
+
assetId: args.assetId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
101
126
|
export function buildImageBody(args) {
|
|
102
127
|
return withoutAbsentFlags({
|
|
103
128
|
name: args.name,
|
|
@@ -605,6 +630,77 @@ export async function runMusicGeneration(options) {
|
|
|
605
630
|
}
|
|
606
631
|
return result;
|
|
607
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* `generate speech` has the same two-outputs shape as `generate music`, and for a sharper reason:
|
|
635
|
+
* the world.json asset is a line an NPC speaks (`engine.playSound`), while the MP3 is the input
|
|
636
|
+
* `tools/lipsync/make_line.py --audio` needs to cut mouth cues from. A creator rigging a talking
|
|
637
|
+
* character wants the file and NOT the asset, so `--no-world` is a first-class path here rather
|
|
638
|
+
* than an escape hatch.
|
|
639
|
+
*
|
|
640
|
+
* Cannot go through {@link runAssetGeneration} because that always applies the patches and never
|
|
641
|
+
* downloads; it shares {@link requestAssetWithJobRecord} so the request, the job record and the
|
|
642
|
+
* "world.json is unchanged" failure contract are the same as every other lane's.
|
|
643
|
+
*/
|
|
644
|
+
export async function runSpeechGeneration(options) {
|
|
645
|
+
const output = createOutput(options.json);
|
|
646
|
+
const runDeps = options.deps ?? defaultDeps(options.json);
|
|
647
|
+
const outPath = options.out === undefined ? undefined : path.resolve(options.out);
|
|
648
|
+
const result = await requestAssetWithJobRecord('speech', 'speech', options.body, runDeps, async (outcome, worldPath, log) => {
|
|
649
|
+
if (!outcome.speech) {
|
|
650
|
+
throw new CliError('The server reported the speech as generated but sent no audio URLs. world.json is unchanged.');
|
|
651
|
+
}
|
|
652
|
+
const { speech } = outcome;
|
|
653
|
+
let patchesApplied = 0;
|
|
654
|
+
if (options.world) {
|
|
655
|
+
let applyResult;
|
|
656
|
+
try {
|
|
657
|
+
applyResult = applyPatchesToWorld(worldPath, outcome.patches);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
throw decoratedError(error, (message) => `Generated speech successfully, but could not write it into world.json: ${message} `
|
|
661
|
+
+ 'world.json is unchanged — the line was generated (and may have been billed) but is not '
|
|
662
|
+
+ `yet in your project. It is at ${speech.originalUrl}.`);
|
|
663
|
+
}
|
|
664
|
+
patchesApplied = applyResult.applied;
|
|
665
|
+
log(`Generated speech (${speech.durationSeconds.toFixed(1)}s): ${outcome.message}`);
|
|
666
|
+
for (const line of applyResult.summary)
|
|
667
|
+
runDeps.log(` - ${line}`);
|
|
668
|
+
runDeps.log(worldUpdatedLine(patchesApplied));
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
log(`Generated speech (${speech.durationSeconds.toFixed(1)}s): ${outcome.message}`);
|
|
672
|
+
runDeps.log('world.json is unchanged (--no-world).');
|
|
673
|
+
}
|
|
674
|
+
let bytes;
|
|
675
|
+
if (outPath !== undefined) {
|
|
676
|
+
try {
|
|
677
|
+
bytes = await downloadAudioFile(speech.originalUrl, outPath, runDeps.fetch, 'line');
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
// The line is generated, billed, and (unless --no-world) already in world.json; only the
|
|
681
|
+
// local copy is missing, and the message has to say so or the creator regenerates.
|
|
682
|
+
throw decoratedError(error, (message) => `${message} The line was generated${options.world ? ' and is in world.json' : ''}; only saving `
|
|
683
|
+
+ 'the local copy failed.');
|
|
684
|
+
}
|
|
685
|
+
runDeps.log(`Saved ${outPath} (${Math.round(bytes / 1024)} KB).`);
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
speechUrl: speech.speechUrl,
|
|
689
|
+
originalUrl: speech.originalUrl,
|
|
690
|
+
speechId: speech.speechId,
|
|
691
|
+
durationSeconds: speech.durationSeconds,
|
|
692
|
+
patchesApplied,
|
|
693
|
+
...(outPath !== undefined ? { file: outPath } : {}),
|
|
694
|
+
...(bytes !== undefined ? { bytes } : {}),
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
output.result({ ok: true, kind: 'speech', ...result });
|
|
698
|
+
if (options.world) {
|
|
699
|
+
runDeps.log(`Play it from game code with engine.playSound('${result.speechId}').`);
|
|
700
|
+
runDeps.log(DEV_VIEW_RELOADS);
|
|
701
|
+
}
|
|
702
|
+
return result;
|
|
703
|
+
}
|
|
608
704
|
/**
|
|
609
705
|
* Refuses `--voxel-grid` alongside `--mesh`, rather than ignoring it.
|
|
610
706
|
*
|
|
@@ -1168,6 +1264,76 @@ const musicCommand = defineCommand({
|
|
|
1168
1264
|
});
|
|
1169
1265
|
},
|
|
1170
1266
|
});
|
|
1267
|
+
const speechCommand = defineCommand({
|
|
1268
|
+
meta: {
|
|
1269
|
+
name: 'speech',
|
|
1270
|
+
description: 'Speak a line of text and add it to world.json assets (or save it as a file).',
|
|
1271
|
+
},
|
|
1272
|
+
args: {
|
|
1273
|
+
text: {
|
|
1274
|
+
type: 'string',
|
|
1275
|
+
required: true,
|
|
1276
|
+
description: 'The line to speak. Billed by the length of the audio it produces.',
|
|
1277
|
+
},
|
|
1278
|
+
voice: {
|
|
1279
|
+
type: 'string',
|
|
1280
|
+
description: `Voice: ${SPEECH_VOICES.join(', ')}, or a raw Eleven Labs voice_id. Default ${SPEECH_VOICES[0]}.`,
|
|
1281
|
+
},
|
|
1282
|
+
model: {
|
|
1283
|
+
type: 'string',
|
|
1284
|
+
description: 'Eleven Labs model id. Defaults to eleven_multilingual_v2.',
|
|
1285
|
+
},
|
|
1286
|
+
stability: {
|
|
1287
|
+
type: 'string',
|
|
1288
|
+
description: 'Voice stability, 0-1. Lower is more expressive, higher is more consistent.',
|
|
1289
|
+
},
|
|
1290
|
+
'similarity-boost': {
|
|
1291
|
+
type: 'string',
|
|
1292
|
+
description: 'How closely to match the original voice, 0-1.',
|
|
1293
|
+
},
|
|
1294
|
+
style: {
|
|
1295
|
+
type: 'string',
|
|
1296
|
+
description: 'Style exaggeration, 0-1.',
|
|
1297
|
+
},
|
|
1298
|
+
speed: {
|
|
1299
|
+
type: 'string',
|
|
1300
|
+
description: 'Speaking rate, 0.7-1.2.',
|
|
1301
|
+
},
|
|
1302
|
+
'asset-id': {
|
|
1303
|
+
type: 'string',
|
|
1304
|
+
description: 'Pre-minted asset_… id to store the line under, so code already referencing it resolves.',
|
|
1305
|
+
},
|
|
1306
|
+
out: {
|
|
1307
|
+
type: 'string',
|
|
1308
|
+
description: 'Also save the MP3 to this path — what tools/lipsync/make_line.py --audio takes.',
|
|
1309
|
+
},
|
|
1310
|
+
world: {
|
|
1311
|
+
type: 'boolean',
|
|
1312
|
+
default: true,
|
|
1313
|
+
description: 'Add the line to world.json assets (the default). Pass --no-world with --out for a file only.',
|
|
1314
|
+
},
|
|
1315
|
+
'original-prompt': ORIGINAL_PROMPT_FLAG,
|
|
1316
|
+
json: JSON_FLAG,
|
|
1317
|
+
},
|
|
1318
|
+
async run({ args }) {
|
|
1319
|
+
const body = buildSpeechBody({
|
|
1320
|
+
text: args.text,
|
|
1321
|
+
voice: args.voice,
|
|
1322
|
+
model: args.model,
|
|
1323
|
+
stability: args.stability,
|
|
1324
|
+
similarityBoost: args['similarity-boost'],
|
|
1325
|
+
style: args.style,
|
|
1326
|
+
speed: args.speed,
|
|
1327
|
+
assetId: args['asset-id'],
|
|
1328
|
+
});
|
|
1329
|
+
await runSpeechGeneration({
|
|
1330
|
+
body: withParent(body),
|
|
1331
|
+
...(args.out !== undefined ? { out: args.out } : {}),
|
|
1332
|
+
world: args.world !== false,
|
|
1333
|
+
json: args.json === true,
|
|
1334
|
+
});
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1171
1337
|
const imageCommand = defineCommand({
|
|
1172
1338
|
meta: { name: 'image', description: 'Generate an image asset and add it to world.json assets.' },
|
|
1173
1339
|
args: {
|
|
@@ -1935,6 +2101,7 @@ export const generateCommand = defineCommand({
|
|
|
1935
2101
|
'block-type': blockTypeCommand,
|
|
1936
2102
|
sound: soundCommand,
|
|
1937
2103
|
music: musicCommand,
|
|
2104
|
+
speech: speechCommand,
|
|
1938
2105
|
image: imageCommand,
|
|
1939
2106
|
character: characterCommand,
|
|
1940
2107
|
animation: animationCommand,
|