Squashed from work: - asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire - asset-ui: loop video chains — text→image→video that ends where it began - h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers - h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor - asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits - asset-ai realtime: a feedback loop survives a resize and travels by default - asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener - asset-ai realtime: the outbound encode leaves the loop's critical path - asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned - llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own - vision tower on CUDA: the encode leg gets its two missing kernels - llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error - vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away - live: a feed that moves box takes its trip with it — one seed image - vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go - llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs - asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels - Merge branch 'ocr-perf-integration' into work - asset-ai: the live anchor can follow the trip, and text leaves the 5090 - asset-ai: the camera moves the world, and the world starts still - asset-import: the EA strategy classics, in the one 2D contract - rtsmap: one seeded generator for tiled strategy maps - asset-ui: one card for the strategy classics, with a pack dropdown - asset-ai: music3 reference-audio path, ocr/h3 backends, registry - asset: mp4 sample index for range-streaming, chat tools, import profiles - cnc: tiberium is twelve growth frames, not twelve empty variants - platform: native file and save dialogs, in-house on all three desktops - chat: the scan holds out for a lane home - chat: a full home queues you — take the free lane - chat: the preload has a percentage, and the boundless cap stops showing - llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA - sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin - sqlite_query: anti-join regression test - td import: HARV's second frame block is its harvesting cycle, not a turret - asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling - ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin - asset-ui: test health fixtures grow the realtime field they were born without - ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re - ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo - ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic - strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move - ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore - ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4) - ai-hub: job leases — work lives only while it is renewed (aicore §8) - asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9) - ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3) - ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor - ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w - ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w - asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct - ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle - asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9) - ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a - route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p - asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6) - makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026 - ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a - ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open - ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4) - asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state ( - ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path - vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1) - asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore - chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5) - asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F - asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8) - asset-store: the shrink — the store stores (aicore P7) - importer + asset-server host: the coordination era ends (aicore P7) - store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7) - client + chat dispatcher: the dead wire comes out (aicore P7/P8) - ai-hub: 0.3.0 — the health version says which era a node runs - ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing - ai-hub: the preload note percents the prefill, not the job bar - ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7) - ai-hub: an open-think model is thinking from its first token - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - zero-warning sweep, round two — the first full-workspace pass - zero-warning sweep, round three — the model lanes and the deep examples - zero-warning sweep, round four — the last stragglers - zero-warning sweep, round five — vj and chat-ui - zero-warning sweep, round six — three cascades Co-authored-by: Claude <info@makepad.nl>
709 lines
27 KiB
Rust
709 lines
27 KiB
Rust
#![cfg(feature = "motion-native")]
|
|
|
|
use makepad_ai_hub::motion_retarget::{
|
|
retarget_hy_motion_glb_with_report, HyMotionClipRef, RetargetOptions,
|
|
};
|
|
use makepad_ai_motion::hy_motion_decode::HyMotionDecoded;
|
|
use makepad_gltf::parse_glb_bytes;
|
|
use makepad_micro_serde::JsonValue;
|
|
use makepad_zip_file::zip_read_central_directory;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::Cursor;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Instant;
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../local/character_verify").join(name)
|
|
}
|
|
|
|
fn zip_member(path: &Path, name: &str) -> Vec<u8> {
|
|
let bytes = std::fs::read(path).unwrap();
|
|
let mut cursor = Cursor::new(bytes);
|
|
let directory = zip_read_central_directory(&mut cursor).unwrap();
|
|
directory
|
|
.file_headers
|
|
.iter()
|
|
.find(|header| header.file_name == name)
|
|
.unwrap_or_else(|| panic!("{} has no {name}", path.display()))
|
|
.extract(&mut cursor)
|
|
.unwrap()
|
|
}
|
|
|
|
fn npy_f32(bytes: &[u8]) -> (Vec<usize>, Vec<f32>) {
|
|
assert_eq!(&bytes[..6], b"\x93NUMPY");
|
|
let major = bytes[6];
|
|
let (header_len, data_start) = match major {
|
|
1 => (u16::from_le_bytes(bytes[8..10].try_into().unwrap()) as usize, 10),
|
|
2 | 3 => (u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize, 12),
|
|
_ => panic!("unsupported npy version {major}"),
|
|
};
|
|
let header = std::str::from_utf8(&bytes[data_start..data_start + header_len]).unwrap();
|
|
assert!(header.contains("'<f4'") || header.contains("\"<f4\""), "{header}");
|
|
assert!(header.contains("False"), "Fortran npy unsupported: {header}");
|
|
let shape_body = header
|
|
.split("'shape':")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('(')
|
|
.nth(1)
|
|
.unwrap()
|
|
.split(')')
|
|
.next()
|
|
.unwrap();
|
|
let shape: Vec<usize> = shape_body
|
|
.split(',')
|
|
.filter_map(|part| part.trim().parse().ok())
|
|
.collect();
|
|
let data = &bytes[data_start + header_len..];
|
|
assert_eq!(data.len() % 4, 0);
|
|
let values = data
|
|
.chunks_exact(4)
|
|
.map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap()))
|
|
.collect();
|
|
(shape, values)
|
|
}
|
|
|
|
fn axis_angle_matrix(axis_angle: &[f32]) -> [f32; 9] {
|
|
let v = [axis_angle[0] as f64, axis_angle[1] as f64, axis_angle[2] as f64];
|
|
let theta = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
|
if theta < 1.0e-9 {
|
|
return [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
|
}
|
|
let a = [v[0] / theta, v[1] / theta, v[2] / theta];
|
|
let (sin, cos) = theta.sin_cos();
|
|
let one = 1.0 - cos;
|
|
[
|
|
(cos + a[0] * a[0] * one) as f32,
|
|
(a[0] * a[1] * one - a[2] * sin) as f32,
|
|
(a[0] * a[2] * one + a[1] * sin) as f32,
|
|
(a[1] * a[0] * one + a[2] * sin) as f32,
|
|
(cos + a[1] * a[1] * one) as f32,
|
|
(a[1] * a[2] * one - a[0] * sin) as f32,
|
|
(a[2] * a[0] * one - a[1] * sin) as f32,
|
|
(a[2] * a[1] * one + a[0] * sin) as f32,
|
|
(cos + a[2] * a[2] * one) as f32,
|
|
]
|
|
}
|
|
|
|
fn oracle_motion() -> Option<HyMotionDecoded> {
|
|
let path = fixture("hy_debug/oracle_walk_official.npz");
|
|
if !path.is_file() {
|
|
return None;
|
|
}
|
|
let (trans_shape, translations) = npy_f32(&zip_member(&path, "trans.npy"));
|
|
let (pose_shape, poses) = npy_f32(&zip_member(&path, "poses.npy"));
|
|
let (key_shape, keypoints_3d) = npy_f32(&zip_member(&path, "keypoints3d.npy"));
|
|
assert_eq!(trans_shape[1], 3);
|
|
assert_eq!(pose_shape, vec![trans_shape[0], 156]);
|
|
assert_eq!(key_shape, vec![trans_shape[0], 52, 3]);
|
|
let frames = trans_shape[0];
|
|
let mut root_rotation_matrices = Vec::with_capacity(frames * 9);
|
|
for pose in poses.chunks_exact(156) {
|
|
root_rotation_matrices.extend_from_slice(&axis_angle_matrix(&pose[..3]));
|
|
}
|
|
Some(HyMotionDecoded {
|
|
frames,
|
|
latent_denorm: Vec::new(),
|
|
rotations_6d: Vec::new(),
|
|
translations,
|
|
local_rotation_matrices: Vec::new(),
|
|
root_rotation_matrices,
|
|
keypoints_3d,
|
|
})
|
|
}
|
|
|
|
fn usize_value(value: &JsonValue) -> usize {
|
|
match value {
|
|
JsonValue::U64(value) => *value as usize,
|
|
JsonValue::I64(value) => *value as usize,
|
|
JsonValue::F64(value) => *value as usize,
|
|
other => panic!("not usize: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn accessor_f32(parsed: &makepad_gltf::ParsedGlb, index: usize) -> Vec<f32> {
|
|
let accessor = &parsed.document.accessors_slice()[index];
|
|
assert_eq!(accessor.component_type, 5126);
|
|
let lanes = match accessor.accessor_type.as_str() {
|
|
"SCALAR" => 1,
|
|
"VEC3" => 3,
|
|
"VEC4" => 4,
|
|
other => panic!("unsupported {other}"),
|
|
};
|
|
let view = &parsed.document.buffer_views_slice()[accessor.buffer_view.unwrap()];
|
|
let start = view.byte_offset.unwrap_or(0) + accessor.byte_offset.unwrap_or(0);
|
|
let stride = view.byte_stride.unwrap_or(lanes * 4);
|
|
let bin = parsed.bin_chunk.as_ref().unwrap();
|
|
let mut output = Vec::with_capacity(accessor.count * lanes);
|
|
for item in 0..accessor.count {
|
|
for lane in 0..lanes {
|
|
let at = start + item * stride + lane * 4;
|
|
output.push(f32::from_le_bytes(bin[at..at + 4].try_into().unwrap()));
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ChannelData {
|
|
times: Vec<f32>,
|
|
values: Vec<f32>,
|
|
}
|
|
|
|
fn animation_channels(bytes: &[u8], clip_name: &str) -> HashMap<(usize, String), ChannelData> {
|
|
let parsed = parse_glb_bytes(bytes).unwrap();
|
|
let animation = parsed
|
|
.document
|
|
.animations
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|animation| {
|
|
matches!(animation.key("name"), Some(JsonValue::String(name)) if name == clip_name)
|
|
})
|
|
.unwrap();
|
|
let samplers = match animation.key("samplers").unwrap() {
|
|
JsonValue::Array(values) => values,
|
|
_ => panic!(),
|
|
};
|
|
let channels = match animation.key("channels").unwrap() {
|
|
JsonValue::Array(values) => values,
|
|
_ => panic!(),
|
|
};
|
|
channels
|
|
.iter()
|
|
.map(|channel| {
|
|
let sampler = usize_value(channel.key("sampler").unwrap());
|
|
let target = channel.key("target").unwrap();
|
|
let node = usize_value(target.key("node").unwrap());
|
|
let path = target.key("path").unwrap().string().unwrap().clone();
|
|
let input = usize_value(samplers[sampler].key("input").unwrap());
|
|
let output = usize_value(samplers[sampler].key("output").unwrap());
|
|
((node, path), ChannelData {
|
|
times: accessor_f32(&parsed, input),
|
|
values: accessor_f32(&parsed, output),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn sample_channel(channel: &ChannelData, lanes: usize, time: f32) -> Vec<f32> {
|
|
let upper = channel.times.partition_point(|value| *value <= time);
|
|
if upper == 0 {
|
|
return channel.values[..lanes].to_vec();
|
|
}
|
|
if upper == channel.times.len() {
|
|
return channel.values[channel.values.len() - lanes..].to_vec();
|
|
}
|
|
let left = upper - 1;
|
|
if (channel.times[left] - time).abs() < 1.0e-6 {
|
|
return channel.values[left * lanes..left * lanes + lanes].to_vec();
|
|
}
|
|
let mix = (time - channel.times[left]) / (channel.times[upper] - channel.times[left]);
|
|
let mut value: Vec<f32> = (0..lanes)
|
|
.map(|lane| {
|
|
let a = channel.values[left * lanes + lane];
|
|
let b = channel.values[upper * lanes + lane];
|
|
a + (b - a) * mix
|
|
})
|
|
.collect();
|
|
if lanes == 4 {
|
|
let length = value.iter().map(|v| v * v).sum::<f32>().sqrt();
|
|
for component in &mut value {
|
|
*component /= length;
|
|
}
|
|
}
|
|
value
|
|
}
|
|
|
|
fn quat_angle(left: &[f32], right: &[f32]) -> f64 {
|
|
let nl = left.iter().map(|v| (*v as f64).powi(2)).sum::<f64>().sqrt();
|
|
let nr = right.iter().map(|v| (*v as f64).powi(2)).sum::<f64>().sqrt();
|
|
let dot = left
|
|
.iter()
|
|
.zip(right)
|
|
.map(|(a, b)| *a as f64 * *b as f64)
|
|
.sum::<f64>()
|
|
/ (nl * nr);
|
|
2.0 * dot.abs().clamp(-1.0, 1.0).acos()
|
|
}
|
|
|
|
#[test]
|
|
fn corrected_blender_oracle_pose_parity_and_release_benchmark() {
|
|
// This Blender fixture predates two deliberate native-retarget fixes:
|
|
// terminal wrists now follow their real hand branch, and ankles transfer
|
|
// a full source-rest-relative foot frame instead of baking the source and
|
|
// target bind-pose pitch mismatch into the shoes. These are the only
|
|
// rotation channels allowed to diverge from that historical reference;
|
|
// every other joint remains a strict oracle.
|
|
const CORRECTED_WRISTS_AND_ANKLES: [usize; 4] = [8, 18, 26, 30];
|
|
const ROTATION_EPSILON: f64 = 2.0e-4;
|
|
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = fixture("native_mario_seed424242_oraclecontract_20k_rigged.glb");
|
|
let reference_path = std::env::var_os("MAKEPAD_RETARGET_ORACLE")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| fixture("native_mario_seed424242_oraclecontract_20k_retargetfix_walk.glb"));
|
|
if !rig_path.is_file() || !reference_path.is_file() {
|
|
eprintln!("Mario retarget fixtures absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(rig_path).unwrap();
|
|
let reference = std::fs::read(reference_path).unwrap();
|
|
let started = Instant::now();
|
|
let output = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef { name: "idle", motion: &motion }],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
if let Some(path) = std::env::var_os("MAKEPAD_RETARGET_OUTPUT") {
|
|
std::fs::write(path, &output.glb).unwrap();
|
|
}
|
|
let cold_ms = started.elapsed().as_secs_f64() * 1_000.0;
|
|
let mut timings = Vec::new();
|
|
for _ in 0..9 {
|
|
let started = Instant::now();
|
|
let again = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef { name: "idle", motion: &motion }],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(again.glb, output.glb, "native export must be deterministic");
|
|
timings.push(started.elapsed().as_secs_f64() * 1_000.0);
|
|
}
|
|
timings.sort_by(f64::total_cmp);
|
|
|
|
let ours = animation_channels(&output.glb, "idle");
|
|
let oracle = animation_channels(&reference, "idle");
|
|
assert_eq!(ours.len(), 102);
|
|
assert_eq!(oracle.len(), 102);
|
|
let mut max_translation = 0.0f64;
|
|
let mut max_scale = 0.0f64;
|
|
let mut max_rotation = 0.0f64;
|
|
let mut max_strict_rotation = 0.0f64;
|
|
let mut max_time = 0.0f64;
|
|
let mut worst_rotation = None;
|
|
let mut worst_strict_rotation = None;
|
|
let mut worst_translation = None;
|
|
let mut worst_scale = None;
|
|
let mut relaxed_rotation_nodes = HashSet::new();
|
|
let mut rotation_by_node = HashMap::<usize, (f64, usize)>::new();
|
|
for ((node, path), expected) in &oracle {
|
|
let actual = &ours[&(*node, path.clone())];
|
|
assert_eq!(actual.times.len(), motion.frames, "node {node} {path}");
|
|
let lanes = if path == "rotation" { 4 } else { 3 };
|
|
assert_eq!(actual.values.len(), motion.frames * lanes);
|
|
max_time = max_time
|
|
.max((actual.times[0] as f64 - expected.times[0] as f64).abs())
|
|
.max(
|
|
(actual.times[motion.frames - 1] as f64
|
|
- expected.times[expected.times.len() - 1] as f64)
|
|
.abs(),
|
|
);
|
|
if expected.times.len() == motion.frames {
|
|
for (actual, expected) in actual.times.iter().zip(&expected.times) {
|
|
max_time = max_time.max((*actual as f64 - *expected as f64).abs());
|
|
}
|
|
}
|
|
match path.as_str() {
|
|
"rotation" => {
|
|
for frame in 0..motion.frames {
|
|
let expected = oracle_frame(expected, 4, frame, motion.frames, actual.times[frame]);
|
|
let actual_values = &actual.values[frame * 4..frame * 4 + 4];
|
|
let angle = quat_angle(actual_values, &expected);
|
|
if angle > max_rotation {
|
|
max_rotation = angle;
|
|
worst_rotation = Some((*node, frame));
|
|
}
|
|
if CORRECTED_WRISTS_AND_ANKLES.contains(node) {
|
|
if angle >= ROTATION_EPSILON {
|
|
relaxed_rotation_nodes.insert(*node);
|
|
}
|
|
} else if angle > max_strict_rotation {
|
|
max_strict_rotation = angle;
|
|
worst_strict_rotation = Some((*node, frame));
|
|
}
|
|
let node_worst = rotation_by_node.entry(*node).or_insert((0.0, frame));
|
|
if angle > node_worst.0 {
|
|
*node_worst = (angle, frame);
|
|
}
|
|
}
|
|
}
|
|
"translation" => {
|
|
for frame in 0..motion.frames {
|
|
let expected = oracle_frame(expected, 3, frame, motion.frames, actual.times[frame]);
|
|
for (lane, (actual, expected)) in actual.values[frame * 3..frame * 3 + 3]
|
|
.iter()
|
|
.zip(expected)
|
|
.enumerate()
|
|
{
|
|
let difference = (*actual as f64 - expected as f64).abs();
|
|
if difference > max_translation {
|
|
max_translation = difference;
|
|
worst_translation = Some((*node, frame, lane, *actual, expected));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"scale" => {
|
|
for frame in 0..motion.frames {
|
|
let expected = oracle_frame(expected, 3, frame, motion.frames, actual.times[frame]);
|
|
for (lane, (actual, expected)) in actual.values[frame * 3..frame * 3 + 3]
|
|
.iter()
|
|
.zip(expected)
|
|
.enumerate()
|
|
{
|
|
let difference = (*actual as f64 - expected as f64).abs();
|
|
if difference > max_scale {
|
|
max_scale = difference;
|
|
worst_scale = Some((*node, frame, lane, *actual, expected));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
let parsed_reference = parse_glb_bytes(&reference).unwrap();
|
|
let reference_nodes = parsed_reference.document.nodes_slice();
|
|
let mut rotation_ranked: Vec<_> = rotation_by_node.into_iter().collect();
|
|
rotation_ranked.sort_by(|left, right| right.1.0.total_cmp(&left.1.0));
|
|
let rotation_ranked: Vec<_> = rotation_ranked
|
|
.into_iter()
|
|
.take(8)
|
|
.map(|(node, (angle, frame))| {
|
|
(node, reference_nodes[node].name.as_deref().unwrap_or("?"), frame, angle)
|
|
})
|
|
.collect();
|
|
eprintln!(
|
|
"native retarget: cold={cold_ms:.3}ms median={:.3}ms bytes={} report={{joints:{} mapped:{} mirrored:{} height:{:.9} scale:{:.9}}}; parity max_time={max_time:.9} max_translation={max_translation:.9} worst_translation={worst_translation:?} max_scale={max_scale:.9} worst_scale={worst_scale:?} max_rotation_rad={max_rotation:.9} worst={worst_rotation:?} max_strict_rotation_rad={max_strict_rotation:.9} strict_worst={worst_strict_rotation:?}; rotation_ranked={rotation_ranked:?}",
|
|
timings[timings.len() / 2],
|
|
output.glb.len(),
|
|
output.report.joints,
|
|
output.report.mapped_joints,
|
|
output.report.mirrored,
|
|
output.report.rig_height,
|
|
output.report.motion_scale,
|
|
);
|
|
assert!(max_time < 1.0e-6, "key-time diff {max_time}");
|
|
assert!(max_translation < 2.0e-5, "translation diff {max_translation}");
|
|
assert!(max_scale < 2.0e-5, "scale diff {max_scale}");
|
|
assert_eq!(
|
|
relaxed_rotation_nodes,
|
|
HashSet::from(CORRECTED_WRISTS_AND_ANKLES),
|
|
"only the corrected wrists and ankles may differ from the historical Blender retarget"
|
|
);
|
|
assert!(
|
|
max_strict_rotation < ROTATION_EPSILON,
|
|
"strict rotation diff {max_strict_rotation} at {worst_strict_rotation:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_walk_jump_clip_contract_and_in_place_root() {
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = fixture("native_mario_seed424242_oraclecontract_20k_rigged.glb");
|
|
if !rig_path.is_file() {
|
|
eprintln!("Mario rig fixture absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(rig_path).unwrap();
|
|
let clips = [
|
|
HyMotionClipRef { name: "idle", motion: &motion },
|
|
HyMotionClipRef { name: "walk", motion: &motion },
|
|
HyMotionClipRef { name: "jump", motion: &motion },
|
|
];
|
|
let output = retarget_hy_motion_glb_with_report(&rig, &clips, &RetargetOptions::default())
|
|
.unwrap();
|
|
assert_eq!(output.report.clips, 3);
|
|
assert_eq!(output.report.frames, motion.frames * 3);
|
|
|
|
let parsed = parse_glb_bytes(&output.glb).unwrap();
|
|
let animations = parsed.document.animations.as_ref().unwrap();
|
|
let names: Vec<_> = animations
|
|
.iter()
|
|
.map(|animation| animation.key("name").unwrap().string().unwrap().as_str())
|
|
.collect();
|
|
assert_eq!(names, ["idle", "walk", "jump"]);
|
|
|
|
// The fixed oracle rig's skeleton root is node 33. Horizontal movement
|
|
// belongs to the game controller in in-place mode; vertical crouch/jump
|
|
// displacement must remain in the clip.
|
|
for name in names {
|
|
let channels = animation_channels(&output.glb, name);
|
|
assert_eq!(channels.len(), 102);
|
|
let root = &channels[&(33, "translation".to_string())];
|
|
let first = &root.values[..3];
|
|
let mut min_y = first[1];
|
|
let mut max_y = first[1];
|
|
for value in root.values.chunks_exact(3) {
|
|
assert!((value[0] - first[0]).abs() < 1.0e-7);
|
|
assert!((value[2] - first[2]).abs() < 1.0e-7);
|
|
min_y = min_y.min(value[1]);
|
|
max_y = max_y.max(value[1]);
|
|
}
|
|
assert!(max_y - min_y > 0.005, "vertical root motion was stripped");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_ui_rig_has_real_wrist_directions() {
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../local/ai_content_library/lib-13.glb");
|
|
if !rig_path.is_file() {
|
|
eprintln!("fresh UI rig fixture absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(rig_path).unwrap();
|
|
let output = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef {
|
|
name: "idle",
|
|
motion: &motion,
|
|
}],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(output.report.joints, 34);
|
|
// 19 driven segments: the two terminal foot endpoints are direction
|
|
// markers inherited from their ankles, not duplicate driven segments.
|
|
assert_eq!(output.report.mapped_joints, 19);
|
|
|
|
// This fresh SkinTokens skeleton branches at each wrist. The old
|
|
// classifier stopped there and used an imported synthetic +Y tail,
|
|
// producing 143/149-degree first-frame wrist turns. Following the real
|
|
// distal hand branches keeps the same motion below a conservative 45°.
|
|
let channels = animation_channels(&output.glb, "idle");
|
|
let parsed = parse_glb_bytes(&output.glb).unwrap();
|
|
let nodes = parsed.document.nodes_slice();
|
|
let wrist_nodes: Vec<usize> = nodes
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, node)| {
|
|
matches!(node.name.as_deref(), Some("bone_9" | "bone_19")).then_some(index)
|
|
})
|
|
.collect();
|
|
assert_eq!(wrist_nodes.len(), 2);
|
|
let rest = [0.0, 0.0, 0.0, 1.0];
|
|
for wrist in wrist_nodes {
|
|
let first = &channels[&(wrist, "rotation".to_string())].values[..4];
|
|
let angle_degrees = quat_angle(first, &rest).to_degrees();
|
|
assert!(
|
|
angle_degrees < 45.0,
|
|
"fresh rig wrist node {wrist} turns {angle_degrees:.2}° at frame zero"
|
|
);
|
|
}
|
|
|
|
// The final nodes in both leg chains are foot-direction endpoints, not
|
|
// extra anatomical segments. They must retain their rest-local rotation
|
|
// while inheriting the driven ankle; mapping ankle -> foot onto them a
|
|
// second time caused 60-70 degree one-frame snaps in generated walks.
|
|
let foot_endpoints: Vec<usize> = nodes
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, node)| {
|
|
matches!(node.name.as_deref(), Some("bone_29" | "bone_33")).then_some(index)
|
|
})
|
|
.collect();
|
|
assert_eq!(foot_endpoints.len(), 2);
|
|
for endpoint in foot_endpoints {
|
|
let rotation = &channels[&(endpoint, "rotation".to_string())].values;
|
|
let first = &rotation[..4];
|
|
let max_delta = rotation
|
|
.chunks_exact(4)
|
|
.map(|frame| quat_angle(first, frame))
|
|
.fold(0.0f64, f64::max);
|
|
assert!(
|
|
max_delta < 1.0e-6,
|
|
"foot endpoint node {endpoint} was redundantly animated ({max_delta} radians)"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_yoshi_rig_with_raised_hip_heads_retargets_if_present() {
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../local/ai_content_library/lib-19.glb");
|
|
if !rig_path.is_file() {
|
|
eprintln!("fresh Yoshi rig fixture absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(&rig_path).unwrap();
|
|
let output = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef {
|
|
name: "idle",
|
|
motion: &motion,
|
|
}],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(output.report.joints, 22);
|
|
assert_eq!(output.report.mapped_joints, 17);
|
|
assert_eq!(output.report.clips, 1);
|
|
assert_eq!(output.report.frames, motion.frames);
|
|
|
|
let parsed = parse_glb_bytes(&output.glb).unwrap();
|
|
let animations = parsed.document.animations.as_ref().unwrap();
|
|
assert_eq!(animations.len(), 1);
|
|
assert!(matches!(
|
|
animations[0].key("name"),
|
|
Some(JsonValue::String(name)) if name == "idle"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_elf_rig_with_terminal_hand_leaves_retargets_if_present() {
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../local/ai_content_library/lib-34.glb");
|
|
if !rig_path.is_file() {
|
|
eprintln!("fresh elf rig fixture absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(&rig_path).unwrap();
|
|
let output = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef {
|
|
name: "idle",
|
|
motion: &motion,
|
|
}],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(output.report.joints, 27);
|
|
assert_eq!(output.report.mapped_joints, 18);
|
|
|
|
let parsed = parse_glb_bytes(&output.glb).unwrap();
|
|
let nodes = parsed.document.nodes_slice();
|
|
let channels = animation_channels(&output.glb, "idle");
|
|
for hand in [11usize, 15usize] {
|
|
assert!(nodes[hand].children.as_deref().unwrap_or(&[]).is_empty());
|
|
let rotation = &channels[&(hand, "rotation".to_string())].values;
|
|
let first = &rotation[..4];
|
|
let max_delta = rotation
|
|
.chunks_exact(4)
|
|
.map(|frame| quat_angle(first, frame))
|
|
.fold(0.0f64, f64::max);
|
|
assert!(
|
|
max_delta < 1.0e-6,
|
|
"terminal hand node {hand} was independently animated ({max_delta} radians)"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clean_elf_rig_with_low_hands_and_split_ankles_retargets_if_present() {
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../local/ai_content_library/lib-49.glb");
|
|
if !rig_path.is_file() {
|
|
eprintln!("clean elf rig fixture absent; skipping");
|
|
return;
|
|
}
|
|
let rig = std::fs::read(&rig_path).unwrap();
|
|
let output = retarget_hy_motion_glb_with_report(
|
|
&rig,
|
|
&[HyMotionClipRef {
|
|
name: "idle",
|
|
motion: &motion,
|
|
}],
|
|
&RetargetOptions::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(output.report.joints, 25);
|
|
assert_eq!(output.report.mapped_joints, 18);
|
|
|
|
let parsed = parse_glb_bytes(&output.glb).unwrap();
|
|
let animations = parsed.document.animations.as_ref().unwrap();
|
|
assert_eq!(animations.len(), 1);
|
|
assert!(matches!(
|
|
animations[0].key("name"),
|
|
Some(JsonValue::String(name)) if name == "idle"
|
|
));
|
|
|
|
let channels = animation_channels(&output.glb, "idle");
|
|
assert_eq!(channels.len(), 25 * 3);
|
|
for endpoint in [17usize, 19, 23, 25] {
|
|
let rotation = &channels[&(endpoint, "rotation".to_string())].values;
|
|
let first = &rotation[..4];
|
|
let max_delta = rotation
|
|
.chunks_exact(4)
|
|
.map(|frame| quat_angle(first, frame))
|
|
.fold(0.0f64, f64::max);
|
|
assert!(
|
|
max_delta < 1.0e-6,
|
|
"split-foot endpoint node {endpoint} was independently animated ({max_delta} radians)"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_fresh_ui_foot_frame_candidate_if_requested() {
|
|
let Some(path) = std::env::var_os("MAKEPAD_RETARGET_FRESH_OUTPUT") else {
|
|
eprintln!("MAKEPAD_RETARGET_FRESH_OUTPUT unset; skipping candidate export");
|
|
return;
|
|
};
|
|
let Some(motion) = oracle_motion() else {
|
|
eprintln!("HY retarget oracle fixtures absent; skipping");
|
|
return;
|
|
};
|
|
let rig_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../local/ai_content_library/lib-13.glb");
|
|
let rig = std::fs::read(&rig_path)
|
|
.unwrap_or_else(|error| panic!("cannot read {}: {error}", rig_path.display()));
|
|
let clips = [
|
|
HyMotionClipRef {
|
|
name: "idle",
|
|
motion: &motion,
|
|
},
|
|
HyMotionClipRef {
|
|
name: "walk",
|
|
motion: &motion,
|
|
},
|
|
HyMotionClipRef {
|
|
name: "jump",
|
|
motion: &motion,
|
|
},
|
|
];
|
|
let output = retarget_hy_motion_glb_with_report(&rig, &clips, &RetargetOptions::default())
|
|
.unwrap();
|
|
std::fs::write(&path, &output.glb)
|
|
.unwrap_or_else(|error| panic!("cannot write {}: {error}", Path::new(&path).display()));
|
|
}
|
|
|
|
fn oracle_frame(
|
|
channel: &ChannelData,
|
|
lanes: usize,
|
|
frame: usize,
|
|
total_frames: usize,
|
|
time: f32,
|
|
) -> Vec<f32> {
|
|
if channel.times.len() == total_frames {
|
|
channel.values[frame * lanes..frame * lanes + lanes].to_vec()
|
|
} else {
|
|
sample_channel(channel, lanes, time)
|
|
}
|
|
}
|