@bitmagic/cli 0.1.66-dev.1 → 0.1.66-dev.10
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 +37 -1
- package/assets/blender-level/find_blender.py +3 -1
- package/assets/character-rigging/SKILL.md +80 -0
- package/assets/character-rigging/references/articulation.md +55 -0
- package/assets/character-rigging/references/new-rig.md +51 -0
- package/assets/character-rigging/references/repairs.md +44 -0
- package/assets/character-rigging/references/verification.md +49 -0
- package/assets/character-rigging/scripts/rig_tool.py +197 -0
- 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/assets/voxelize-glb.d.ts +5 -1
- package/dist/assets/voxelize-glb.js +1 -0
- package/dist/assets/voxelize-glb.js.map +1 -1
- package/dist/assets/vxl-summary.js +2 -2
- package/dist/commands/forge.js +3 -1
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +62 -0
- package/dist/commands/generate.js +241 -2
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/judge.js +5 -0
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/verify.js +7 -0
- package/dist/commands/verify.js.map +1 -1
- package/dist/editor/asset-viewer.js +10 -5
- package/dist/editor/asset-viewer.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/model.js +3 -3
- package/dist/generate/model.js.map +1 -1
- 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/prop.d.ts +19 -1
- package/dist/generate/prop.js +34 -6
- package/dist/generate/prop.js.map +1 -1
- package/dist/generate/stream.d.ts +20 -0
- package/dist/generate/stream.js +15 -1
- package/dist/generate/stream.js.map +1 -1
- package/dist/scaffold/asset-pipeline-skill-files.js +6 -1
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/character-rigging-skill-files.d.ts +2 -0
- package/dist/scaffold/character-rigging-skill-files.js +17 -0
- package/dist/scaffold/character-rigging-skill-files.js.map +1 -0
- 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 +28 -19
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +6 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +5 -2
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/telemetry/command-context.d.ts +23 -0
- package/dist/telemetry/command-context.js +11 -0
- package/dist/telemetry/command-context.js.map +1 -1
- package/dist/telemetry/report.d.ts +1 -0
- package/dist/telemetry/report.js +4 -1
- package/dist/telemetry/report.js.map +1 -1
- package/dist/tools/bundles.js +8 -0
- package/dist/tools/bundles.js.map +1 -1
- package/dist/verify/browser.js +25 -0
- package/dist/verify/browser.js.map +1 -1
- package/dist/verify/categories.d.ts +16 -0
- package/dist/verify/categories.js +51 -0
- package/dist/verify/categories.js.map +1 -0
- package/package.json +10 -7
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lip sync for the MetaHuman: plays a spoken line and drives the face's ARKit
|
|
3
|
+
* blendshapes from Rhubarb Lip Sync mouth cues (https://github.com/DanielSWolf/rhubarb-lip-sync),
|
|
4
|
+
* falling back to an amplitude-driven jaw when a line ships without cues.
|
|
5
|
+
*
|
|
6
|
+
* Rhubarb's cues are the Preston Blair mouth shapes: A (closed, M/B/P), B (slightly open,
|
|
7
|
+
* most consonants + EE), C (open, EH), D (wide open, AA), E (rounded, AO/ER), F (puckered,
|
|
8
|
+
* UW/OW/W), G (F/V, teeth on lip), H (L, tongue up) and X (rest). Each is expressed here
|
|
9
|
+
* as a small set of ARKit weights; consecutive cues are cross-faded for co-articulation.
|
|
10
|
+
*/
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import type { MetaHumanActor } from './MetaHumanActor.js';
|
|
13
|
+
|
|
14
|
+
export interface MouthCue { start: number; end: number; value: string }
|
|
15
|
+
export interface LipSyncCues { metadata?: { soundFile?: string; duration?: number }; mouthCues: MouthCue[] }
|
|
16
|
+
export interface SpeechLine { id: string; text: string; audioUrl: string; cuesUrl?: string; duration?: number }
|
|
17
|
+
|
|
18
|
+
/** ARKit blendshape weights per Rhubarb mouth shape. */
|
|
19
|
+
export const VISEMES: Record<string, Record<string, number>> = {
|
|
20
|
+
X: {},
|
|
21
|
+
A: { mouthPressLeft: 0.35, mouthPressRight: 0.35, mouthRollLower: 0.15, mouthRollUpper: 0.1 },
|
|
22
|
+
B: { jawOpen: 0.12, mouthStretchLeft: 0.25, mouthStretchRight: 0.25, mouthUpperUpLeft: 0.1, mouthUpperUpRight: 0.1, mouthLowerDownLeft: 0.15, mouthLowerDownRight: 0.15 },
|
|
23
|
+
C: { jawOpen: 0.32, mouthLowerDownLeft: 0.2, mouthLowerDownRight: 0.2, mouthUpperUpLeft: 0.15, mouthUpperUpRight: 0.15, mouthStretchLeft: 0.1, mouthStretchRight: 0.1 },
|
|
24
|
+
D: { jawOpen: 0.62, mouthLowerDownLeft: 0.25, mouthLowerDownRight: 0.25, mouthUpperUpLeft: 0.2, mouthUpperUpRight: 0.2 },
|
|
25
|
+
E: { jawOpen: 0.26, mouthFunnel: 0.45, mouthPucker: 0.15 },
|
|
26
|
+
F: { jawOpen: 0.1, mouthPucker: 0.75, mouthFunnel: 0.3 },
|
|
27
|
+
G: { jawOpen: 0.08, mouthRollLower: 0.55, mouthUpperUpLeft: 0.12, mouthUpperUpRight: 0.12, mouthShrugUpper: 0.1 },
|
|
28
|
+
H: { jawOpen: 0.28, tongueOut: 0.15, mouthLowerDownLeft: 0.1, mouthLowerDownRight: 0.1 },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Every blendshape any viseme touches — these are zeroed when a shape does not mention them. */
|
|
32
|
+
const MOUTH_SHAPES: string[] = Array.from(new Set(Object.values(VISEMES).flatMap((v) => Object.keys(v))));
|
|
33
|
+
|
|
34
|
+
/** How far ahead of a cue boundary the mouth starts moving toward the next shape (s). */
|
|
35
|
+
const COARTICULATION = 0.06;
|
|
36
|
+
|
|
37
|
+
export class MetaHumanSpeech {
|
|
38
|
+
private audio: HTMLAudioElement | null = null;
|
|
39
|
+
private ctx: AudioContext | null = null;
|
|
40
|
+
private analyser: AnalyserNode | null = null;
|
|
41
|
+
private readonly samples = new Uint8Array(512);
|
|
42
|
+
private cues: MouthCue[] = [];
|
|
43
|
+
private cueIdx = 0;
|
|
44
|
+
private speaking = false;
|
|
45
|
+
private line: SpeechLine | null = null;
|
|
46
|
+
private readonly current = new Map<string, number>();
|
|
47
|
+
private energy = 0;
|
|
48
|
+
private rms = 0;
|
|
49
|
+
private hold: string | null = null;
|
|
50
|
+
/**
|
|
51
|
+
* Clock-driven mode (the F9 recorder): cue time advances by the engine's frame delta instead of
|
|
52
|
+
* the audio element's clock, so the visemes land on the same frames whatever the capture speed,
|
|
53
|
+
* and the line runs even where audio playback is refused. The audio track is muxed in afterwards.
|
|
54
|
+
*/
|
|
55
|
+
private clockDriven = false;
|
|
56
|
+
private clockT = 0;
|
|
57
|
+
private clockEnd = 0;
|
|
58
|
+
private peakJaw = 0;
|
|
59
|
+
onEnd: ((line: SpeechLine) => void) | null = null;
|
|
60
|
+
|
|
61
|
+
constructor(private readonly actor: MetaHumanActor) {}
|
|
62
|
+
|
|
63
|
+
static async loadLines(url: string): Promise<SpeechLine[]> {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { cache: 'no-store' });
|
|
66
|
+
if (!res.ok) return [];
|
|
67
|
+
const lines = (await res.json()) as SpeechLine[];
|
|
68
|
+
return Array.isArray(lines) ? lines : [];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
isSpeaking(): boolean { return this.speaking; }
|
|
75
|
+
setClockDriven(on: boolean): void { this.clockDriven = on; }
|
|
76
|
+
isClockDriven(): boolean { return this.clockDriven; }
|
|
77
|
+
getEnergy(): number { return this.energy; }
|
|
78
|
+
getLine(): SpeechLine | null { return this.line; }
|
|
79
|
+
|
|
80
|
+
/** Start a line. Must be called from a user gesture the first time (browser audio policy). */
|
|
81
|
+
async speak(line: SpeechLine): Promise<boolean> {
|
|
82
|
+
this.stop();
|
|
83
|
+
this.line = line;
|
|
84
|
+
let cues: LipSyncCues | null = null;
|
|
85
|
+
if (line.cuesUrl) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(line.cuesUrl, { cache: 'no-store' });
|
|
88
|
+
if (res.ok) cues = (await res.json()) as LipSyncCues;
|
|
89
|
+
} catch { /* no cues → amplitude fallback */ }
|
|
90
|
+
}
|
|
91
|
+
this.cues = cues?.mouthCues ?? [];
|
|
92
|
+
this.cueIdx = 0;
|
|
93
|
+
|
|
94
|
+
const audio = new Audio(line.audioUrl);
|
|
95
|
+
audio.preload = 'auto';
|
|
96
|
+
audio.crossOrigin = 'anonymous';
|
|
97
|
+
this.audio = audio;
|
|
98
|
+
this.analyser = null;
|
|
99
|
+
try {
|
|
100
|
+
if (!this.ctx) this.ctx = new AudioContext();
|
|
101
|
+
if (this.ctx.state === 'suspended') await this.ctx.resume();
|
|
102
|
+
const source = this.ctx.createMediaElementSource(audio);
|
|
103
|
+
const analyser = this.ctx.createAnalyser();
|
|
104
|
+
analyser.fftSize = 512;
|
|
105
|
+
analyser.smoothingTimeConstant = 0.4;
|
|
106
|
+
source.connect(analyser);
|
|
107
|
+
analyser.connect(this.ctx.destination);
|
|
108
|
+
this.analyser = analyser;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn('[MetaHumanSpeech] no AudioContext analyser (audio still plays):', err);
|
|
111
|
+
}
|
|
112
|
+
audio.addEventListener('ended', () => { if (!this.clockDriven) this.finish(); });
|
|
113
|
+
this.clockT = 0;
|
|
114
|
+
this.clockEnd = line.duration ?? (this.cues.length ? this.cues[this.cues.length - 1]!.end : 0);
|
|
115
|
+
try {
|
|
116
|
+
await audio.play();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.warn('[MetaHumanSpeech] audio.play() rejected (needs a user gesture?):', err);
|
|
119
|
+
this.audio = null;
|
|
120
|
+
this.analyser = null;
|
|
121
|
+
if (!this.clockDriven) return false; // clock-driven: the face still runs, the track is muxed later
|
|
122
|
+
}
|
|
123
|
+
this.speaking = true;
|
|
124
|
+
this.hold = null;
|
|
125
|
+
this.peakJaw = 0;
|
|
126
|
+
console.log(`[MetaHumanSpeech] speaking "${line.id}" (${this.cues.length} cues${this.analyser ? ', analyser' : ''})`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop(): void {
|
|
131
|
+
if (this.audio) {
|
|
132
|
+
this.audio.pause();
|
|
133
|
+
this.audio.src = '';
|
|
134
|
+
this.audio = null;
|
|
135
|
+
}
|
|
136
|
+
this.speaking = false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private finish(): void {
|
|
140
|
+
if (!this.speaking) return;
|
|
141
|
+
this.speaking = false;
|
|
142
|
+
const line = this.line;
|
|
143
|
+
this.audio = null;
|
|
144
|
+
if (line) this.onEnd?.(line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Debug: snap and HOLD the mouth on one Rhubarb shape (screenshots, tuning); `null`/'X' releases. */
|
|
148
|
+
scrub(shape: string | null): void {
|
|
149
|
+
this.hold = shape && shape !== 'X' ? shape : null;
|
|
150
|
+
const target = VISEMES[shape ?? 'X'] ?? {};
|
|
151
|
+
for (const name of MOUTH_SHAPES) {
|
|
152
|
+
const v = target[name] ?? 0;
|
|
153
|
+
this.current.set(name, v);
|
|
154
|
+
this.actor.setMorph(name, v);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
debug(): Record<string, unknown> {
|
|
159
|
+
const t = this.audio?.currentTime ?? 0;
|
|
160
|
+
const cue = this.cues[this.cueIdx];
|
|
161
|
+
return { speaking: this.speaking, t, cueIdx: this.cueIdx, cues: this.cues.length, shape: cue?.value ?? null, energy: this.energy, rms: this.rms, analyser: !!this.analyser, line: this.line?.id ?? null, peakJaw: this.peakJaw, hold: this.hold };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
update(deltaTime: number): void {
|
|
165
|
+
const dt = Math.min(deltaTime, 0.1);
|
|
166
|
+
if (this.speaking && this.clockDriven) {
|
|
167
|
+
this.clockT += dt;
|
|
168
|
+
if (this.clockEnd > 0 && this.clockT >= this.clockEnd) this.finish();
|
|
169
|
+
}
|
|
170
|
+
if (!this.speaking || (!this.audio && !this.clockDriven)) {
|
|
171
|
+
this.energy += (0 - this.energy) * (1 - Math.exp(-dt * 8));
|
|
172
|
+
if (this.hold) return; // a scrubbed shape stays put
|
|
173
|
+
if (this.current.size) this.blendTo({}, dt, 14);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Loudness (for brow/head accents, and the no-cues fallback).
|
|
177
|
+
this.rms = 0;
|
|
178
|
+
if (this.analyser) {
|
|
179
|
+
this.analyser.getByteTimeDomainData(this.samples);
|
|
180
|
+
let sum = 0;
|
|
181
|
+
for (let i = 0; i < this.samples.length; i++) {
|
|
182
|
+
const v = (this.samples[i]! - 128) / 128;
|
|
183
|
+
sum += v * v;
|
|
184
|
+
}
|
|
185
|
+
this.rms = Math.sqrt(sum / this.samples.length);
|
|
186
|
+
}
|
|
187
|
+
const t = this.clockDriven ? this.clockT : (this.audio?.currentTime ?? 0);
|
|
188
|
+
// Loudness: from the analyser in real time; from the cue shape (any open mouth) on the clock.
|
|
189
|
+
const cueNow = this.cues.length ? this.cues.find((c) => t >= c.start && t < c.end) : undefined;
|
|
190
|
+
const loud = this.clockDriven ? (cueNow && cueNow.value !== 'X' ? 0.6 : 0) : THREE.MathUtils.clamp(this.rms * 6, 0, 1);
|
|
191
|
+
this.energy += (loud - this.energy) * (1 - Math.exp(-dt * 12));
|
|
192
|
+
|
|
193
|
+
let target: Record<string, number>;
|
|
194
|
+
if (this.cues.length > 0) {
|
|
195
|
+
while (this.cueIdx < this.cues.length - 1 && t >= this.cues[this.cueIdx]!.end) this.cueIdx++;
|
|
196
|
+
const cue = this.cues[this.cueIdx]!;
|
|
197
|
+
const shape = t >= cue.start && t < cue.end ? cue.value : 'X';
|
|
198
|
+
target = { ...(VISEMES[shape] ?? {}) };
|
|
199
|
+
const next = this.cues[this.cueIdx + 1];
|
|
200
|
+
const remaining = cue.end - t;
|
|
201
|
+
if (next && remaining < COARTICULATION) {
|
|
202
|
+
const w = (1 - remaining / COARTICULATION) * 0.6;
|
|
203
|
+
const nextTarget = VISEMES[next.value] ?? {};
|
|
204
|
+
for (const name of MOUTH_SHAPES) {
|
|
205
|
+
const a = target[name] ?? 0;
|
|
206
|
+
const b = nextTarget[name] ?? 0;
|
|
207
|
+
target[name] = a + (b - a) * w;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Louder syllables open the jaw a little more.
|
|
211
|
+
if (this.analyser && target.jawOpen) target.jawOpen *= 0.85 + 0.3 * this.energy;
|
|
212
|
+
} else {
|
|
213
|
+
// No cues: amplitude-driven jaw flap.
|
|
214
|
+
const open = THREE.MathUtils.clamp(this.rms * 5, 0, 0.7);
|
|
215
|
+
target = { jawOpen: open, mouthFunnel: open * 0.3, mouthLowerDownLeft: open * 0.3, mouthLowerDownRight: open * 0.3 };
|
|
216
|
+
}
|
|
217
|
+
this.blendTo(target, dt, 24);
|
|
218
|
+
this.peakJaw = Math.max(this.peakJaw, this.current.get('jawOpen') ?? 0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private blendTo(target: Record<string, number>, dt: number, rate: number): void {
|
|
222
|
+
const k = 1 - Math.exp(-dt * rate);
|
|
223
|
+
for (const name of MOUTH_SHAPES) {
|
|
224
|
+
const cur = this.current.get(name) ?? 0;
|
|
225
|
+
const tgt = target[name] ?? 0;
|
|
226
|
+
const v = cur + (tgt - cur) * k;
|
|
227
|
+
if (tgt === 0 && Math.abs(v) < 1e-3) {
|
|
228
|
+
this.current.delete(name);
|
|
229
|
+
this.actor.setMorph(name, 0);
|
|
230
|
+
} else {
|
|
231
|
+
this.current.set(name, v);
|
|
232
|
+
this.actor.setMorph(name, v);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
dispose(): void {
|
|
238
|
+
this.stop();
|
|
239
|
+
this.ctx?.close().catch(() => { /* ignore */ });
|
|
240
|
+
this.ctx = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image-based studio lighting for the MetaHuman lab: a real HDRI (Poly Haven,
|
|
3
|
+
* CC0) prefiltered into `scene.environment`, plus one fixed rim light.
|
|
4
|
+
*
|
|
5
|
+
* The engine's AmbientLighting builds scene.environment from the skybox (or a
|
|
6
|
+
* neutral grey fallback) and may re-apply it on skybox events, so this class
|
|
7
|
+
* re-asserts its own map right before every render.
|
|
8
|
+
*/
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
|
|
11
|
+
import { PMREMGenerator as PMREMGeneratorWebGPU } from 'three/webgpu';
|
|
12
|
+
import { PMREMGenerator as PMREMGeneratorWebGL } from 'three/src/extras/PMREMGenerator.js';
|
|
13
|
+
import { isWebGpuActive } from 'engine/RendererType.js';
|
|
14
|
+
import type { EngineLike } from 'types/game.js';
|
|
15
|
+
|
|
16
|
+
export interface StudioEnvironmentOptions {
|
|
17
|
+
/** Equirectangular .hdr (Radiance RGBE). */
|
|
18
|
+
hdrUrl: string;
|
|
19
|
+
/** Yaw of the environment in radians — turns the softboxes around the subject. */
|
|
20
|
+
rotationY: number;
|
|
21
|
+
/** Rim light colour / intensity / direction (world-space, pointing FROM the light). */
|
|
22
|
+
rim: { color: string; intensity: number; direction: { x: number; y: number; z: number } } | null;
|
|
23
|
+
/** Also show the HDRI as the visible sky (scene.background), rotated like the environment. */
|
|
24
|
+
background?: boolean;
|
|
25
|
+
/** A different equirect for the visible sky (`.hdr`, or an LDR .png/.jpg); the HDRI still lights. Falls back to the HDRI if it fails to load. */
|
|
26
|
+
backgroundUrl?: string;
|
|
27
|
+
backgroundIntensity?: number;
|
|
28
|
+
backgroundBlurriness?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_STUDIO_ENVIRONMENT: StudioEnvironmentOptions = {
|
|
32
|
+
hdrUrl: '/assets/env/brown_photostudio_02_1k.hdr',
|
|
33
|
+
rotationY: 0,
|
|
34
|
+
rim: { color: '#dfe9ff', intensity: 1.6, direction: { x: -0.6, y: 0.9, z: -1.0 } },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type PmremLike = { fromEquirectangular(tex: THREE.Texture): THREE.RenderTarget; dispose(): void };
|
|
38
|
+
|
|
39
|
+
export class StudioEnvironment {
|
|
40
|
+
private target: THREE.RenderTarget | null = null;
|
|
41
|
+
private envTexture: THREE.Texture | null = null;
|
|
42
|
+
private backgroundTexture: THREE.Texture | null = null;
|
|
43
|
+
private rimLight: THREE.DirectionalLight | null = null;
|
|
44
|
+
private unregister: (() => void) | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly engine: EngineLike, private readonly options: StudioEnvironmentOptions) {}
|
|
47
|
+
|
|
48
|
+
async load(): Promise<void> {
|
|
49
|
+
const scene = this.engine.scene;
|
|
50
|
+
const renderer = this.engine.renderer;
|
|
51
|
+
if (!scene || !renderer) throw new Error('[StudioEnvironment] scene/renderer not ready');
|
|
52
|
+
|
|
53
|
+
// WebGPURenderer.init() is fired without await by the engine; PMREM needs it done.
|
|
54
|
+
const r = renderer as unknown as { hasInitialized?: () => boolean; init?: () => Promise<unknown> };
|
|
55
|
+
if (typeof r.hasInitialized === 'function' && !r.hasInitialized()) {
|
|
56
|
+
await r.init?.();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hdr = await new RGBELoader().loadAsync(this.options.hdrUrl);
|
|
60
|
+
hdr.mapping = THREE.EquirectangularReflectionMapping;
|
|
61
|
+
|
|
62
|
+
const pmrem: PmremLike = isWebGpuActive()
|
|
63
|
+
? new PMREMGeneratorWebGPU(renderer as unknown as ConstructorParameters<typeof PMREMGeneratorWebGPU>[0])
|
|
64
|
+
: new PMREMGeneratorWebGL(renderer);
|
|
65
|
+
this.target = pmrem.fromEquirectangular(hdr);
|
|
66
|
+
pmrem.dispose();
|
|
67
|
+
hdr.dispose();
|
|
68
|
+
|
|
69
|
+
this.envTexture = this.target.texture;
|
|
70
|
+
scene.environment = this.envTexture;
|
|
71
|
+
scene.environmentRotation.set(0, this.options.rotationY, 0);
|
|
72
|
+
if (this.options.background) {
|
|
73
|
+
// Keep an equirect for the sky: the PMREM only holds prefiltered mips. A separate sky map
|
|
74
|
+
// (the street's composite with the districts' styles on their sides) takes precedence.
|
|
75
|
+
let sky: THREE.Texture | null = null;
|
|
76
|
+
const url = this.options.backgroundUrl;
|
|
77
|
+
if (url) {
|
|
78
|
+
try {
|
|
79
|
+
if (/\.hdr$/i.test(url)) sky = await new RGBELoader().loadAsync(url);
|
|
80
|
+
else { sky = await new THREE.TextureLoader().loadAsync(url); sky.colorSpace = THREE.SRGBColorSpace; }
|
|
81
|
+
console.log('[StudioEnvironment] sky map:', url);
|
|
82
|
+
} catch (err) { console.warn('[StudioEnvironment] sky map failed, using the HDRI:', url, err); sky = null; }
|
|
83
|
+
}
|
|
84
|
+
if (!sky) sky = await new RGBELoader().loadAsync(this.options.hdrUrl);
|
|
85
|
+
sky.mapping = THREE.EquirectangularReflectionMapping;
|
|
86
|
+
this.backgroundTexture = sky;
|
|
87
|
+
scene.background = sky;
|
|
88
|
+
scene.backgroundRotation.set(0, this.options.rotationY, 0);
|
|
89
|
+
scene.backgroundIntensity = this.options.backgroundIntensity ?? 1;
|
|
90
|
+
scene.backgroundBlurriness = this.options.backgroundBlurriness ?? 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.options.rim && !this.rimLight) {
|
|
94
|
+
const d = this.options.rim.direction;
|
|
95
|
+
this.rimLight = new THREE.DirectionalLight(new THREE.Color(this.options.rim.color), this.options.rim.intensity);
|
|
96
|
+
this.rimLight.position.set(d.x, d.y, d.z).normalize().multiplyScalar(20);
|
|
97
|
+
this.rimLight.target.position.set(0, 1, 0);
|
|
98
|
+
this.rimLight.castShadow = false;
|
|
99
|
+
scene.add(this.rimLight);
|
|
100
|
+
scene.add(this.rimLight.target);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Keep our map in place even if AmbientLighting swaps scene.environment later.
|
|
104
|
+
this.unregister = this.engine.registerBeforeRender?.(() => {
|
|
105
|
+
if (this.envTexture && scene.environment !== this.envTexture) scene.environment = this.envTexture;
|
|
106
|
+
if (this.backgroundTexture && scene.background !== this.backgroundTexture) scene.background = this.backgroundTexture;
|
|
107
|
+
}) ?? null;
|
|
108
|
+
console.log('[StudioEnvironment] HDRI environment ready:', this.options.hdrUrl);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Aim the rim light so it always sits behind-above the subject relative to the camera. */
|
|
112
|
+
updateRim(subject: THREE.Vector3, cameraPos: THREE.Vector3): void {
|
|
113
|
+
if (!this.rimLight) return;
|
|
114
|
+
const toCam = new THREE.Vector3().subVectors(cameraPos, subject); toCam.y = 0;
|
|
115
|
+
if (toCam.lengthSq() < 1e-6) return;
|
|
116
|
+
toCam.normalize();
|
|
117
|
+
// Opposite side of the camera, lifted, and swung 35° to one side.
|
|
118
|
+
const behind = toCam.clone().multiplyScalar(-1).applyAxisAngle(new THREE.Vector3(0, 1, 0), 0.6);
|
|
119
|
+
this.rimLight.position.copy(subject).addScaledVector(behind, 6).add(new THREE.Vector3(0, 5, 0));
|
|
120
|
+
this.rimLight.target.position.copy(subject).add(new THREE.Vector3(0, 1.2, 0));
|
|
121
|
+
this.rimLight.target.updateMatrixWorld();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setRotation(rotationY: number): void {
|
|
125
|
+
this.engine.scene?.environmentRotation.set(0, rotationY, 0);
|
|
126
|
+
this.engine.scene?.backgroundRotation.set(0, rotationY, 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispose(): void {
|
|
130
|
+
this.unregister?.();
|
|
131
|
+
this.unregister = null;
|
|
132
|
+
if (this.rimLight && this.engine.scene) {
|
|
133
|
+
this.engine.scene.remove(this.rimLight);
|
|
134
|
+
this.engine.scene.remove(this.rimLight.target);
|
|
135
|
+
}
|
|
136
|
+
this.target?.dispose();
|
|
137
|
+
this.target = null;
|
|
138
|
+
this.envTexture = null;
|
|
139
|
+
this.backgroundTexture?.dispose();
|
|
140
|
+
this.backgroundTexture = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Make one spoken line for the MetaHuman lab.
|
|
3
|
+
|
|
4
|
+
ElevenLabs text-to-speech (--elevenlabs <voice_id>, key in ELEVENLABS_API_KEY), or --audio <file> you
|
|
5
|
+
generated elsewhere, or — placeholder only — macOS `say`
|
|
6
|
+
-> assets/speech/<id>.m4a web audio
|
|
7
|
+
PocketSphinx forced alignment of the transcript -> phones -> Rhubarb-style mouth cues
|
|
8
|
+
-> assets/speech/<id>.json {"metadata": {...}, "mouthCues": [{start, end, value}]}
|
|
9
|
+
and registers the line in assets/speech/lines.json (loaded by Game.ts; press T in the game).
|
|
10
|
+
|
|
11
|
+
Usage (from the project root, with the venv in tools/lipsync/.venv):
|
|
12
|
+
tools/lipsync/.venv/bin/python tools/lipsync/make_line.py intro "Hi, I'm Bo." [--voice Samantha]
|
|
13
|
+
tools/lipsync/.venv/bin/python tools/lipsync/make_line.py greet "<transcript>" --audio path/to/tts.mp3
|
|
14
|
+
ELEVENLABS_API_KEY=... tools/lipsync/.venv/bin/python tools/lipsync/make_line.py pitch "<text>" --elevenlabs <voice_id> [--model eleven_multilingual_v2]
|
|
15
|
+
"""
|
|
16
|
+
import argparse, base64, json, os, re, subprocess, sys, urllib.request, wave
|
|
17
|
+
|
|
18
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
19
|
+
OUT = os.path.join(ROOT, 'assets', 'speech')
|
|
20
|
+
|
|
21
|
+
# ARPAbet phone -> Rhubarb mouth shape (A closed MBP, B teeth/most consonants, C open EH,
|
|
22
|
+
# D wide AA, E rounded AO/ER, F puckered UW/OW/W, G F/V, H L, X rest).
|
|
23
|
+
PHONE_SHAPE = {
|
|
24
|
+
'AA': 'D', 'AE': 'C', 'AH': 'C', 'AO': 'E', 'AW': 'D', 'AY': 'D', 'EH': 'C', 'ER': 'E', 'EY': 'C',
|
|
25
|
+
'IH': 'B', 'IY': 'B', 'OW': 'E', 'OY': 'E', 'UH': 'F', 'UW': 'F',
|
|
26
|
+
'B': 'A', 'M': 'A', 'P': 'A', 'F': 'G', 'V': 'G', 'L': 'H', 'W': 'F', 'R': 'E',
|
|
27
|
+
'SIL': 'X', '<sil>': 'X', '<s>': 'X', '</s>': 'X', '+SPN+': 'X', '+NSN+': 'X', '+BREATH+': 'X',
|
|
28
|
+
}
|
|
29
|
+
DEFAULT_SHAPE = 'B'
|
|
30
|
+
MIN_CUE = 0.03 # seconds; shorter cues are merged into their predecessor
|
|
31
|
+
|
|
32
|
+
# Pronunciations for words the CMU dictionary lacks. Extend as your lines need it.
|
|
33
|
+
EXTRA_WORDS = {
|
|
34
|
+
'metahuman': 'M EH T AH HH Y UW M AH N', 'metahumans': 'M EH T AH HH Y UW M AH N Z',
|
|
35
|
+
'bitmagic': 'B IH T M AE JH IH K', 'arkit': 'AA R K IH T',
|
|
36
|
+
'blendshape': 'B L EH N D SH EY P', 'blendshapes': 'B L EH N D SH EY P S',
|
|
37
|
+
'voxel': 'V AA K S AH L', 'voxels': 'V AA K S AH L Z',
|
|
38
|
+
}
|
|
39
|
+
ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
|
|
40
|
+
'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
|
|
41
|
+
TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']
|
|
42
|
+
|
|
43
|
+
def num_words(n):
|
|
44
|
+
if n < 20: return ONES[n]
|
|
45
|
+
if n < 100: return TENS[n // 10] + ('' if n % 10 == 0 else ' ' + ONES[n % 10])
|
|
46
|
+
if n < 1000: return ONES[n // 100] + ' hundred' + ('' if n % 100 == 0 else ' ' + num_words(n % 100))
|
|
47
|
+
return ' '.join(ONES[int(c)] for c in str(n))
|
|
48
|
+
|
|
49
|
+
def normalize(text):
|
|
50
|
+
t = text.lower().replace('-', ' ').replace('—', ' ').replace('…', ' ')
|
|
51
|
+
t = re.sub(r'\d+', lambda m: ' ' + num_words(int(m.group())) + ' ', t)
|
|
52
|
+
t = re.sub(r"[^a-z' ]+", ' ', t)
|
|
53
|
+
return [w for w in t.split() if w]
|
|
54
|
+
|
|
55
|
+
def run(cmd, **kw):
|
|
56
|
+
subprocess.run(cmd, check=True, **kw)
|
|
57
|
+
|
|
58
|
+
def elevenlabs_tts(args, base):
|
|
59
|
+
"""ElevenLabs `with-timestamps`: the audio plus character-level alignment (kept next to the line for
|
|
60
|
+
tooling; the mouth cues still come from the forced alignment below, which yields phones)."""
|
|
61
|
+
key = os.environ.get('ELEVENLABS_API_KEY')
|
|
62
|
+
if not key: sys.exit('[make_line] --elevenlabs needs ELEVENLABS_API_KEY in the environment')
|
|
63
|
+
api = (os.environ.get('ELEVENLABS_API_URL') or 'https://api.elevenlabs.io').rstrip('/')
|
|
64
|
+
body = json.dumps({'text': args.text, 'model_id': args.model, 'output_format': 'mp3_44100_128'}).encode()
|
|
65
|
+
req = urllib.request.Request(f'{api}/v1/text-to-speech/{args.elevenlabs}/with-timestamps', data=body,
|
|
66
|
+
headers={'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'application/json'})
|
|
67
|
+
with urllib.request.urlopen(req, timeout=120) as res:
|
|
68
|
+
payload = json.load(res)
|
|
69
|
+
mp3 = base + '.source.mp3'
|
|
70
|
+
with open(mp3, 'wb') as f: f.write(base64.b64decode(payload['audio_base64']))
|
|
71
|
+
with open(base + '.alignment.json', 'w') as f: json.dump(payload.get('alignment') or payload.get('normalized_alignment') or {}, f)
|
|
72
|
+
print(f'[make_line] ElevenLabs voice {args.elevenlabs} / {args.model}: {os.path.getsize(mp3)} bytes', file=sys.stderr)
|
|
73
|
+
return mp3
|
|
74
|
+
|
|
75
|
+
def make_audio(args, base):
|
|
76
|
+
aiff = base + '.aiff'
|
|
77
|
+
if args.elevenlabs:
|
|
78
|
+
src = elevenlabs_tts(args, base)
|
|
79
|
+
elif args.audio:
|
|
80
|
+
src = args.audio
|
|
81
|
+
else:
|
|
82
|
+
print('[make_line] WARNING: macOS `say` is a PLACEHOLDER voice — use --elevenlabs or --audio for anything shipped', file=sys.stderr)
|
|
83
|
+
say = ['say', '-o', aiff, args.text]
|
|
84
|
+
if args.voice: say[1:1] = ['-v', args.voice]
|
|
85
|
+
run(say)
|
|
86
|
+
src = aiff
|
|
87
|
+
run(['ffmpeg', '-y', '-loglevel', 'error', '-i', src, '-ac', '1', '-ar', '16000', '-sample_fmt', 's16', base + '.wav'])
|
|
88
|
+
run(['ffmpeg', '-y', '-loglevel', 'error', '-i', src, '-vn', '-c:a', 'aac', '-b:a', '96k', base + '.m4a'])
|
|
89
|
+
if os.path.exists(aiff): os.remove(aiff)
|
|
90
|
+
|
|
91
|
+
def align(wav_path, words):
|
|
92
|
+
from pocketsphinx import Decoder
|
|
93
|
+
with wave.open(wav_path, 'rb') as w:
|
|
94
|
+
assert w.getframerate() == 16000 and w.getnchannels() == 1 and w.getsampwidth() == 2, 'need 16 kHz mono 16-bit'
|
|
95
|
+
pcm = w.readframes(w.getnframes())
|
|
96
|
+
duration = w.getnframes() / 16000
|
|
97
|
+
dec = Decoder(samprate=16000, loglevel='ERROR')
|
|
98
|
+
try:
|
|
99
|
+
frate = int(dec.config['frate'])
|
|
100
|
+
except Exception:
|
|
101
|
+
frate = 100
|
|
102
|
+
for word, phones in EXTRA_WORDS.items():
|
|
103
|
+
dec.add_word(word, phones, True)
|
|
104
|
+
oov = [w for w in words if dec.lookup_word(w) is None]
|
|
105
|
+
if oov:
|
|
106
|
+
raise SystemExit(f'words missing from the dictionary: {oov} - add pronunciations to EXTRA_WORDS in {__file__}')
|
|
107
|
+
dec.set_align_text(' '.join(words))
|
|
108
|
+
dec.start_utt(); dec.process_raw(pcm, False, True); dec.end_utt()
|
|
109
|
+
dec.set_alignment() # second pass: phone-level timing
|
|
110
|
+
dec.start_utt(); dec.process_raw(pcm, False, True); dec.end_utt()
|
|
111
|
+
phones = []
|
|
112
|
+
for word in dec.get_alignment():
|
|
113
|
+
for ph in word:
|
|
114
|
+
phones.append((ph.name, ph.start / frate, (ph.start + ph.duration) / frate))
|
|
115
|
+
return phones, duration
|
|
116
|
+
|
|
117
|
+
def to_cues(phones, duration):
|
|
118
|
+
cues = []
|
|
119
|
+
for name, start, end in phones:
|
|
120
|
+
base = re.sub(r'\d', '', name).upper()
|
|
121
|
+
shape = PHONE_SHAPE.get(base, PHONE_SHAPE.get(name, DEFAULT_SHAPE))
|
|
122
|
+
if cues and cues[-1]['value'] == shape:
|
|
123
|
+
cues[-1]['end'] = end
|
|
124
|
+
else:
|
|
125
|
+
cues.append({'start': start, 'end': end, 'value': shape})
|
|
126
|
+
merged = []
|
|
127
|
+
for c in cues: # merge blips into their predecessor so the mouth doesn't flicker
|
|
128
|
+
if merged and (c['end'] - c['start']) < MIN_CUE:
|
|
129
|
+
merged[-1]['end'] = c['end']
|
|
130
|
+
else:
|
|
131
|
+
merged.append(c)
|
|
132
|
+
if merged:
|
|
133
|
+
merged[-1]['end'] = max(merged[-1]['end'], duration)
|
|
134
|
+
if merged[-1]['value'] != 'X':
|
|
135
|
+
merged.append({'start': merged[-1]['end'], 'end': duration, 'value': 'X'})
|
|
136
|
+
for c in merged:
|
|
137
|
+
c['start'] = round(c['start'], 3); c['end'] = round(c['end'], 3)
|
|
138
|
+
return merged
|
|
139
|
+
|
|
140
|
+
def main():
|
|
141
|
+
ap = argparse.ArgumentParser()
|
|
142
|
+
ap.add_argument('id'); ap.add_argument('text')
|
|
143
|
+
ap.add_argument('--voice', default=None, help='macOS voice for `say` (default: system voice)')
|
|
144
|
+
ap.add_argument('--audio', default=None, help='use this audio file instead of `say` (any ffmpeg format)')
|
|
145
|
+
ap.add_argument('--elevenlabs', default=None, metavar='VOICE_ID', help='ElevenLabs text-to-speech with this voice id (ELEVENLABS_API_KEY in the env)')
|
|
146
|
+
ap.add_argument('--model', default='eleven_multilingual_v2', help='ElevenLabs model id (with --elevenlabs)')
|
|
147
|
+
args = ap.parse_args()
|
|
148
|
+
os.makedirs(OUT, exist_ok=True)
|
|
149
|
+
base = os.path.join(OUT, args.id)
|
|
150
|
+
with open(base + '.txt', 'w') as f: f.write(args.text)
|
|
151
|
+
make_audio(args, base)
|
|
152
|
+
words = normalize(args.text)
|
|
153
|
+
cues, duration = None, None
|
|
154
|
+
try:
|
|
155
|
+
phones, duration = align(base + '.wav', words)
|
|
156
|
+
cues = to_cues(phones, duration)
|
|
157
|
+
with open(base + '.json', 'w') as f:
|
|
158
|
+
json.dump({'metadata': {'soundFile': f'{args.id}.m4a', 'duration': round(duration, 3)}, 'mouthCues': cues}, f, indent=1)
|
|
159
|
+
except SystemExit:
|
|
160
|
+
raise
|
|
161
|
+
except Exception as e: # keep the audio; the game falls back to amplitude lip-flap
|
|
162
|
+
print(f'[make_line] alignment failed ({e}); line registered WITHOUT cues', file=sys.stderr)
|
|
163
|
+
if duration is None:
|
|
164
|
+
duration = float(subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', base + '.m4a']))
|
|
165
|
+
os.remove(base + '.wav')
|
|
166
|
+
lines_path = os.path.join(OUT, 'lines.json')
|
|
167
|
+
lines = json.load(open(lines_path)) if os.path.exists(lines_path) else []
|
|
168
|
+
lines = [l for l in lines if l.get('id') != args.id]
|
|
169
|
+
entry = {'id': args.id, 'text': args.text, 'audioUrl': f'/assets/speech/{args.id}.m4a', 'duration': round(duration, 2)}
|
|
170
|
+
if cues: entry['cuesUrl'] = f'/assets/speech/{args.id}.json'
|
|
171
|
+
lines.append(entry)
|
|
172
|
+
json.dump(lines, open(lines_path, 'w'), indent=2)
|
|
173
|
+
if cues:
|
|
174
|
+
counts = {}
|
|
175
|
+
for c in cues: counts[c['value']] = counts.get(c['value'], 0) + 1
|
|
176
|
+
print(f"{args.id}: {duration:.2f} s, {len(cues)} cues {counts}; first: " + ' '.join(f"{c['value']}@{c['start']}" for c in cues[:14]))
|
|
177
|
+
else:
|
|
178
|
+
print(f'{args.id}: {duration:.2f} s, no cues')
|
|
179
|
+
|
|
180
|
+
if __name__ == '__main__':
|
|
181
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MetaHuman tools
|
|
2
|
+
|
|
3
|
+
Installed by `bitmagic tools install metahuman`, used by the `importing-metahumans` skill. Everything
|
|
4
|
+
here runs with Node ≥ 18; `npm install` in this directory fetches gltf-transform (GLB editing) and
|
|
5
|
+
playwright-core (the rigs drive the system Chrome).
|
|
6
|
+
|
|
7
|
+
| File | Does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `prepare.sh <export.glb> <out.glb>` | the five preparation steps below, in order |
|
|
10
|
+
| `strip-draco.mjs` | rewrite without Draco (the engine's loader has no decoder) + print an inventory |
|
|
11
|
+
| `merge-skeletons.mjs` | graft the body hierarchy's limbs onto the face hierarchy: one skeleton |
|
|
12
|
+
| `reorder-body-first.mjs` | body mesh first in the scene (the engine reads the bind pose from the first skin) |
|
|
13
|
+
| `smooth-outfit-weights.mjs` | Laplacian-smooth a garment's skin weights |
|
|
14
|
+
| `transfer-skin-weights.mjs` | give the garment the skin's weights wherever skin lies under it |
|
|
15
|
+
| `e2e/shot.mjs [port]` | headless stills of the character + hair-follows-head and locomotion checks → `e2e/shots/` |
|
|
16
|
+
| `e2e/eval.mjs "<expr>"\|probe.js [port] [shot.png]` | evaluate an expression or a probe file inside the running game |
|
|
17
|
+
| `e2e/walk-probe.mjs [port]` | walk for a few seconds and print idle-vs-walking bone / garment statistics |
|
|
18
|
+
| `e2e/probes/*.js` | the probes (arm pose, torn edges, helper-follow sweeps, clip shoulders, …) |
|
|
19
|
+
|
|
20
|
+
The rigs need `window.__lab` in the game: `installMetaHumanLabHooks(...)` from
|
|
21
|
+
`src/work/metahuman/MetaHumanLabHooks.ts` installs it. The lip-sync tool lives in `tools/lipsync/`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Evaluate a JS expression (or a .js file) inside the running game (after Play) in headless Chrome and print it.
|
|
2
|
+
// node tools/metahuman/e2e/eval.mjs "window.__lab.retargetInfo()" [port] [screenshot.png]
|
|
3
|
+
// node tools/metahuman/e2e/eval.mjs tools/metahuman/e2e/probes/arm-pose-check.js [port]
|
|
4
|
+
// Env: VIEW=640x360 (viewport), DPR=2 (device scale factor), URL_SUFFIX="?debug=1" (appended to the page URL),
|
|
5
|
+
// TIMEOUT_MS=150000 (watchdog: the browser is force-closed and the process exits 2).
|
|
6
|
+
import { chromium } from 'playwright-core';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
const expr = /\.js$/.test(process.argv[2]) && fs.existsSync(process.argv[2]) ? fs.readFileSync(process.argv[2], 'utf8') : process.argv[2];
|
|
9
|
+
const PORT = process.argv[3] || '3010'; const SHOT = process.argv[4] || null;
|
|
10
|
+
const [VW, VH] = (process.env.VIEW || '1280x720').split('x').map(Number);
|
|
11
|
+
const SUFFIX = process.env.URL_SUFFIX || '';
|
|
12
|
+
const TIMEOUT_MS = Number(process.env.TIMEOUT_MS || 150000);
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
const browser = await chromium.launch({ channel: 'chrome', headless: true,
|
|
15
|
+
args: ['--use-angle=metal', '--enable-unsafe-webgpu', `--window-size=${VW},${VH}`, '--autoplay-policy=no-user-gesture-required'] });
|
|
16
|
+
const watchdog = setTimeout(async () => { console.log(`[eval] watchdog: ${TIMEOUT_MS} ms elapsed, closing browser`); try { await browser.close(); } catch {} process.exit(2); }, TIMEOUT_MS);
|
|
17
|
+
try {
|
|
18
|
+
const page = await browser.newPage({ viewport: { width: VW, height: VH }, deviceScaleFactor: Number(process.env.DPR || 1) });
|
|
19
|
+
const MATCH = process.env.CONSOLE_MATCH ? new RegExp(process.env.CONSOLE_MATCH, 'i') : null; let errCount = 0;
|
|
20
|
+
page.on('console', (m) => { const t = m.text(); if (m.type() === 'error') { errCount++; if (errCount <= 3) console.log('PAGE[error]:', t.slice(0, 200)); } else if (MATCH && MATCH.test(t)) console.log('PAGE[' + m.type() + ']:', t.slice(0, 260)); });
|
|
21
|
+
await page.goto(`http://localhost:${PORT}/${SUFFIX}`, { waitUntil: 'load' });
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
while (Date.now() - start < 60000 && !/PLAY/i.test(await page.evaluate(() => document.body.innerText || ''))) await sleep(400);
|
|
24
|
+
await sleep(600);
|
|
25
|
+
const btn = page.getByText(/^\s*PLAY\s*$/i).first(); if (await btn.count()) await btn.click({ force: true }); else await page.mouse.click(VW / 2, VH * 0.72);
|
|
26
|
+
while (Date.now() - start < 60000 && !(await page.evaluate(() => !!(window.__lab && window.__lab.actor && window.__lab.actor.getModel())))) await sleep(400);
|
|
27
|
+
await sleep(1500);
|
|
28
|
+
const result = await page.evaluate(expr);
|
|
29
|
+
console.log(JSON.stringify(result, null, 1));
|
|
30
|
+
if (SHOT) { await sleep(700); await page.screenshot({ path: SHOT }); console.log('[shot]', SHOT); }
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(watchdog);
|
|
33
|
+
await browser.close();
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const a = window.__lab.actor; const model = a.getModel(); window.__lab.setLife(false);
|
|
3
|
+
const V = model.position.constructor;
|
|
4
|
+
const wp = (o) => o.getWorldPosition(new V());
|
|
5
|
+
const dir = (from, to) => wp(to).sub(wp(from)).normalize();
|
|
6
|
+
const deg = (u, v) => Math.round(Math.acos(Math.max(-1, Math.min(1, u.dot(v)))) * 180 / Math.PI);
|
|
7
|
+
const b = (n) => a.getBone(n);
|
|
8
|
+
const out = {};
|
|
9
|
+
// the character's arm chain directions (world)
|
|
10
|
+
const upL = dir(b('upperarm_l'), b('lowerarm_l')), foreL = dir(b('lowerarm_l'), b('hand_l'));
|
|
11
|
+
const upR = dir(b('upperarm_r'), b('lowerarm_r')), foreR = dir(b('lowerarm_r'), b('hand_r'));
|
|
12
|
+
out.bo = { upperarm_l: upL.toArray().map((x) => +x.toFixed(2)), forearm_l: foreL.toArray().map((x) => +x.toFixed(2)), upperarm_r: upR.toArray().map((x) => +x.toFixed(2)) };
|
|
13
|
+
// Mixamo clip skeleton (the engine's hidden animation rig)
|
|
14
|
+
const pl = window.__lab.playerLoader ? window.__lab.playerLoader() : null;
|
|
15
|
+
let mix = null;
|
|
16
|
+
try {
|
|
17
|
+
const ac = pl.getAnimationController(); const mp = ac.getTrackAMixamoPlayer ? ac.getTrackAMixamoPlayer() : null;
|
|
18
|
+
const bm = mp && mp.getBoneMap(); const g = (n) => bm.get(n);
|
|
19
|
+
if (bm) { const mUp = dir(g('mixamorigLeftArm'), g('mixamorigLeftForeArm')), mFore = dir(g('mixamorigLeftForeArm'), g('mixamorigLeftHand'));
|
|
20
|
+
mix = { upperarm_l: mUp.toArray().map((x) => +x.toFixed(2)), forearm_l: mFore.toArray().map((x) => +x.toFixed(2)), angleUpper: deg(mUp, upL), angleFore: deg(mFore, foreL) }; }
|
|
21
|
+
} catch (e) { mix = 'err ' + e.message; }
|
|
22
|
+
out.mixamo = mix;
|
|
23
|
+
// Hand and elbow relative to pelvis, in the model's frame
|
|
24
|
+
const pelvis = wp(b('pelvis')); const rel = (n) => wp(b(n)).sub(pelvis).toArray().map((x) => +x.toFixed(2));
|
|
25
|
+
out.relPelvis = { hand_l: rel('hand_l'), lowerarm_l: rel('lowerarm_l'), upperarm_l: rel('upperarm_l'), hand_r: rel('hand_r'), head: rel('head') };
|
|
26
|
+
// hair vs head
|
|
27
|
+
let hair = null; model.traverse((o) => { if (!hair && o.isMesh && /^Hair_/.test(o.name)) hair = o; });
|
|
28
|
+
out.hairParent = hair && hair.parent && hair.parent.name;
|
|
29
|
+
hair.geometry.computeBoundingBox(); const c = hair.localToWorld(hair.geometry.boundingBox.getCenter(new V()));
|
|
30
|
+
out.hairCenterRelHead = c.sub(wp(b('head'))).toArray().map((x) => +x.toFixed(3));
|
|
31
|
+
return out;
|
|
32
|
+
})()
|