From a41699cecc1aef19c116cb8f1c2d9ec41f57f858 Mon Sep 17 00:00:00 2001 From: snkmcb <88454117+snkmcb@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:40:55 +0900 Subject: [PATCH 1/2] Add the reference pipeline: three sources, one downstream half (MIG-5) MIG-5's cross-repository test and boundary consolidation's BND-1. A .vrma (through usdVrmaFileFormat), a BVH export and a recorded session (as usd-motion-plugins' motion_convert and motion_record wrote them) are baked onto Seed-san.vrm through one motion_retarget argument list, and the test asserts the half after the clip is the same code: loaded plugins and modules differ by the first arrow's importer alone, the output layers share one shape and joint order, and every bake passes one set of motion checks (rotation away from rest, hips travel, unbindable bones named, nothing outside the humanoid moves, scales authored). workspace_reference_pipeline_local runs the same checks over data that cannot be committed (USDVRM_LOCAL_AVATAR, USDVRM_LOCAL_VRMA_DIR, optionally keeping bakes in USDVRM_LOCAL_OUTPUT_DIR) and is Skipped without it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 ++ CMakeLists.txt | 40 +++ docs/roadmap/boundary-consolidation.md | 19 +- docs/roadmap/current.md | 5 +- docs/roadmap/motion-foundation-split.md | 16 +- tests/motion/test_reference_pipeline.py | 351 ++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 tests/motion/test_reference_pipeline.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e8514ae..f412705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,36 @@ Current schema contract version: **1**. ### Added +- **The reference pipeline: three source categories, one downstream half** + — `workspace_reference_pipeline` + ([`tests/motion/test_reference_pipeline.py`](tests/motion/test_reference_pipeline.py)). + This is the motion migration's cross-repository test (MIG-5) and boundary + consolidation's BND-1. It bakes three clips onto `Seed-san.vrm`: + - a `.vrma`, through this repository's importer; + - a BVH export, as `usd-motion-plugins`' `motion_convert` wrote it; + - a recorded live session, as its `motion_record` wrote it. + + All three go through one `motion_retarget` argument list that differs only + in `--animation`. The test asserts that the downstream half is the same code, + in three ways: + - `--load-report`'s plugins and modules differ across sources by the first + arrow's plugin alone (`usdVrmaFileFormat` for a `.vrma`); + - the authored layers have one shape and one joint order; + - every bake passes the same checks with one tolerance. The bound bones + reproduce the clip's rotation away from rest and the hips its travel, + unbindable bones are named, nothing outside the humanoid moves, and scales + are authored. + + Four mutations each fail at the check meant to catch them: a per-source + flag on the root motion, a swapped arm mapping, a first arrow that is not + allowed, and a renamed animation prim. + + `workspace_reference_pipeline_local` runs the same checks over data that + cannot be committed. It reads `USDVRM_LOCAL_AVATAR` and + `USDVRM_LOCAL_VRMA_DIR`, can keep each bake in `USDVRM_LOCAL_OUTPUT_DIR`, + and reports Skipped without them. Measured on 2026-09-24 over + `AliciaSolid.vrm` (VRM 0.x) and the seven VRM Animation MotionPack clips, + with the export and the session beside them: all nine pass. - **A plain-CMake lane with no `ost` in it** — [`.github/workflows/plain-cmake.yml`](.github/workflows/plain-cmake.yml). On Linux it builds and tests the workspace from an OpenUSD 26.08 install diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d97316..a894f69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -248,6 +248,46 @@ if(USDVRM_BUILD_TESTS AND USDVRM_TEST_PYTHON endif() endif() +# The reference pipeline: a `.vrma`, a converted BVH export and a recorded +# session through one downstream invocation onto one VRM, asserted to be the +# same code rather than three plausible outputs (MIG-5's cross-repository test, +# boundary consolidation BND-1). Two of the three first arrows are +# usd-motion-plugins' tools, read as their committed output; the third is +# usdVrmaFileFormat, so this needs both importers and the tool in one build. +# +# `_local` is the same test over an avatar and `.vrma` files that cannot be +# committed, named by USDVRM_LOCAL_AVATAR and USDVRM_LOCAL_VRMA_DIR in the +# environment of the run; without them it reports Skipped. +if(USDVRM_BUILD_TESTS AND USDVRM_TEST_PYTHON + AND TARGET motion_retarget + AND TARGET UsdVrmFileFormat AND TARGET UsdVrmaFileFormat + AND EXISTS "${_real_avatar}") + set(_reference_plugins "${CMAKE_CURRENT_SOURCE_DIR}/plugins") + set(_reference_resources "${CMAKE_CURRENT_BINARY_DIR}/plugins") + set(_reference_common + "${CMAKE_CURRENT_SOURCE_DIR}/tests/motion/test_reference_pipeline.py" + --retarget "$" + --bvh-clip "${CMAKE_CURRENT_SOURCE_DIR}/tests/motion/fixtures/mocopi-mobile-arm-raise-turn.usda" + --trace-clip "${CMAKE_CURRENT_SOURCE_DIR}/tools/motionRetarget/tests/fixtures/recorded_session_clip.usda") + add_test(NAME workspace_reference_pipeline + COMMAND "${USDVRM_TEST_PYTHON}" ${_reference_common} + --avatar "${_real_avatar}" + --vrma "${_reference_plugins}/usdVrmaFileFormat/tests/fixtures/canonical_walk.vrma") + add_test(NAME workspace_reference_pipeline_local + COMMAND "${USDVRM_TEST_PYTHON}" ${_reference_common} --local) + set_tests_properties(workspace_reference_pipeline workspace_reference_pipeline_local + PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${_e2e_usd_root}/bin;PATH=path_list_prepend:${_e2e_usd_root}/lib;PATH=path_list_prepend:$;PATH=path_list_prepend:$;PATH=path_list_prepend:${_reference_plugins}/vrmSchema/lib;PATH=path_list_prepend:$;PYTHONPATH=path_list_prepend:${_e2e_usd_root}/lib/python;PYTHONPATH=path_list_prepend:${_e2e_usd_root}/lib/site-packages;PXR_PLUGINPATH_NAME=path_list_prepend:${_reference_resources}/usdVrmFileFormat/tests/resources/usdVrmFileFormat;PXR_PLUGINPATH_NAME=path_list_prepend:${_reference_resources}/usdVrmaFileFormat/tests/resources/usdVrmaFileFormat;PXR_PLUGINPATH_NAME=path_list_prepend:${_reference_plugins}/vrmSchema/plugin/resources/vrmSchema" + LABELS "motion.integration;motion.real-corpus") + set_tests_properties(workspace_reference_pipeline_local PROPERTIES + SKIP_RETURN_CODE 77) +else() + message(STATUS + "usd-vrm-plugins: a tool or a file-format bundle is not in this build " + "tree; skipping workspace_reference_pipeline") +endif() + # The OpenExec plan's P0-6: a `motion_retarget` bake against `execMotion` + # `execVrm` evaluating the same avatar and clip. Here for the same reason as the # two tests above -- it is a composition of both exec bundles and the retarget diff --git a/docs/roadmap/boundary-consolidation.md b/docs/roadmap/boundary-consolidation.md index f4c859b..2169f51 100644 --- a/docs/roadmap/boundary-consolidation.md +++ b/docs/roadmap/boundary-consolidation.md @@ -108,7 +108,7 @@ Generator: request/context -> MotionClip or a pose stream Done when a fifth producer can be added by naming which crossing it takes. -## 3. BND-1 — one reference pipeline, proved once for every category ⬜ +## 3. BND-1 — one reference pipeline, proved once for every category ✅ Every category reaches `UsdSkelAnimation` today, and each is proved by its own tests along its own path. There is no single test that says *the same thing* @@ -118,14 +118,27 @@ happens to all of them. source → canonical MotionClip → vrmRetarget → UsdSkelAnimation → validation ``` -- ⬜ One integration test, three sources: a `.vrma` clip, a BVH export, and a +- ✅ One integration test, three sources: a `.vrma` clip, a BVH export, and a recorded live trace — through the identical downstream call sequence, with the source-specific part confined to the first arrow. -- ⬜ The assertion is that the downstream half is **the same code**, not that +- ✅ The assertion is that the downstream half is **the same code**, not that three outputs are individually plausible. A source that needs a downstream branch has found a boundary defect, which is the point of running them together. +**Done on 2026-09-24, as `workspace_reference_pipeline`** +(`tests/motion/test_reference_pipeline.py`), which is also the motion +migration's MIG-5 cross-repository test. `vrmRetarget` in the diagram above is +now the consumed `motionRetarget` driven by `motion_retarget`. The BVH export +and the recorded trace arrive as the clips `usd-motion-plugins`' converter and +recorder wrote, because those first arrows are that repository's now. "The same +code" is measured three ways: + +- the plugins and modules each run loads differ by the first arrow's importer + alone; +- the authored layers share one shape and joint order; +- every bake passes one set of motion checks. + This is the test a fourth source is added *to*. NPZ/AMASS ships when it can join it without changing it. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 90aaa57..4ab18d6 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -209,8 +209,9 @@ Each step's record is in [the track](motion-foundation-split.md). - ⬜ The aggregate product installs and opens a `.vrm` and a `.vrma` from release artifacts, with the shared core resolved as a dependency. That is the next release's dry run. -- ⬜ The cross-repository test — VRMA → `MotionClip` → a target VRM — runs by - default somewhere, and here until an integration repository exists. +- ✅ The cross-repository test — VRMA → `MotionClip` → a target VRM — runs by + default: `workspace_reference_pipeline`, here until an integration + repository exists (2026-09-24). A new generic motion feature is proposed in `usd-motion-plugins`, and a new device or protocol input in `motion-connectors` diff --git a/docs/roadmap/motion-foundation-split.md b/docs/roadmap/motion-foundation-split.md index 6aed5fa..d314529 100644 --- a/docs/roadmap/motion-foundation-split.md +++ b/docs/roadmap/motion-foundation-split.md @@ -1,6 +1,6 @@ # Motion migration — generic motion to `usd-motion-plugins`, input to `motion-connectors` -**Status:** ✅ MIG-0..MIG-4 on this side — every identity [WORKSPACE.md §9.1](../architecture/WORKSPACE.md#91-destination-of-every-identity) sends elsewhere arrived there with its history (2026-09-19..21) and is consumed or deleted here (2026-09-21..24), `execMotion` last; what MIG-4 still owes is `motion-connectors`' (recorded evidence, ARDY); 🚧 MIG-5 — the mechanical check and the reduced contract are done, the release-artifact proof and the cross-repository test are not · **Target:** after the OpenExec foundation · +**Status:** ✅ MIG-0..MIG-4 on this side — every identity [WORKSPACE.md §9.1](../architecture/WORKSPACE.md#91-destination-of-every-identity) sends elsewhere arrived there with its history (2026-09-19..21) and is consumed or deleted here (2026-09-21..24), `execMotion` last; what MIG-4 still owes is `motion-connectors`' (recorded evidence, ARDY); 🚧 MIG-5 — the mechanical check, the reduced contract and the cross-repository test are done; the release-artifact proof is the next release's dry run · **Target:** after the OpenExec foundation · **Structure:** [architecture/WORKSPACE.md §9](../architecture/WORKSPACE.md#9-destinations-under-the-motion-architecture) · **Policy:** the `usd-motion-plugins` design policy §37, and [design/INTEGRATION_SCOPE_POLICY.md](../design/INTEGRATION_SCOPE_POLICY.md) §13 · @@ -572,10 +572,22 @@ repository's, and needs nothing from this one. rule kept. - ⬜ The aggregate product still installs and opens a `.vrm` and a `.vrma`, with the shared core resolved as a dependency, from release artifacts. -- ⬜ The cross-repository test — VRMA → `MotionClip` → a target VRM — runs +- ✅ The cross-repository test — VRMA → `MotionClip` → a target VRM — runs somewhere by default. The motion-plugins policy puts such tests in a runtime or integration repository (its §30.6); until one exists it stays here, because this repository is its natural integrator. + - It is `workspace_reference_pipeline` (2026-09-24), which is also boundary + consolidation's BND-1. It bakes three categories onto `Seed-san.vrm` + through one `motion_retarget` argument list: a `.vrma` through this + repository's importer, and a BVH export and a recorded session as + `motion_convert` and `motion_record` wrote them. + - It asserts that the half after the clip is one code path. The loaded + plugins and modules differ by the first arrow's importer alone, the + output layers share one shape, and each bake passes the same motion + checks. + - `workspace_reference_pipeline_local` repeats it over data that cannot be + committed, and is skipped without it. It passed over `AliciaSolid.vrm` + (VRM 0.x) and the seven VRM Animation MotionPack clips on 2026-09-24. ## 8. Open questions diff --git a/tests/motion/test_reference_pipeline.py b/tests/motion/test_reference_pipeline.py new file mode 100644 index 0000000..b7ebcdb --- /dev/null +++ b/tests/motion/test_reference_pipeline.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""One downstream half for every source category: the reference pipeline. + + .vrma clip -> usdVrmaFileFormat -+ + BVH export -> motion_convert -+-> MotionClip -> motion_retarget -> VRM + recorded session -> motion_record -+ + +Only the first arrow knows where a clip came from. Two of the three first +arrows are `usd-motion-plugins`' tools, whose committed output this reads +(`tests/motion/fixtures/`, `tools/motionRetarget/tests/fixtures/`); the third is +this repository's importer. Everything after the clip is the shared packages +and `motion_retarget`, and the claim here is that it is **the same code** for +all three, not that three outputs are each plausible (boundary consolidation +BND-1, which the motion migration made its MIG-5 cross-repository test). + +"The same code" is asserted three ways, none of which a per-source test can +make: + + * **One invocation.** Every source is baked by one argument list that differs + in `--animation` alone. A source that needed a flag has found a boundary + defect. + * **One set of loaded code.** `--load-report` names every plugin and module + the run loaded. Across sources they may differ by the first arrow's plugin + and nothing else -- `usdVrmaFileFormat` for a `.vrma`, nothing for a clip + already on a stage. A downstream branch that pulled in code of its own + would show up here as a module one source loads and another does not. + * **One output shape.** The authored layer -- every spec's path, kind and + type, the animation's joint order -- is identical across sources. The + values differ, because the motion does. + +Then each bake is held to the same checks, by the same function, with the same +tolerance: the bound bones reproduce the clip's motion away from rest, the ones +the avatar cannot represent are named, nothing outside the humanoid moves, and +the skeleton resolves the animation rather than its rest pose (#64). + +Two runs are registered. `workspace_reference_pipeline` runs by default, over +the committed inputs: `Seed-san.vrm`, a generated `.vrma`, the converted mocopi +export and the recorded session. `workspace_reference_pipeline_local` runs the +same checks over data that cannot be committed, named by two environment +variables, and is skipped without them: + + USDVRM_LOCAL_AVATAR a .vrm to bake onto + USDVRM_LOCAL_VRMA_DIR a directory whose *.vrma are the .vrma sources + USDVRM_LOCAL_OUTPUT_DIR optional: where to keep each bake and its load + report, to open in usdview afterwards + +The converted export and the recorded session join those, so the local run +still compares three categories. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import shutil +import sys +import tempfile + +from pxr import Sdf, Usd + +from rigcheck import (DISTANCE_TOLERANCE, ROTATION_TOLERANCE, Failures, Rig, + as_quatf, quat_distance, run_tool) +from test_real_avatar import bones_bound_by, check_binding_resolves + +# The plugin each first arrow may add to a run. Everything else a run loads has +# to be loaded by every other run too. +FIRST_ARROW = { + "vrma": frozenset({"UsdVrmaFileFormat"}), + "bvh": frozenset(), + "trace": frozenset(), +} + +# ctest's SKIP_RETURN_CODE for the local run without its data. +SKIPPED = 77 + + +class Source: + def __init__(self, kind: str, path: pathlib.Path) -> None: + self.kind = kind + self.path = path + self.name = f"{kind}:{path.name}" + + +def downstream(tool: str, avatar: pathlib.Path, source: Source, + output: pathlib.Path, report: pathlib.Path): + """The one invocation. Nothing in it may depend on `source.kind`.""" + return run_tool(tool, "--avatar", str(avatar), "--animation", str(source.path), + "--output", str(output), "--load-report", str(report)) + + +def layer_shape(layer: Sdf.Layer) -> set[tuple[str, str, str]]: + """Every spec's path, kind and type -- what was authored, not its value.""" + shape: set[tuple[str, str, str]] = set() + + def visit(path: Sdf.Path) -> None: + spec = layer.GetObjectAtPath(path) + if spec is None: + return + kind = type(spec).__name__ + if isinstance(spec, Sdf.PrimSpec): + detail = spec.typeName + elif isinstance(spec, Sdf.AttributeSpec): + detail = str(spec.typeName) + else: + detail = "" + shape.add((str(path), kind, detail)) + + layer.Traverse(Sdf.Path.absoluteRootPath, visit) + return shape + + +def normalized(path: str) -> str: + return os.path.normcase(os.path.normpath(path)) + + +def check_bake(failures: Failures, source: Source, output: pathlib.Path, + bound: dict[str, str], stderr: str) -> Rig | None: + """The same checks for every source category, and the same tolerance.""" + stage = Usd.Stage.Open(str(output)) + if not failures.check(stage is not None, f"{source.name}: the bake does not open"): + return None + target = Rig(stage) + clip = Rig(Usd.Stage.Open(str(source.path))) + if not check_binding_resolves(failures, target): + return None + + failures.check( + target.animation.GetScalesAttr().HasAuthoredValue(), + f"{source.name}: the bake authors no scales, so UsdSkel resolves every " + f"joint to its rest pose (#64)") + + silenced = frozenset({clip.leaf(token) for token in clip.joints} - set(bound)) + for bone in sorted(silenced): + failures.check( + bone in stderr, + f"{source.name}: the clip drives {bone}, the avatar binds no joint " + f"for it, and motion_retarget did not say so") + + times = target.times + sampled = sorted({times[0], times[len(times) // 3], + times[2 * len(times) // 3], times[-1]}) + compared = 0 + carried = 0 + for bone, token in sorted(bound.items()): + clip_token = clip.find_leaf(bone) + if clip_token is None: + continue + carried += 1 + if not failures.check( + token in target.slot, + f"{source.name}: the avatar binds {bone} to '{token}', which " + f"its own skeleton does not list"): + continue + compared += 1 + for time in sampled: + expected = clip.rest_relative(clip_token, time, silenced) + actual = target.rest_relative(token, time) + failures.check( + quat_distance(as_quatf(actual), as_quatf(expected)) + <= ROTATION_TOLERANCE, + f"{source.name}, time {time}: {bone} turns {actual} away from " + f"its rest on the avatar and {expected} in the clip") + failures.check( + compared and compared == carried, + f"{source.name}: {compared} of the {carried} bones the clip carries and " + f"the avatar binds were compared") + + # Root motion is a delta from each rig's own start (MOTION_CONTRACT.md, root + # and hips), so the hips travel what the clip's travel, whatever the two + # rigs' heights. + clip_hips = clip.find_leaf("hips") + if failures.check(clip_hips is not None and "hips" in bound, + f"{source.name}: no hips on one side of the bake"): + clip_start = clip.translation(clip_hips, clip.times[0]) + target_start = target.translation(bound["hips"], times[0]) + for time in sampled: + clip_delta = clip.translation(clip_hips, time) - clip_start + target_delta = target.translation(bound["hips"], time) - target_start + failures.check( + all(abs(a - b) <= DISTANCE_TOLERANCE + for a, b in zip(clip_delta, target_delta)), + f"{source.name}, time {time}: the avatar's hips moved " + f"{tuple(target_delta)} and the clip's {tuple(clip_delta)}") + + clip_moving = {bone for bone in bound + if (token := clip.find_leaf(bone)) and clip.moved(token)} + target_moving = {bone for bone, token in bound.items() if target.moved(token)} + failures.check( + bool(clip_moving), + f"{source.name}: no bound bone moves in the clip, so the bake is " + f"compared against a still pose") + failures.check( + clip_moving == target_moving, + f"{source.name}: the clip moves {sorted(clip_moving)} and the bake " + f"moves {sorted(target_moving)}") + + unbound = [token for token in target.joints if token not in set(bound.values())] + moved = sorted(token for token in unbound if target.moved(token)) + failures.check( + not moved, + f"{source.name}: the bake moves {len(moved)} joint(s) no human bone is " + f"bound to: {moved[:8]}") + return target + + +def check_same_code(failures: Failures, sources: list[Source], + reports: dict[str, dict]) -> None: + plugins = {s.name: frozenset(reports[s.name]["loaded_plugins"]) for s in sources} + modules = {s.name: frozenset(normalized(m) + for m in reports[s.name]["loaded_modules"]) + for s in sources} + common_plugins = frozenset.intersection(*plugins.values()) + common_modules = frozenset.intersection(*modules.values()) + for source in sources: + extra = plugins[source.name] - common_plugins + failures.check( + extra <= FIRST_ARROW[source.kind], + f"{source.name} loads {sorted(extra)} that another source does " + f"not, beyond its own first arrow {sorted(FIRST_ARROW[source.kind])}") + arrow_libraries = {normalized(reports[source.name]["loaded_plugins"][name]) + for name in extra} + stray = modules[source.name] - common_modules - arrow_libraries + failures.check( + not stray, + f"{source.name} loads module(s) no other source loads and no " + f"first-arrow plugin accounts for: {sorted(stray)}") + kinds = {source.kind for source in sources} + failures.check( + kinds == set(FIRST_ARROW), + f"only {sorted(kinds)} were compared; the claim is about " + f"{sorted(FIRST_ARROW)}") + + +def check_same_shape(failures: Failures, sources: list[Source], + outputs: dict[str, pathlib.Path], + targets: dict[str, Rig]) -> None: + reference = sources[0] + shape = layer_shape(Sdf.Layer.FindOrOpen(str(outputs[reference.name]))) + for source in sources[1:]: + other = layer_shape(Sdf.Layer.FindOrOpen(str(outputs[source.name]))) + failures.check( + other == shape, + f"{source.name} and {reference.name} bake layers of different " + f"shape: {sorted(other ^ shape)[:6]}") + if reference.name in targets and source.name in targets: + failures.check( + targets[source.name].anim_joints + == targets[reference.name].anim_joints, + f"{source.name} and {reference.name} animate different joints " + f"or the same joints in a different order") + + +def local_inputs() -> tuple[pathlib.Path, list[pathlib.Path]] | None: + avatar = os.environ.get("USDVRM_LOCAL_AVATAR") + vrma_dir = os.environ.get("USDVRM_LOCAL_VRMA_DIR") + if not avatar or not vrma_dir: + return None + return pathlib.Path(avatar), sorted(pathlib.Path(vrma_dir).glob("*.vrma")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--retarget", required=True, help="motion_retarget") + parser.add_argument("--avatar", type=pathlib.Path, + help="the target .vrm (not with --local)") + parser.add_argument("--vrma", type=pathlib.Path, action="append", default=[], + help="a .vrma source (not with --local)") + parser.add_argument("--bvh-clip", type=pathlib.Path, required=True, + help="motion_convert's output for a BVH export") + parser.add_argument("--trace-clip", type=pathlib.Path, required=True, + help="motion_record's output for a recorded session") + parser.add_argument("--local", action="store_true", + help="take the avatar and the .vrma sources from " + "USDVRM_LOCAL_AVATAR and USDVRM_LOCAL_VRMA_DIR") + arguments = parser.parse_args() + + if arguments.local: + local = local_inputs() + if local is None: + print("USDVRM_LOCAL_AVATAR / USDVRM_LOCAL_VRMA_DIR are not set; " + "skipping the local reference pipeline") + return SKIPPED + avatar, vrmas = local + else: + avatar, vrmas = arguments.avatar, arguments.vrma + + sources = ([Source("vrma", path) for path in vrmas] + + [Source("bvh", arguments.bvh_clip), + Source("trace", arguments.trace_clip)]) + missing = [p for p in [avatar, *(s.path for s in sources)] + if p is None or not p.exists()] + if missing or not vrmas: + print(f"missing input: {missing or 'no .vrma source'}", file=sys.stderr) + return 1 + + avatar_stage = Usd.Stage.Open(str(avatar)) + if avatar_stage is None: + print(f"{avatar} did not open. A .vrm needs usdVrmFileFormat on " + f"PXR_PLUGINPATH_NAME.", file=sys.stderr) + return 1 + failures = Failures() + bound = bones_bound_by(avatar_stage) + if not failures.check( + len(bound) >= 20, + f"{avatar.name} carries {len(bound)} vrm:humanBones bindings"): + return failures.report() + + keep = None + if arguments.local and os.environ.get("USDVRM_LOCAL_OUTPUT_DIR"): + keep = pathlib.Path(os.environ["USDVRM_LOCAL_OUTPUT_DIR"]) + keep.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="reference-pipeline-") as directory: + work = pathlib.Path(directory) + outputs: dict[str, pathlib.Path] = {} + reports: dict[str, dict] = {} + targets: dict[str, Rig] = {} + for index, source in enumerate(sources): + output = work / f"{index}-bake.usda" + report = work / f"{index}-load.json" + result = downstream(arguments.retarget, avatar, source, output, report) + if not failures.check( + result.returncode == 0, + f"{source.name}: motion_retarget failed with the argument " + f"list every other source takes: {result.stderr[-2000:]}"): + continue + outputs[source.name] = output + reports[source.name] = json.loads(report.read_text(encoding="utf-8")) + target = check_bake(failures, source, output, bound, result.stderr) + if target is not None: + targets[source.name] = target + print(f"{source.name}: baked {len(target.times) if target else 0} " + f"samples onto {avatar.name}") + if keep is not None: + stem = f"{avatar.stem}__{source.kind}__{source.path.stem}" + shutil.copyfile(output, keep / f"{stem}.usda") + shutil.copyfile(report, keep / f"{stem}.load.json") + + baked = [source for source in sources if source.name in outputs] + if len(baked) == len(sources): + check_same_code(failures, baked, reports) + check_same_shape(failures, baked, outputs, targets) + + return failures.report() + + +if __name__ == "__main__": + raise SystemExit(main()) From 9191fbb64d11a7f297d77470cb3591e69492ffde Mon Sep 17 00:00:00 2001 From: snkmcb <88454117+snkmcb@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:44:12 +0900 Subject: [PATCH 2/2] Bake a kept output in place rather than copying it there A bake references its avatar by a path relative to itself, so a copy out of the temporary directory pointed at a .vrm that is not there and opened in usdview with no model. Found opening the first kept bake. Co-Authored-By: Claude Opus 5.5 --- tests/motion/test_reference_pipeline.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/motion/test_reference_pipeline.py b/tests/motion/test_reference_pipeline.py index b7ebcdb..916e8b5 100644 --- a/tests/motion/test_reference_pipeline.py +++ b/tests/motion/test_reference_pipeline.py @@ -55,7 +55,6 @@ import json import os import pathlib -import shutil import sys import tempfile @@ -321,6 +320,12 @@ def main() -> int: for index, source in enumerate(sources): output = work / f"{index}-bake.usda" report = work / f"{index}-load.json" + if keep is not None: + # Baked in place rather than copied there: the bake references + # its avatar by a path relative to itself, which a copy breaks. + stem = f"{avatar.stem}__{source.kind}__{source.path.stem}" + output = keep / f"{stem}.usda" + report = keep / f"{stem}.load.json" result = downstream(arguments.retarget, avatar, source, output, report) if not failures.check( result.returncode == 0, @@ -334,10 +339,6 @@ def main() -> int: targets[source.name] = target print(f"{source.name}: baked {len(target.times) if target else 0} " f"samples onto {avatar.name}") - if keep is not None: - stem = f"{avatar.stem}__{source.kind}__{source.path.stem}" - shutil.copyfile(output, keep / f"{stem}.usda") - shutil.copyfile(report, keep / f"{stem}.load.json") baked = [source for source in sources if source.name in outputs] if len(baked) == len(sources):