basic advanced example

This commit is contained in:
Vysyr committed 2026-10-31 23:11:57 +01:00
1 parent 6fe57915e8
commit bd8edf5afb
29 files changed
+1501 -38

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
"""Offline, statically declared enums and timing metadata for editor use."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
from ue_workshop.animation_clip import AnimationClip
from ue_workshop.animations import FighterAnimations, CreatureAnimations
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL, _clips
class TimingClip(AnimationClip):
WALK = ("walk", 2.0)
FLOAT_DURATION = ("float_duration", 3.200000047683716)
FRACTIONAL = ("fractional", 2.1)
class CatalogTests(unittest.TestCase):
def test_catalog_covers_all_source_files_without_duplicate_keys(self):
for catalog, model in ((FighterAnimations, FIGHTER_MODEL), (CreatureAnimations, CREATURE_MODEL)):
with self.subTest(model=model):
keys = [clip.key for clip in catalog]
self.assertEqual(set(keys), set(_clips(model)))
self.assertEqual(len(keys), len(set(keys)))
self.assertTrue(all(clip.seconds > 0 for clip in catalog))
def test_metadata_is_available_without_loading_unreal(self):
self.assertNotIn("unreal", sys.modules)
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.key, "draw_a_great_sword_1")
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.seconds, 0.5)
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.frames(24), 12)
def test_frames_respect_fps_speed_and_fractional_end(self):
self.assertEqual(TimingClip.WALK.frames(24), 48)
self.assertEqual(TimingClip.WALK.frames(30, speed=2), 30)
self.assertEqual(TimingClip.FRACTIONAL.frames(24), 51)
self.assertEqual(TimingClip.FLOAT_DURATION.frames(30), 96)
def test_invalid_fps_and_speed_are_rejected(self):
for fps in (0, -1, True, 24.5):
with self.subTest(fps=fps), self.assertRaises(ValueError):
TimingClip.WALK.frames(fps)
for speed in (0, -1, True, float("nan"), float("inf")):
with self.subTest(speed=speed), self.assertRaises(ValueError):
TimingClip.WALK.frames(24, speed)
+77
View File
@@ -0,0 +1,77 @@
"""Automatic catalogs preserve other models, partial selections and offline imports."""
from pathlib import Path
import sys
import tempfile
from types import ModuleType
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
from ue_workshop import animation_catalog
from ue_workshop.animation_clip import AnimationClip
class AutomaticCatalogTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.output = Path(self.folder.name) / "animations.py"
def load(self):
namespace = {"__name__": "ue_workshop._catalog_test", "__package__": "ue_workshop"}
exec(compile(self.output.read_text(encoding="utf-8"), str(self.output), "exec"), namespace)
return namespace
def update(self, name, clips, **options):
return animation_catalog.update_catalog(name, clips, output=self.output, **options)
def test_new_model_produces_enum_with_durations_without_unreal(self):
self.assertEqual(self.update("StudentHero", {"walking": 2.0}), "StudentHeroAnimations")
clip = self.load()["StudentHeroAnimations"].WALKING
self.assertEqual(clip.key, "walking")
self.assertEqual(clip.seconds, 2)
self.assertEqual(clip.frames(24), 48)
self.assertNotIn("unreal", sys.modules)
def test_full_update_preserves_other_models_and_removes_deleted_clips(self):
self.update("Fighter", {"walk": 1.0, "attack": 2.0})
self.update("StudentHero", {"jump": 3.0})
self.update("Fighter", {"walk": 1.5})
namespace = self.load()
self.assertEqual([clip.key for clip in namespace["FighterAnimations"]], ["walk"])
self.assertEqual(namespace["StudentHeroAnimations"].JUMP.seconds, 3.0)
def test_partial_selection_updates_duration_without_erasing_other_clips(self):
self.update("Hero", {"walk": 1.0, "attack": 2.0})
self.update("Hero", {"walk": 1.5}, replace=False)
catalog = self.load()["HeroAnimations"]
self.assertEqual(catalog.WALK.seconds, 1.5)
self.assertEqual(catalog.ATTACK.seconds, 2.0)
def test_unchanged_metadata_does_not_rewrite_file(self):
self.update("Hero", {"walk": 1.0})
with patch.object(animation_catalog.tempfile, "NamedTemporaryFile", side_effect=AssertionError("rewritten")):
self.update("Hero", {"walk": 1.0})
def test_empty_model_is_valid(self):
self.update("Hero", {})
self.assertEqual(list(self.load()["HeroAnimations"]), [])
def test_invalid_metadata_does_not_damage_existing_catalog(self):
self.update("Hero", {"walk": 1.0})
before = self.output.read_bytes()
for data in ({"walk": 0}, {"walk": float("nan")}, {"walk": 1.0, "WALK": 2.0}):
with self.subTest(data=data), self.assertRaises(ValueError):
self.update("Hero", data)
self.assertEqual(self.output.read_bytes(), before)
def test_publish_adds_class_to_loaded_module_without_replacing_other_enums(self):
module = ModuleType("ue_workshop.animations")
module.AnimationClip = AnimationClip
module.ExistingAnimations = object()
original = module.ExistingAnimations
source = animation_catalog._class_source("StudentHeroAnimations", {"walk": 2.0})
with patch.dict(sys.modules, {"ue_workshop.animations": module}):
animation_catalog._publish_class(source, self.output)
self.assertEqual(module.StudentHeroAnimations.WALK.frames(24), 48)
self.assertIs(module.ExistingAnimations, original)
+7
View File
@@ -19,6 +19,9 @@ class Clip:
def get_name(self):
return "EmbeddedTake"
def get_play_length(self):
return 1.5
class DiscoveryTests(unittest.TestCase):
def setUp(self):
@@ -40,6 +43,7 @@ class DiscoveryTests(unittest.TestCase):
self.cache = patch.object(self.module, "_cached", return_value=[Mesh()]).start()
self.importer = patch.object(self.module, "_import", return_value=[Mesh()]).start()
self.animation = patch.object(self.module, "import_animation", side_effect=lambda *a, **kw: Clip()).start()
self.catalog = patch.object(self.module, "update_catalog").start()
self.addCleanup(patch.stopall)
def test_discovery_excludes_model_and_derives_names_without_aliases(self):
@@ -55,6 +59,8 @@ class DiscoveryTests(unittest.TestCase):
self.assertEqual(self.animation.call_count, 2)
for call in self.animation.call_args_list:
self.assertFalse(call.kwargs["reimport"])
self.catalog.assert_called_once_with("Hero", {
"great_sword_attack_2": 1.5, "great_sword_walk": 1.5}, replace=True)
def test_empty_dictionary_disables_discovery_and_keeps_embedded_name(self):
self.cache.return_value = [Mesh(), Clip()]
@@ -67,6 +73,7 @@ class DiscoveryTests(unittest.TestCase):
self.assertEqual(set(result.animations), {"selected"})
self.animation.assert_called_once()
self.assertEqual(self.animation.call_args.args[0], self.walk)
self.catalog.assert_called_once_with("Hero", {"selected": 1.5}, replace=False)
def test_reimport_true_reimports_model_and_discovered_clips(self):
self.module.import_model(self.model, name="Hero", reimport=True)
+58
View File
@@ -0,0 +1,58 @@
"""Validate light keys without an Unreal installation."""
import importlib.util
from pathlib import Path
import sys
import unittest
from unittest.mock import MagicMock, patch
class LightKeyTests(unittest.TestCase):
def setUp(self):
self.unreal = MagicMock()
self.unreal.FrameNumber.side_effect = lambda frame: frame
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py"
spec = importlib.util.spec_from_file_location("ue_workshop._light_key_test", path)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"unreal": self.unreal}):
spec.loader.exec_module(module)
self.shot = module.Sequence.__new__(module.Sequence)
self.shot.frames = 48
self.shot._light_tracks = {}
self.shot.bind = MagicMock()
self.track = self.shot.bind.return_value.add_track.return_value
self.channels = [MagicMock() for _ in range(4)]
self.track.add_section.return_value.get_all_channels.return_value = self.channels
self.actor = MagicMock()
self.light = self.actor.get_component_by_class.return_value
self.light.get_path_name.return_value = "Light"
def test_color_keys_rgb_and_leaves_alpha_at_default(self):
self.shot.light_key(self.actor, 10, color=(1, 0.5, 0))
self.shot.bind.assert_called_once_with(self.light)
self.track.set_property_name_and_path.assert_called_once_with("LightColor", "LightColor")
self.assertEqual(self.channels[1].add_key.call_args.args, (10, 0.5))
self.channels[3].set_default.assert_called_once_with(1.0)
self.channels[3].add_key.assert_not_called()
def test_later_keys_reuse_the_track(self):
self.shot.light_key(self.actor, 0, intensity=0)
self.shot.light_key(self.actor, 47, intensity=5000)
self.track.add_section.assert_called_once()
self.track.set_property_name_and_path.assert_called_once_with("Intensity", "Intensity")
self.assertEqual(self.channels[0].add_key.call_count, 2)
def test_color_and_intensity_use_separate_tracks(self):
self.shot.light_key(self.actor, 0, color=(1, 1, 1), intensity=100)
self.assertEqual(self.track.add_section.call_count, 2)
def test_invalid_arguments_create_no_track(self):
for args in [dict(frame=48, color=(1, 1, 1)), dict(frame=0, color=(1, 1)),
dict(frame=0, intensity=1, interpolation="smooth")]:
with self.assertRaises(ValueError):
self.shot.light_key(self.actor, **args)
self.shot.bind.assert_not_called()
def test_actor_without_light_is_rejected(self):
self.actor.get_component_by_class.return_value = None
with self.assertRaises(ValueError):
self.shot.light_key(self.actor, 0, intensity=1)
+46
View File
@@ -0,0 +1,46 @@
"""Horizontal root travel, interpolation and accumulated playback loops."""
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
class Vector:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
class RootMotionTests(unittest.TestCase):
def setUp(self):
unreal = MagicMock()
unreal.Vector = Vector
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/root_motion.py"
spec = importlib.util.spec_from_file_location("ue_workshop._root_motion_test", path)
self.module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"unreal": unreal}):
spec.loader.exec_module(self.module)
self.poses = [SimpleNamespace(translation=Vector(0, 0, 100)),
SimpleNamespace(translation=Vector(0, 50, 110)),
SimpleNamespace(translation=Vector(0, 100, 100))]
def test_horizontal_interpolation_keeps_vertical_motion_in_clip(self):
value = self.module.displacement(self.poses, 2.0, 0.5)
self.assertEqual((value.x, value.y, value.z), (0, 25, 0))
def test_end_and_repeated_playback_accumulate_instead_of_reset(self):
self.assertEqual(self.module.displacement(self.poses, 2.0, 2).y, 100)
self.assertEqual(self.module.displacement(self.poses, 2.0, 5).y, 250)
def test_before_start_has_no_displacement(self):
self.assertEqual(self.module.displacement(self.poses, 2.0, -1).y, 0)
def test_reverse_accumulates_backwards_without_initial_jump(self):
self.assertEqual(self.module.displacement(self.poses, 2.0, 0, reverse=True).y, 0)
self.assertEqual(self.module.displacement(self.poses, 2.0, 1, reverse=True).y, -50)
self.assertEqual(self.module.displacement(self.poses, 2.0, 5, reverse=True).y, -250)
def test_reverse_samples_the_end_of_a_nonuniform_trajectory(self):
self.poses[1].translation.y = 20
self.assertEqual(self.module.displacement(self.poses, 2.0, 0.5, reverse=True).y, -40)
+116 -2
View File
@@ -7,6 +7,11 @@ import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
from ue_workshop.animation_clip import AnimationClip
class KnownClip(AnimationClip):
WALK = ("walk", 2.0)
class Clip:
@@ -17,6 +22,12 @@ class Clip:
assert name == "skeleton"
return self.skeleton
def get_path_name(self):
return "OriginalClip"
def get_play_length(self):
return 2.0
class AnimationTests(unittest.TestCase):
def setUp(self):
@@ -35,6 +46,10 @@ class AnimationTests(unittest.TestCase):
self.shot.asset = MagicMock()
self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1)
self.shot._animations = {}
self.shot._transforms = {}
self.shot._root_motion = {}
self.shot._root_motion_copies = {}
self.shot._pose_holds = {}
self.shot.bind = MagicMock()
self.actor = MagicMock()
self.actor.get_path_name.return_value = "Parent"
@@ -44,7 +59,7 @@ class AnimationTests(unittest.TestCase):
self.clip = Clip(self.skeleton)
self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip})
self.imported.mesh.get_editor_property.return_value = self.skeleton
self.shot.scene = SimpleNamespace(_skeletal_models={"Parent": (self.child, self.imported)})
self.shot.scene = SimpleNamespace(name="TestScene", _skeletal_models={"Parent": (self.child, self.imported)})
def test_adjacent_clips_share_child_track_and_set_speed(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
@@ -55,6 +70,24 @@ class AnimationTests(unittest.TestCase):
track.add_section.return_value.set_range.assert_called_with(48, 120)
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8)
def test_enum_plays_once_when_end_is_omitted(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=10)
section.set_range.assert_called_with(10, 58)
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", self.clip)
def test_enum_automatic_end_respects_speed(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, speed=2)
section.set_range.assert_called_with(10, 34)
def test_explicit_end_overrides_enum_duration(self):
section = self.shot.animation(self.actor, KnownClip.WALK, start=0, end=120)
section.set_range.assert_called_with(0, 120)
def test_enum_cannot_exceed_sequence_length(self):
with self.assertRaises(ValueError):
self.shot.animation(self.actor, KnownClip.WALK, start=100)
self.shot.bind.assert_not_called()
def test_nested_clip_is_rejected_without_creating_section(self):
self.shot.animation(self.actor, "walk", start=0, end=48)
with self.assertRaises(ValueError):
@@ -64,11 +97,62 @@ class AnimationTests(unittest.TestCase):
def test_invalid_ranges_and_speeds_do_not_create_track(self):
for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48},
{"start": True}, {"speed": 0}, {"speed": float("nan")},
{"speed": float("inf")}):
{"speed": float("inf")}, {"keep_end_position": 1}, {"reverse": 1},
{"keep_end_pose": 1}):
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk", **options)
self.shot.bind.assert_not_called()
def test_reverse_is_set_on_native_animation_parameters(self):
section = self.shot.animation(self.actor, "walk", start=0, end=60, reverse=True)
section.get_editor_property.return_value.set_editor_property.assert_any_call("reverse", True)
def test_keep_end_pose_uses_native_keep_state(self):
main, hold = MagicMock(), MagicMock()
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold]
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, keep_end_pose=True)
section.set_completion_mode.assert_called_once_with(self.unreal.MovieSceneCompletionMode.KEEP_STATE)
section.set_range.assert_called_with(10, 58)
hold.set_range.assert_called_with(58, 120)
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.0)
def test_later_clip_truncates_frozen_pose(self):
main, hold, next_section = MagicMock(), MagicMock(), MagicMock()
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold, next_section]
self.shot.animation(self.actor, KnownClip.WALK, start=0, keep_end_pose=True)
self.shot.animation(self.actor, KnownClip.WALK, start=60)
hold.set_range.assert_called_with(48, 60)
def test_relative_move_replaces_baked_keys_and_offsets_future_motion(self):
axes = [MagicMock() for _ in range(3)]
future_keys = []
for axis in axes:
interior, future = MagicMock(), MagicMock()
interior.get_time.return_value.frame_number.value = 90
future.get_time.return_value.frame_number.value = 150
future.get_value.return_value = 100
axis.get_keys.return_value = [interior, future]
future_keys.append(future)
self.shot._transforms["Parent"] = axes
self.shot.position = MagicMock(side_effect=[(0, 300, 0), (0, 350, 0)])
self.shot.transform_key = MagicMock()
self.assertEqual(self.shot.move(self.actor, 80, 110, offset=(0, -200, 0)), (0, 100, 0))
for axis in axes:
axis.remove_key.assert_called_once_with(axis.get_keys.return_value[0])
future_keys[1].set_value.assert_called_once_with(-150)
def test_keep_end_position_uses_copy_and_adds_transform_motion(self):
copy = Clip(self.skeleton)
prepare = MagicMock(return_value=(copy, ["poses"], 2.0))
self.shot._keep_end_position = MagicMock()
with patch.dict(sys.modules, {"ue_workshop.root_motion": SimpleNamespace(prepare=prepare)}):
section = self.shot.animation(self.actor, "walk", start=0, end=48, keep_end_position=True)
self.shot.animation(self.actor, "walk", start=60, end=108, keep_end_position=True)
prepare.assert_called_once()
self.assertIs(self.imported.animations["walk"], self.clip)
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", copy)
self.assertEqual(self.shot._keep_end_position.call_count, 2)
def test_crossfade_sets_complementary_weight_curves(self):
track = self.shot.bind.return_value.add_track.return_value
first, second = MagicMock(), MagicMock()
@@ -143,3 +227,33 @@ class AnimationTests(unittest.TestCase):
with self.assertRaises(ValueError):
self.shot.animation(self.actor, "walk")
self.shot.bind.assert_not_called()
def test_position_uses_existing_sequencer_curves(self):
axes = [MagicMock(), MagicMock(), MagicMock()]
for channel, value in zip(axes, (10, 250, 3)):
channel.evaluate_keys.return_value = [value]
self.shot._transforms["Parent"] = axes
self.assertEqual(self.shot.position(self.actor, 80), (10, 250, 3))
self.shot.asset.make_range.assert_called_once_with(80, 1)
def test_position_without_track_uses_spawn_position(self):
self.actor.get_actor_location.return_value.to_tuple.return_value = (30, -850, 0)
self.assertEqual(self.shot.position(self.actor, 80), (30, -850, 0))
def test_relative_move_anchors_existing_position_and_adds_offset(self):
self.shot.position = MagicMock(return_value=(10, 250, 3))
self.shot.transform_key = MagicMock()
target = self.shot.move(self.actor, start=80, end=110, offset=(0, 50, 0))
self.assertEqual(target, (10, 300, 3))
self.shot.position.assert_called_once_with(self.actor, 80)
self.shot.transform_key.assert_any_call(self.actor, 80, location=(10, 250, 3), interpolation="auto")
self.shot.transform_key.assert_any_call(self.actor, 110, location=(10, 300, 3), interpolation="auto")
def test_invalid_relative_move_does_not_write_keys(self):
self.shot.transform_key = MagicMock()
for options in ({"start": 80, "end": 80}, {"start": 80, "end": 120},
{"start": 80, "end": 110, "offset": (0, float("nan"), 0)}):
options.setdefault("offset", (0, 50, 0))
with self.subTest(options=options), self.assertRaises(ValueError):
self.shot.move(self.actor, **options)
self.shot.transform_key.assert_not_called()