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

@@ -0,0 +1,89 @@
"""Maintain static editor enums as models and animations are imported."""
import ast
import importlib
import math
from pathlib import Path
import sys
import tempfile
from .paths import slug
CATALOG = Path(__file__).with_name("animations.py")
HEADER = '''"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
'''
def _read_classes(text):
classes = {}
for node in ast.parse(text).body:
if not isinstance(node, ast.ClassDef):
continue
data = {}
for statement in node.body:
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
key, seconds = ast.literal_eval(statement.value)
data[key] = float(seconds)
classes[node.name] = data
return classes
def _class_source(name, clips):
lines = [f'class {name}(AnimationClip):',
f' """Clip names and durations: type {name}. for editor completion."""']
members = set()
for key, seconds in sorted(clips.items()):
member = slug(key).upper()
if member in members:
raise ValueError(f"Powtorzona nazwa enuma: {name}.{member}")
members.add(member)
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"Nieprawidlowa dlugosc animacji {key}: {seconds}")
lines.extend([f' {member} = ({key!r}, {seconds!r})',
f' """{seconds:.3f} seconds at speed=1; clip: {key}."""'])
return '\n'.join(lines)
def _publish_class(source, filename):
# Refresh only this class, preserving other enum classes and existing imports.
# Names are validated and data are emitted with repr; source is generated here.
module = sys.modules.get(__package__ + ".animations")
if module is not None:
exec(compile(source, str(filename), "exec"), vars(module))
importlib.invalidate_caches()
def update_catalog(name, clips, *, replace=True, output=None):
"""Write NameAnimations. Partial selections merge; full discovery replaces it.
Other models are preserved. Identical metadata does not rewrite the file.
clips maps actual playback keys to durations in seconds, without Unreal objects.
"""
name = slug(name)
class_name = name[0].upper() + name[1:] + "Animations"
path = CATALOG if output is None else Path(output)
old_text = path.read_text(encoding="utf-8") if path.is_file() else ""
classes = _read_classes(old_text)
data = {} if replace else dict(classes.get(class_name, {}))
data.update({key: float(seconds) for key, seconds in clips.items()})
classes[class_name] = data
sources = {key: _class_source(key, value) for key, value in sorted(classes.items())}
text = HEADER + '\n\n' + '\n\n\n'.join(sources.values()) + '\n'
if text != old_text:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
dir=path.parent, prefix=path.name + ".", suffix=".tmp",
delete=False) as temporary:
temporary.write(text)
temporary_path = Path(temporary.name)
try:
temporary_path.replace(path)
finally:
temporary_path.unlink(missing_ok=True)
if output is None:
_publish_class(sources[class_name], path)
return class_name
@@ -0,0 +1,33 @@
"""Animation metadata usable in normal Python and VS Code without Unreal."""
from enum import Enum
import math
class AnimationClip(Enum):
"""A named clip with its exported duration. Subclasses list concrete clips."""
def __init__(self, key: str, seconds: float):
self._key = key
self._seconds = seconds
@property
def key(self) -> str:
"""Clip name used by shot.animation(), derived from the FBX filename."""
return self._key
@property
def seconds(self) -> float:
"""Duration of one playback at speed=1, exported from Unreal."""
return self._seconds
def frames(self, fps: int = 24, speed: float = 1.0) -> int:
"""Frames needed for one playback at this sequence FPS and speed."""
if type(fps) is not int or fps < 1:
raise ValueError("fps musi byc dodatnia liczba calkowita.")
if isinstance(speed, bool) or not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
raise ValueError("speed musi byc dodatnia liczba.")
value = self.seconds * fps / speed
# Unreal stores durations as float32; avoid adding a whole extra frame
# for values such as 3.200000047683716 seconds at 30 FPS.
nearest = round(value)
return max(1, nearest if math.isclose(value, nearest, rel_tol=0, abs_tol=0.0001) else math.ceil(value))
+140
View File
@@ -0,0 +1,140 @@
"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
class CreatureAnimations(AnimationClip):
"""Clip names and durations: type CreatureAnimations. for editor completion."""
LEFT_TURN_45 = ('left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: left_turn_45."""
MUTANT_BREATHING_IDLE = ('mutant_breathing_idle', 4.0333333015441895)
"""4.033 seconds at speed=1; clip: mutant_breathing_idle."""
MUTANT_DYING = ('mutant_dying', 4.599999904632568)
"""4.600 seconds at speed=1; clip: mutant_dying."""
MUTANT_FLEXING_MUSCLES = ('mutant_flexing_muscles', 4.666666507720947)
"""4.667 seconds at speed=1; clip: mutant_flexing_muscles."""
MUTANT_IDLE = ('mutant_idle', 5.300000190734863)
"""5.300 seconds at speed=1; clip: mutant_idle."""
MUTANT_JUMPING = ('mutant_jumping', 3.1666667461395264)
"""3.167 seconds at speed=1; clip: mutant_jumping."""
MUTANT_LEFT_TURN_45 = ('mutant_left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_left_turn_45."""
MUTANT_RIGHT_TURN_45 = ('mutant_right_turn_45', 1.9666666984558105)
"""1.967 seconds at speed=1; clip: mutant_right_turn_45."""
MUTANT_RIGHT_TURN_45_2 = ('mutant_right_turn_45_2', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_right_turn_45_2."""
MUTANT_RUN = ('mutant_run', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: mutant_run."""
MUTANT_SWIPING = ('mutant_swiping', 2.6666667461395264)
"""2.667 seconds at speed=1; clip: mutant_swiping."""
MUTANT_WALKING = ('mutant_walking', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: mutant_walking."""
class FighterAnimations(AnimationClip):
"""Clip names and durations: type FighterAnimations. for editor completion."""
DRAW_A_GREAT_SWORD_1 = ('draw_a_great_sword_1', 0.5)
"""0.500 seconds at speed=1; clip: draw_a_great_sword_1."""
DRAW_A_GREAT_SWORD_2 = ('draw_a_great_sword_2', 0.800000011920929)
"""0.800 seconds at speed=1; clip: draw_a_great_sword_2."""
GREAT_SWORD_180_TURN = ('great_sword_180_turn', 0.800000011920929)
"""0.800 seconds at speed=1; clip: great_sword_180_turn."""
GREAT_SWORD_180_TURN_2 = ('great_sword_180_turn_2', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_180_turn_2."""
GREAT_SWORD_ATTACK = ('great_sword_attack', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_attack."""
GREAT_SWORD_BLOCKING = ('great_sword_blocking', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking."""
GREAT_SWORD_BLOCKING_2 = ('great_sword_blocking_2', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_blocking_2."""
GREAT_SWORD_BLOCKING_3 = ('great_sword_blocking_3', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking_3."""
GREAT_SWORD_CASTING = ('great_sword_casting', 4.800000190734863)
"""4.800 seconds at speed=1; clip: great_sword_casting."""
GREAT_SWORD_CROUCHING = ('great_sword_crouching', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_crouching."""
GREAT_SWORD_CROUCHING_2 = ('great_sword_crouching_2', 0.699999988079071)
"""0.700 seconds at speed=1; clip: great_sword_crouching_2."""
GREAT_SWORD_CROUCHING_3 = ('great_sword_crouching_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_crouching_3."""
GREAT_SWORD_CROUCHING_4 = ('great_sword_crouching_4', 0.36666667461395264)
"""0.367 seconds at speed=1; clip: great_sword_crouching_4."""
GREAT_SWORD_CROUCHING_5 = ('great_sword_crouching_5', 1.399999976158142)
"""1.400 seconds at speed=1; clip: great_sword_crouching_5."""
GREAT_SWORD_CROUCHING_6 = ('great_sword_crouching_6', 0.46666666865348816)
"""0.467 seconds at speed=1; clip: great_sword_crouching_6."""
GREAT_SWORD_HIGH_SPIN_ATTACK = ('great_sword_high_spin_attack', 1.8666666746139526)
"""1.867 seconds at speed=1; clip: great_sword_high_spin_attack."""
GREAT_SWORD_IDLE = ('great_sword_idle', 2.0)
"""2.000 seconds at speed=1; clip: great_sword_idle."""
GREAT_SWORD_IDLE_2 = ('great_sword_idle_2', 3.700000047683716)
"""3.700 seconds at speed=1; clip: great_sword_idle_2."""
GREAT_SWORD_IDLE_3 = ('great_sword_idle_3', 3.6666667461395264)
"""3.667 seconds at speed=1; clip: great_sword_idle_3."""
GREAT_SWORD_IDLE_4 = ('great_sword_idle_4', 3.7666666507720947)
"""3.767 seconds at speed=1; clip: great_sword_idle_4."""
GREAT_SWORD_IDLE_5 = ('great_sword_idle_5', 7.566666603088379)
"""7.567 seconds at speed=1; clip: great_sword_idle_5."""
GREAT_SWORD_IMPACT = ('great_sword_impact', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: great_sword_impact."""
GREAT_SWORD_IMPACT_2 = ('great_sword_impact_2', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_impact_2."""
GREAT_SWORD_IMPACT_3 = ('great_sword_impact_3', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_impact_3."""
GREAT_SWORD_IMPACT_4 = ('great_sword_impact_4', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_impact_4."""
GREAT_SWORD_IMPACT_5 = ('great_sword_impact_5', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_impact_5."""
GREAT_SWORD_JUMP = ('great_sword_jump', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_jump."""
GREAT_SWORD_JUMP_2 = ('great_sword_jump_2', 0.8999999761581421)
"""0.900 seconds at speed=1; clip: great_sword_jump_2."""
GREAT_SWORD_JUMP_ATTACK = ('great_sword_jump_attack', 2.1666667461395264)
"""2.167 seconds at speed=1; clip: great_sword_jump_attack."""
GREAT_SWORD_KICK = ('great_sword_kick', 1.5)
"""1.500 seconds at speed=1; clip: great_sword_kick."""
GREAT_SWORD_KICK_2 = ('great_sword_kick_2', 1.7333333492279053)
"""1.733 seconds at speed=1; clip: great_sword_kick_2."""
GREAT_SWORD_POWER_UP = ('great_sword_power_up', 3.5)
"""3.500 seconds at speed=1; clip: great_sword_power_up."""
GREAT_SWORD_RUN = ('great_sword_run', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_run."""
GREAT_SWORD_RUN_2 = ('great_sword_run_2', 0.6000000238418579)
"""0.600 seconds at speed=1; clip: great_sword_run_2."""
GREAT_SWORD_SLASH = ('great_sword_slash', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_slash."""
GREAT_SWORD_SLASH_2 = ('great_sword_slash_2', 3.5333333015441895)
"""3.533 seconds at speed=1; clip: great_sword_slash_2."""
GREAT_SWORD_SLASH_3 = ('great_sword_slash_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_slash_3."""
GREAT_SWORD_SLASH_4 = ('great_sword_slash_4', 1.7999999523162842)
"""1.800 seconds at speed=1; clip: great_sword_slash_4."""
GREAT_SWORD_SLASH_5 = ('great_sword_slash_5', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: great_sword_slash_5."""
GREAT_SWORD_SLIDE_ATTACK = ('great_sword_slide_attack', 2.133333444595337)
"""2.133 seconds at speed=1; clip: great_sword_slide_attack."""
GREAT_SWORD_STRAFE = ('great_sword_strafe', 1.100000023841858)
"""1.100 seconds at speed=1; clip: great_sword_strafe."""
GREAT_SWORD_STRAFE_2 = ('great_sword_strafe_2', 1.1666666269302368)
"""1.167 seconds at speed=1; clip: great_sword_strafe_2."""
GREAT_SWORD_STRAFE_3 = ('great_sword_strafe_3', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_strafe_3."""
GREAT_SWORD_STRAFE_4 = ('great_sword_strafe_4', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_strafe_4."""
GREAT_SWORD_TURN = ('great_sword_turn', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_turn."""
GREAT_SWORD_TURN_2 = ('great_sword_turn_2', 0.8333333134651184)
"""0.833 seconds at speed=1; clip: great_sword_turn_2."""
GREAT_SWORD_WALK = ('great_sword_walk', 1.3666666746139526)
"""1.367 seconds at speed=1; clip: great_sword_walk."""
GREAT_SWORD_WALK_2 = ('great_sword_walk_2', 1.2999999523162842)
"""1.300 seconds at speed=1; clip: great_sword_walk_2."""
SPELL_CAST = ('spell_cast', 1.1333333253860474)
"""1.133 seconds at speed=1; clip: spell_cast."""
TWO_HANDED_SWORD_DEATH = ('two_handed_sword_death', 2.4000000953674316)
"""2.400 seconds at speed=1; clip: two_handed_sword_death."""
TWO_HANDED_SWORD_DEATH_2 = ('two_handed_sword_death_2', 2.5999999046325684)
"""2.600 seconds at speed=1; clip: two_handed_sword_death_2."""
+39 -5
View File
@@ -3,21 +3,55 @@ import unreal
from .camera import look_at
def _setup(actor, component_class, intensity, color):
light = actor.get_component_by_class(component_class)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light.set_light_color(unreal.LinearColor(*color, 1.0))
return light
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
intensity=12000, color=(1, 1, 1), size=180):
actor = scene.spawn(unreal.RectLight, name, location)
light = actor.get_component_by_class(unreal.RectLightComponent)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light = _setup(actor, unreal.RectLightComponent, intensity, color)
light.set_editor_property("source_width", float(size))
light.set_editor_property("source_height", float(size))
light.set_editor_property("attenuation_radius", 2000.0)
light.set_light_color(unreal.LinearColor(*color, 1.0))
look_at(actor, target)
return actor
def point(scene, name="Point", location=(0, 0, 250), intensity=10000, color=(1, 1, 1)):
"""Bulb shining in all directions."""
actor = scene.spawn(unreal.PointLight, name, location)
light = _setup(actor, unreal.PointLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
return actor
def spot(scene, name="Spot", location=(200, -200, 400), target=(0, 0, 100),
intensity=20000, color=(1, 1, 1), angle=30):
"""Cone of light; angle is measured in degrees from the cone axis."""
actor = scene.spawn(unreal.SpotLight, name, location)
light = _setup(actor, unreal.SpotLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
light.set_editor_property("outer_cone_angle", float(angle))
light.set_editor_property("inner_cone_angle", float(angle) * 0.7)
look_at(actor, target)
return actor
def sun(scene, name="Sun", location=(300, -300, 500), target=(0, 0, 0),
intensity=500, color=(1, 1, 1)):
"""Parallel light for the whole scene; only the location-target direction matters."""
actor = scene.spawn(unreal.DirectionalLight, name, location)
_setup(actor, unreal.DirectionalLightComponent, intensity, color)
look_at(actor, target)
return actor
def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)):
return [area(scene, "Key", (200, -250, 350), color=key_color),
return [area(scene, "Key", (200, -500, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
+58
View File
@@ -0,0 +1,58 @@
"""Transfer horizontal root motion to Sequencer actor keys without changing source clips."""
import math
import hashlib
import unreal
from .paths import GENERATED
def prepare(clip, child, scene_name):
component = child.skeletal_mesh_component
bone = component.get_bone_name(0)
count = int(clip.get_editor_property("number_of_sampled_keys"))
duration = clip.get_play_length()
if count < 2 or duration <= 0:
raise ValueError("keep_end_position wymaga animacji z co najmniej dwiema probkami.")
poses = [unreal.AnimationLibrary.get_bone_pose_for_time(
clip, bone, duration * index / (count - 1), False) for index in range(count)]
first = poses[0].translation
# Extract only horizontal travel. Keep vertical motion (jumps/bobbing),
# every root rotation and scale, and all other bone tracks unchanged.
positions = [unreal.Vector(first.x, first.y, pose.translation.z) for pose in poses]
folder = f"{GENERATED}/{scene_name}/RootMotion"
identity = hashlib.sha1(clip.get_path_name().encode("utf-8")).hexdigest()[:8]
name = clip.get_name() + "_" + identity + "_InPlace"
path = folder + "/" + name
if unreal.EditorAssetLibrary.does_asset_exist(path):
# This path contains only our generated derivative for this scene.
if not unreal.EditorAssetLibrary.delete_asset(path):
raise RuntimeError("Nie mozna odswiezyc wygenerowanego klipu " + path)
copy = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(name, folder, clip)
if copy is None:
raise RuntimeError("Nie mozna utworzyc kopii animacji dla keep_end_position.")
controller = copy.get_editor_property("controller")
if not controller.set_bone_track_keys(bone, positions, [pose.rotation for pose in poses],
[pose.scale3d for pose in poses], should_transact=False):
raise RuntimeError("Nie mozna przeniesc ruchu kosci glownej do Transform.")
unreal.EditorAssetLibrary.save_loaded_asset(copy)
return copy, poses, duration
def displacement(poses, duration, elapsed, reverse=False):
"""Interpolate horizontal root travel, accumulating complete playback loops."""
elapsed = max(0.0, elapsed)
loops = math.floor(elapsed / duration)
time = elapsed - loops * duration
if reverse:
time = duration - time
index = min(time / duration * (len(poses) - 1), len(poses) - 1)
lower = int(index)
upper = min(lower + 1, len(poses) - 1)
alpha = index - lower
a, b = poses[lower].translation, poses[upper].translation
start, end = poses[0].translation, poses[-1].translation
if reverse:
start, end = end, start
return unreal.Vector(a.x + (b.x - a.x) * alpha - start.x + loops * (end.x - start.x),
a.y + (b.y - a.y) * alpha - start.y + loops * (end.y - start.y), 0)
+2
View File
@@ -111,6 +111,8 @@ class Scene:
By default all animation FBXs beside the model are available by filename:
shot.animation(actor, "great_sword_walk"). Optional animations={...}
selects specific files; animations={} keeps only embedded animations.
Also writes NameAnimations with durations to ue_workshop.animations for
editor completion, including custom models (name="Ninja" -> NinjaAnimations).
"""
from .skeletal import import_model
if height is not None and (not isinstance(height, (int, float)) or
+194 -9
View File
@@ -4,6 +4,16 @@ import math
import unreal
from .paths import GENERATED, slug
from .animation_clip import AnimationClip
def _key_mode(interpolation):
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
return modes[interpolation]
class Sequence:
@@ -28,7 +38,11 @@ class Sequence:
self.asset.set_playback_end(frames)
self._bindings = {}
self._transforms = {}
self._light_tracks = {}
self._animations = {}
self._root_motion = {}
self._root_motion_copies = {}
self._pose_holds = {}
def bind(self, actor):
key = actor.get_path_name()
@@ -66,19 +80,41 @@ class Sequence:
weight.add_key(unreal.FrameNumber(frame), value,
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
def animation(self, actor, clip=None, start=0, end=None, speed=1.0):
def animation(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None,
start=0, end=None, speed=1.0, keep_end_position=False, reverse=False,
keep_end_pose=False):
"""Play/loop an imported clip over [start, end) in sequence frames.
clip is an alias from scene.skeletal_model(animations=...), an AnimSequence,
or None for the only embedded animation. Transform keys still move the parent.
Pass a FighterAnimations/CreatureAnimations member, a filename key, an
AnimSequence, or None for the only available animation. With an enum and
no end, play once using its stored duration, sequence FPS and speed.
Strings/AnimSequence/None keep looping to the sequence end if end is omitted.
An explicit end sets the section range and can repeat the clip.
Overlapping clips automatically crossfade over their entire shared range.
Add clips in timeline order; at most two clips can overlap at a time.
keep_end_position transfers horizontal root travel into actor Transform
keys, keeping the final position for subsequent clips. Defaults to False.
reverse=True plays the clip backwards, including transferred root travel.
keep_end_pose=True retains the final pose with a frozen section after
playback and Sequencer Keep State. A later clip ends this pose hold.
"""
if type(start) is not int:
raise ValueError("start musi byc numerem klatki (liczba calkowita).")
if isinstance(clip, AnimationClip):
if end is None:
end = start + clip.frames(self.fps, speed)
clip = clip.key
end = self.frames if end is None else end
if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames:
raise ValueError(f"Zakres animacji: 0 <= start < end <= {self.frames}.")
if not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
raise ValueError("Predkosc animacji musi byc dodatnia.")
if type(keep_end_position) is not bool:
raise ValueError("keep_end_position musi byc True lub False.")
if type(reverse) is not bool:
raise ValueError("reverse musi byc True lub False.")
if type(keep_end_pose) is not bool:
raise ValueError("keep_end_pose musi byc True lub False.")
entry = self.scene._skeletal_models.get(actor.get_path_name())
if entry is None:
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
@@ -109,29 +145,97 @@ class Sequence:
previous = overlaps[0]
blend_frames = previous[1] - start
track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack)
hold = self._pose_holds.pop(key, None)
if hold is not None:
hold_start, hold_section = hold
if start <= hold_start:
track.remove_section(hold_section)
else:
hold_section.set_range(hold_start, start)
if keep_end_position:
from .root_motion import prepare
source_key = clip.get_path_name()
if source_key not in self._root_motion_copies:
self._root_motion_copies[source_key] = prepare(
clip, child, self.scene.name + "/" + self.asset.get_name())
clip, poses, duration = self._root_motion_copies[source_key]
section = track.add_section()
section.set_range(start, end)
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
params.set_editor_property("reverse", reverse)
rate = unreal.MovieSceneTimeWarpVariant()
rate.set_fixed_play_rate(float(speed))
params.set_editor_property("play_rate", rate)
params.set_editor_property("force_custom_mode", True)
section.set_editor_property("params", params)
# UE may rescale the range when play_rate changes. Restore the requested
# display-frame bounds after applying parameters (including speed).
section.set_range(start, end)
if keep_end_pose:
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
self._animation_blend(section, start, end, blend_frames, 0)
if previous:
self._animation_blend(previous[3], previous[0], previous[1], previous[4], blend_frames)
sections.append((start, end, track, section, blend_frames))
if keep_end_position:
self._keep_end_position(actor, child, start, end, speed, poses, duration, reverse)
if keep_end_pose and end < self.frames:
length = clip.get_play_length()
final_time = min((end - start) / self.fps * speed, length)
if reverse:
final_time = length - final_time
self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
return section
def _hold_pose(self, track, clip, start, time):
section = track.add_section()
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
rate = unreal.MovieSceneTimeWarpVariant()
rate.set_fixed_play_rate(0.0)
params.set_editor_property("play_rate", rate)
ticks = self.asset.get_tick_resolution()
tick_rate = ticks.numerator / ticks.denominator
# Stay just inside the asset end to avoid modulo wrapping to frame zero.
time = max(0.0, min(time, clip.get_play_length() - 1 / tick_rate))
params.set_editor_property("first_loop_start_frame_offset", unreal.FrameNumber(round(time * tick_rate)))
params.set_editor_property("force_custom_mode", True)
section.set_editor_property("params", params)
section.set_range(start, self.frames)
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
self._animation_blend(section, start, self.frames, 0, 0)
return section
def _keep_end_position(self, actor, child, start, end, speed, poses, duration, reverse=False):
from .root_motion import displacement
key = actor.get_path_name()
if key not in self._root_motion:
self._root_motion[key] = {
"origin": actor.get_actor_location(),
"mesh_transform": child.get_actor_transform(),
"clips": [],
}
motion = self._root_motion[key]
motion["clips"].append((start, end, speed, poses, duration, reverse))
mesh = motion["mesh_transform"]
zero = mesh.transform_location(unreal.Vector(0, 0, 0))
# These are ordinary Transform keys. The final key holds the position
# after playback; subsequent animation calls do not reset it.
first = min(item[0] for item in motion["clips"])
last = min(self.frames - 1, max(item[1] for item in motion["clips"]))
for frame in range(first, last + 1):
total = unreal.Vector(0, 0, 0)
for clip_start, clip_end, clip_speed, clip_poses, clip_duration, clip_reverse in motion["clips"]:
elapsed = min(max(frame - clip_start, 0), clip_end - clip_start) / self.fps * clip_speed
total += displacement(clip_poses, clip_duration, elapsed, reverse=clip_reverse)
world_delta = mesh.transform_location(total) - zero
self.transform_key(actor, frame, location=(motion["origin"] + world_delta).to_tuple())
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
mode = _key_mode(interpolation)
key = actor.get_path_name()
if key not in self._transforms:
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
@@ -151,7 +255,88 @@ class Sequence:
if len(values) != 3:
raise ValueError("Transformacja wymaga trzech wartosci.")
for channel, value in zip(channels[offset:offset + 3], values):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"):
"""Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame."""
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
mode = _key_mode(interpolation)
if color is not None and len(color) != 3:
raise ValueError("Kolor wymaga trzech wartosci.")
light = actor.get_component_by_class(unreal.LightComponent)
if light is None:
raise ValueError("light_key wymaga aktora swiatla, np. z lighting.spot().")
if color is not None:
channels = self._light_channels(light, unreal.MovieSceneColorTrack, "LightColor")
for channel, value in zip(channels, color):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
if intensity is not None:
channels = self._light_channels(light, unreal.MovieSceneFloatTrack, "Intensity")
channels[0].add_key(unreal.FrameNumber(frame), float(intensity), interpolation=mode)
def _light_channels(self, light, track_class, name):
key = (light.get_path_name(), name)
if key not in self._light_tracks:
track = self.bind(light).add_track(track_class)
track.set_property_name_and_path(name, name)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
if name == "LightColor":
channels[3].set_default(1.0) # Alpha is never keyed.
self._light_tracks[key] = channels
return self._light_tracks[key]
def position(self, actor, frame: int) -> tuple[float, float, float]:
"""Actor Transform position at a sequence frame, including keep_end_position.
Evaluates Sequencer curves without moving the playhead. Does not include
motion of bones inside an animation that was not transferred to Transform.
"""
if type(frame) is not int or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
channels = self._transforms.get(actor.get_path_name())
if channels is None:
return actor.get_actor_location().to_tuple()
interval = self.asset.make_range(frame, 1)
rate = unreal.FrameRate(self.fps, 1)
values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]]
if any(not axis for axis in values):
raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.")
return tuple(float(axis[0]) for axis in values)
def move(self, actor, start: int, end: int, offset: tuple[float, float, float],
interpolation="auto") -> tuple[float, float, float]:
"""Move by offset (world cm) from the actor's position at start.
Replaces position keys in the range and shifts later position keys by
the resulting change, preserving later motion. Call after animation().
Returns the target position. Rotation/scale keys are kept.
"""
if (type(start) is not int or type(end) is not int or
not 0 <= start < end < self.frames):
raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.")
if interpolation not in {"linear", "auto", "constant"}:
raise ValueError("Interpolacja: linear, auto lub constant.")
if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.")
origin = self.position(actor, start)
target = tuple(value + delta for value, delta in zip(origin, offset))
channels = self._transforms.get(actor.get_path_name())
if channels is not None:
previous_end = self.position(actor, end)
for axis, channel in enumerate(channels[:3]):
correction = target[axis] - previous_end[axis]
for key in list(channel.get_keys()):
time = key.get_time().frame_number.value
if start <= time <= end:
channel.remove_key(key)
elif time > end:
key.set_value(key.get_value() + correction)
self.transform_key(actor, start, location=origin, interpolation=interpolation)
self.transform_key(actor, end, location=target, interpolation=interpolation)
return target
def save(self, open_editor=True):
self.scene.save()
+5 -1
View File
@@ -6,6 +6,7 @@ import unreal
from .paths import GENERATED, PROJECT, slug
from .characters import _clips
from .animation_catalog import update_catalog
@dataclass
@@ -111,7 +112,8 @@ def import_model(filename, name="Character", animations=None, collection="Charac
"""
source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if animations is None:
discover_all = animations is None
if discover_all:
animations = _clips(source)
name = slug(name)
destination = f"{GENERATED}/Skeletal/{name}"
@@ -131,4 +133,6 @@ def import_model(filename, name="Character", animations=None, collection="Charac
for alias, filename in (animations or {}).items():
clips[alias] = import_animation(filename, skeleton, name=slug(alias),
destination=destination + "/Animations", reimport=reimport)
update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()},
replace=discover_all)
return SkeletalImport(meshes[0], clips)
+27
View File
@@ -199,6 +199,33 @@ animację kości przez 120 klatek (5 s); krótszy klip zapętla się w swojej se
Klucze `location`, `rotation` i `scale` mogą dodatkowo poruszać całym modelem.
Przykład dwóch postaci z różnymi klipami: `examples/mixamo_characters.py`.
Do pisania scen w VS Code możesz używać enumów z gotowymi długościami — bez
szukania assetów ani uruchamiania komend w UE:
```python
from ue_workshop.animations import FighterAnimations
clip = FighterAnimations.GREAT_SWORD_ATTACK
clip.seconds # czas klipu w sekundach
clip.frames(shot.fps) # liczba klatek przy FPS sekwencji
shot.animation(character, clip, start=48) # bez end: jeden przebieg
```
Po wpisaniu `FighterAnimations.` edytor podpowiada nazwy. `CreatureAnimations`
udostępnia klipy stwora. [Dane offline, łączenie klipów i aktualizacja katalogu](docs/animation_catalog.md).
Własne modele również dostają katalog automatycznie: pierwszy import
`scene.skeletal_model(path, name="Ninja")` zapisze `NinjaAnimations` w
`ue_workshop.animations`. Kolejne klipy i długości są aktualizowane przy imporcie,
bez osobnego polecenia generowania. `REIMPORT=False` nadal wykorzystuje gotowe assety.
Gdy atak przesuwa postać, `shot.animation(character, clip, start=57,
keep_end_position=True)` automatycznie przenosi poziomy ruch do kluczy Transform
i zachowuje końcową pozycję na kolejne klipy. [Przykład slash_2](docs/animation_catalog.md).
Po śmierci `shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150,
keep_end_pose=True)` utrzymuje leżącą pozę do końca sekwencji lub następnej animacji.
```python
character = scene.skeletal_model(
PROJECT / "source_assets/character/creature/Maw J Laygo.fbx",
+181
View File
@@ -0,0 +1,181 @@
# Nazwy i długości animacji w VS Code
W `ue_workshop.animations` są statycznie zapisane enumy `FighterAnimations` i
`CreatureAnimations`. Działają w zwykłym Pythonie, bez `unreal`. Po wpisaniu kropki
VS Code może podpowiedzieć wszystkie klipy. Opisy członków podają długość w sekundach.
Nazwy odpowiadają nazwom plików; nie ma skrótów typu `attack`.
```python
from ue_workshop.animations import FighterAnimations, CreatureAnimations
draw = FighterAnimations.DRAW_A_GREAT_SWORD_1
attack = FighterAnimations.GREAT_SWORD_ATTACK
draw.key # "draw_a_great_sword_1"
draw.seconds # 0.5 sekundy
draw.frames(24) # 12 klatek przy 24 FPS
attack.seconds # około 1.2 sekundy
attack.frames(24) # 29 klatek przy 24 FPS, zaokrąglone w górę
attack.frames(24, speed=2)
# Bez end klip wybrany enumem gra raz, z automatycznie policzonym końcem.
shot.animation(character_fighter, draw, start=0)
next_start = draw.frames(shot.fps) - 6
shot.animation(character_fighter, attack, start=next_start)
```
W tym przykładzie przejście trwa 6 wspólnych klatek. Przy zmianie klipu albo FPS
nie trzeba pamiętać jego długości. `speed` jest uwzględniane zarówno przez `frames`,
jak i przez automatyczny koniec w `shot.animation`.
Podanie `end` nadal ustawia własny zakres i pozwala zapętlić klip:
```python
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=0, end=120)
```
Dotychczasowe nazwy tekstowe nadal działają. Dla tekstu lub bez wskazania klipu
pominięte `end` zachowuje dotychczasowe znaczenie: gra do końca sekwencji.
Koniec sekcji musi mieścić się w `Sequence(..., frames=...)`.
## Zachowanie pozycji po ataku
Jeśli klip przesuwa postać do przodu, włącz opcję na tym klipie:
```python
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_2,
start=57, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_3, start=161)
```
`animation()` przenosi poziomy ruch kości głównej do zwykłych kluczy pozycji
aktora na ścieżce Transform. Ostatni klucz zachowuje osiągnięte przesunięcie,
więc następny atak zaczyna się w nowym miejscu. Klucze możesz edytować w Sequencerze.
Ruch pionowy i obroty kości pozostają w animacji.
### Pozostawienie postaci na ziemi po śmierci
```python
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING,
start=150, keep_end_pose=True)
```
Enum bez `end` odtwarza klip raz. `keep_end_pose=True` dodaje po nim odcinek
z zamrożoną końcową pozą aż do końca sekwencji. Postać pozostaje na ziemi także
przy renderowaniu pojedynczej późniejszej klatki. Kolejna animacja tej samej
postaci kończy utrzymywanie pozy. Oryginalny klip nie jest zmieniany.
`keep_end_pose` dotyczy ułożenia kości; `keep_end_position` dotyczy przesunięcia
aktora. Można włączyć obie opcje. Jeśli jawnie wydłużysz zakres przez `end`,
klip nadal może się zapętlać w tym zakresie — dla śmierci pozostaw pojedynczy przebieg.
Aby uniknąć podwójnego przesuwania, sekcja korzysta z wygenerowanej kopii klipu
bez jego poziomego przesunięcia. Oryginalny asset animacji pozostaje bez zmian.
Opcja domyślnie jest wyłączona. Jeśli chcesz własne klucze pozycji, dodaj je po
wywołaniach `animation()` — zastąpią wygenerowane wartości na tych samych klatkach.
### Ruch względem aktualnej pozycji
Po `keep_end_position` nie musisz obliczać nowych współrzędnych ręcznie:
```python
# Po wywołaniach shot.animation(...): przesunięcie o 50 cm w osi Y.
shot.move(character_creature, start=80, end=120, offset=(0, 50, 0))
```
Helper odczyta pozycję aktora w klatce 80 z krzywych Sequencera, doda klucz
początkowy, a w klatce 120 klucz przesunięty o `offset`. To współrzędne świata
w centymetrach. Ostatnia klatka ruchu jest kluczem, więc musi być mniejsza od
`shot.frames`. Dodawaj ruch po animacjach. `move()` zastępuje klucze pozycji wewnątrz
odcinka, także wygenerowane przez `keep_end_position`. Późniejsze klucze pozycji są
przesuwane o wynikową różnicę, żeby zachować dalszy ruch. Klucze rotacji i skali zostają.
### Cofanie podczas chodzenia
`reverse=True` odtwarza klip od końca. W połączeniu z `keep_end_position=True`
odwraca także przesunięcie zapisane w klipie i zachowuje nową pozycję:
```python
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING,
start=80, end=120, reverse=True, keep_end_position=True)
```
Postać odtwarza kroki do tyłu bez zmiany kierunku, w którym patrzy. Jeśli chcesz
ustalić odległość cofnięcia samodzielnie, dopisz po animacjach:
```python
shot.move(character_creature, start=80, end=120, offset=(0, -200, 0))
```
To ustawia cofnięcie o 200 cm w osi Y; `reverse` odpowiada za kierunek odtwarzania
ruchu nóg, a `move` zastępuje trasę pozycjami wybranymi przez autora sceny.
Do własnych obliczeń służy:
```python
x, y, z = shot.position(character_creature, frame=80)
```
Odczyt uwzględnia klucze Transform, `keep_end_position` i interpolację. Nie przestawia
kursora w Sequencerze. Dotyczy pozycji aktora; ruchy samych kości wewnątrz klipu
bez `keep_end_position` nie zmieniają pozycji jego aktora. Odczyt wykonuje się w UE
podczas uruchamiania skryptu, więc VS Code nie musi znać liczbowych współrzędnych.
Listę z danymi też można odczytać bez UE:
```python
for clip in FighterAnimations:
print(clip.key, clip.seconds, clip.frames(24))
```
## Własny model studenta — generowanie automatyczne
Każde `scene.skeletal_model(...)` automatycznie zapisuje nazwy i długości jego
zaimportowanych klipów do `Content/Python/ue_workshop/animations.py`. Dotyczy to
także modeli pobranych przez studentów, FBX z animacją w środku i `.blend`.
Umieść model i jego animacje FBX w osobnym folderze, np. `source_assets/ninja`.
Przy pierwszym uruchomieniu w UE:
```python
character = scene.skeletal_model(
PROJECT / "source_assets/ninja/model.fbx",
name="Ninja",
reimport=False,
)
```
Powstanie `NinjaAnimations` — nazwa enuma wynika z `name`. VS Code zobaczy nowy
statyczny kod i będzie mógł go podpowiadać. W kolejnej edycji skryptu możesz użyć:
```python
from ue_workshop.animations import NinjaAnimations
# Jeśli w folderze był walking.fbx:
clip = NinjaAnimations.WALKING
clip.seconds
clip.frames(shot.fps)
shot.animation(character, clip, start=0)
```
Pierwszy import w UE jest potrzebny do odczytania rzeczywistych długości.
Późniejsze pisanie sceny korzysta z zapisanego katalogu i nie wymaga komend w UE.
Różnym modelom nadaj różne `name` — to również nazwa ich assetów w projekcie.
Po dodaniu FBX do folderu uruchom swój skrypt ponownie: brakujący klip zostanie
zaimportowany i dopisany do enuma. Po zmianie istniejącego klipu użyj `REIMPORT=True`;
nowa długość zapisze się automatycznie. Nie trzeba uruchamiać osobnego generatora.
`REIMPORT=False` nadal używa istniejących assetów. Nie ma sprawdzania dat źródeł.
Katalogi innych modeli pozostają w pliku. Jawny wybór `animations={...}` aktualizuje
tylko wybrane wpisy, zachowując inne znane klipy tej postaci. Automatyczny odczyt
całego folderu odświeża pełną listę. Jeśli dane się nie zmieniły, plik nie jest
zapisywany ponownie.
Opcjonalna ręczna aktualizacja dołączonego fightera i creature:
```text
python tools/run_workshop.py tools/update_animation_catalog.py
```
To narzędzie również zachowuje katalogi własnych modeli studentów.
+32
View File
@@ -7,6 +7,33 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma
Własny skrypt: `scene.skeletal_model(..., reimport=True)` lub `scene.model(..., reimport=True)`.
Nie ma automatycznego wykrywania zmian źródeł.
Enumy i długości bez uruchamiania UE:
```python
from ue_workshop.animations import FighterAnimations
clip = FighterAnimations.GREAT_SWORD_ATTACK
clip.seconds
clip.frames(shot.fps, speed=1.0)
shot.animation(character, clip, start=48) # jeden przebieg; end obliczony z długości
```
Jawne `end` nadal pozwala zapętlić klip. `scene.skeletal_model(path, name="Ninja")`
automatycznie wygeneruje `NinjaAnimations` po pierwszym imporcie w UE, także dla
własnych plików studentów. [Katalog offline i jego odświeżanie](animation_catalog.md).
`shot.animation(character, clip, start=57, keep_end_position=True)` zapisuje poziomy
ruch z klipu jako klucze Transform; końcowy klucz utrzymuje pozycję po ataku.
Źródłowy klip pozostaje bez zmian. Opcja dotyczy ruchu kości głównej.
`shot.position(character, frame=80)` odczytuje pozycję aktora z krzywych sekwencji.
`shot.move(character, start=80, end=120, offset=(0, 50, 0))` dodaje klucze ruchu
o 50 cm względem pozycji w klatce 80, także po `keep_end_position`.
`move` zastępuje klucze pozycji w tym odcinku i odpowiednio przesuwa późniejsze.
`shot.animation(character, clip, reverse=True, keep_end_position=True, start=80, end=120)`
odtwarza kroki i przeniesiony ruch do tyłu. Odległość można nadpisać przez `shot.move`.
`shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150, keep_end_pose=True)`
odtwarza śmierć raz i zachowuje końcową pozę na dalszą część sekwencji.
`camera.create(..., fov=60)` ustawia poziomy kąt widzenia w stopniach: większy FOV
daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywowi
45 mm na sensorze o szerokości 36 mm.
@@ -24,6 +51,9 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo
| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` |
| Światła studyjne | `lights = lighting.studio(scene)` |
| Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` |
| Żarówka | `lamp = lighting.point(scene, location=(0, 0, 250), intensity=10000, color=(1, 0.5, 0.2))` |
| Reflektor | `spot = lighting.spot(scene, location=(200, -200, 400), target=(0, 0, 100), angle=30)` |
| Słońce (liczy się tylko kierunek) | `sun = lighting.sun(scene, location=(300, -300, 500), target=(0, 0, 0), intensity=500)` |
| Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6)` |
| Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` |
| Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` |
@@ -31,6 +61,8 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo
| Klucz pozycji | `shot.transform_key(actor, 0, location=(0, 0, 0))` |
| Klucz rotacji | `shot.transform_key(actor, 47, rotation=(0, 0, 360))` |
| Klucz skali | `shot.transform_key(actor, 24, scale=(1.2, 1.2, 1.2), interpolation="auto")` |
| Klucz koloru światła | `shot.light_key(spot, 0, color=(1, 0, 0))` |
| Klucz jasności światła | `shot.light_key(spot, 47, intensity=40000, interpolation="auto")` |
| Zapis + otwarcie Sequencera | `shot.save()` |
| Własny renderer | `renderer = Renderer(width=1920, height=1080, samples=8, warmup=32, mp4=True)` |
| Preset z własnymi wartościami | `renderer = Renderer.from_preset("final", samples=8)` |
+73
View File
@@ -100,3 +100,76 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA
nakładania; stykające się klipy nie dostają fade. Sprawdzone łańcuchy trzech
klipów oraz odrzucanie niejednoznacznych zakresów i dodawania w odwrotnej kolejności.
- Parametr `blend_in` usunięty z API i przykładów. Zakresy scen pozostają takie same.
## Katalog enumów i długości dostępnych offline
- 34 testy poza UE: statyczne enumy obejmują wszystkie pliki źródłowe, importują
się bez `unreal`, udostępniają sekundy i przeliczenie klatek z FPS i prędkością.
- Sprawdzone zaokrąglanie czasu float32 bez dodawania zbędnej klatki, automatyczny
koniec dla enuma, jawny koniec oraz odrzucenie sekcji dłuższej niż sekwencja.
- `tools/smoke_animation_catalog.py` w UE: porównane długości wszystkich 63 klipów
z rzeczywistym `get_play_length()`, odtworzone enumy, w tym `speed=2`, oraz render
klatki ze środka automatycznego przejścia. Katalog pochodzi z eksportu assetów UE.
- Po przypisaniu parametrów sekcji przywracany jest zakres podany w Pythonie,
ponieważ edytor może przeskalować go przy zmianie prędkości.
## Automatyczne katalogi własnych modeli
- 41 testów poza UE: generowanie nowej klasy, metadane bez `unreal`, zachowanie
innych modeli, aktualizacja podzbioru, usuniętych klipów i niezmienionych danych.
- Sprawdzone odrzucanie kolizji nazw i nieprawidłowych czasów bez naruszenia pliku
oraz dodanie nowej klasy do już załadowanego modułu.
- `tools/smoke_automatic_catalog.py` zaliczony w UE: pierwszy import pod nazwą
StudentCatalogSmoke, automatyczny zapis dwóch klipów, odczyt wygenerowanego enuma
i użycie go do animacji z automatycznym końcem. Katalog testowy zapisano w build.
- Zwykły hook odświeżył także pełne katalogi Fighter i Creature z istniejących assetów.
## keep_end_position
- 45 testów poza UE: dodatkowo pozioma interpolacja ruchu, sumowanie pętli,
włączenie przeniesienia do Transform i ponowne użycie jednej kopii klipu.
- `tools/smoke_keep_end_position.py` zaliczony w UE: slash_2 od klatki 57 przy
30 FPS, wejście slash_3 w klatce 161. Zapisane rzeczywiste klucze rodzica;
po klatce 163 aktor zachowuje około 354 cm przesunięcia dla modelu wysokości 200 cm.
- Sprawdzone ciągłe położenie bioder przy przejściu, brak podwojenia przesunięcia,
niezmieniona źródłowa animacja, ponowne wykonanie i render klatki po ataku.
- Przenoszony jest poziomy ruch pierwszej kości szkieletu (Hips w tym modelu).
Pionowe ruchy i obroty kości zostają w klipie; kopie są w wygenerowanym folderze RootMotion.
## Odczyt pozycji i ruch względny
- 49 testów poza UE: odczyt istniejących kanałów pozycji, pozycja startowa bez
ścieżki, dodanie względnego offsetu oraz walidacja bez tworzenia częściowych kluczy.
- `tools/smoke_relative_move.py` zaliczony w UE: creature wysokości 300 cm,
start `(0, -850, 0)`, MUTANT_RUN z keep_end_position, swiping i walking.
- Pozycja w klatce 80 odczytana z natywnych krzywych Sequencera. Ruch 80–120
zwiększył Y dokładnie o 50 cm, zachował punkt początkowy i pozycję po klatce 120.
Sprawdzono też interpolowaną pozycję w środku ruchu.
## Cofanie i zastępowanie trasy
- 53 testy poza UE: odwrócone odtwarzanie, cofanie ruchu po nierównomiernej krzywej,
sumowanie pętli do tyłu, usuwanie kluczy wewnątrz move i przesuwanie późniejszych.
- `tools/smoke_reverse_walk.py` zaliczony w UE: run → swiping → walking z reverse,
naturalny ruch wstecz oraz zastąpienie trasy cofnięciem o 200 cm.
- Sprawdzone pozycje początkowa, środkowa i końcowa, brak wewnętrznych kluczy
z poprzedniej trasy i zachowanie względnego ruchu późniejszego klucza.
## Zachowanie końcowej pozy
- 55 testów poza UE: natywne Keep State, zamrożony odcinek końcowej pozy oraz
skracanie tego odcinka, gdy pojawi się następny klip tej samej postaci.
- `tools/smoke_keep_end_pose.py`: śmierć mutanta od klatki 0 przy 30 FPS, render
klatki 299 po zakończeniu klipu. Wizualnie sprawdzono, że mutant leży na ziemi.
- Samo Keep State dawało w takim renderze pozę referencyjną; dodatkowy odcinek
z prędkością 0 i przesunięciem czasu do końca klipu utrzymuje właściwą pozę.
## Rodzaje świateł i klucze światła
- 60 testów poza UE: klucze koloru i jasności, ponowne użycie tracków oraz
odrzucanie błędnych argumentów bez tworzenia tracka.
- `tools/smoke_lights.py` w UE: point, spot i sun w jednej scenie, render klatek
0, 24 i 47. Wizualnie sprawdzono zmianę koloru reflektora z czerwonego na niebieski
oraz wzrost jasności reflektora i żarówki.
- Domyślne `intensity=500` słońca dobrano do stałej ekspozycji `camera.create`;
przy `intensity=1` słońce nie było widoczne na renderze.
+39 -18
View File
@@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules:
if _render_module is not None and _render_module.is_rendering():
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"):
_module = sys.modules.get("ue_workshop." + _name)
if _module is not None:
importlib.reload(_module)
@@ -19,32 +19,53 @@ if "ue_workshop" in sys.modules:
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
from ue_workshop.paths import PROJECT
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
# POSTACIE
from ue_workshop.animations import FighterAnimations, CreatureAnimations
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
scene = Scene("MyShot")
floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11))
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
#character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200, location=(30, -40, 0.16), rotation=(0,0,180))
character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0,-850,0))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
cam = camera.create(scene, location=(450, -550, 300), fov=40)
lights = [
lighting.area(scene, "Key", (200, -500, 350), color=(1.0, 0.75, 0.5)),
lighting.area(scene, "Fill", (100, 300, 250), intensity=7000, color=(0.3, 0.6, 1.0)),
lighting.area(scene, "Rim", (-200, 0, 300), intensity=15000)
]
cam = camera.create(scene, location=(200, -300, 200), target=(0, 0, 100), fov=90)
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
shot = Sequence(scene, fps=24, frames=120)
shot = Sequence(scene, fps=30, frames=260)
# Fighter
shot.animation(character_fighter, FighterAnimations.DRAW_A_GREAT_SWORD_2, start=0)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=22, end=55)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING, start=48)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING_2, start=61)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH, start=80)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_JUMP_ATTACK, start=117, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_STRAFE, start=180, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_RUN_2, start=207, end=259, keep_end_position=True)
shot.transform_key(character_fighter,58, location=(30, -40, 0.16), interpolation="auto")
shot.transform_key(character_fighter,64, location=(30, 20, 0.16), interpolation="auto")
shot.move(character_fighter, start=134, end=146, offset=(0, -170, 0), interpolation="auto")
# Creature
shot.animation(character_creature, CreatureAnimations.MUTANT_RUN, start=0, end=60, keep_end_position=True)
shot.animation(character_creature, CreatureAnimations.MUTANT_SWIPING, start=20)
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING, start=95, end=160, keep_end_position=True, reverse=True)
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING, start=130, end=259, keep_end_pose=True)
# Camera
shot.camera(cam)
r = cam.get_actor_rotation()
shot.transform_key(cam, 0, rotation=(r.roll, r.pitch, r.yaw))
shot.transform_key(cam, 10, location=(200, -300, 200), interpolation="auto")
shot.transform_key(cam, 75, location=(450, -550, 300), rotation=(0, -15, 150), interpolation="auto")
shot.animation(character_fighter, "draw_a_great_sword_1", start=0, end=60)
# shot.animation(character_fighter, "great_sword_idle", start=0, end=60)
# shot.animation(character_fighter, "great_sword_attack", start=48, end=120)
# shot.transform_key(character_fighter, 70, location=(0, 0, 0), interpolation="auto")
# shot.transform_key(character_fighter, 100, location=(0, 200, 0), interpolation="auto")
shot.save()
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
+2 -2
View File
@@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules:
if _render_module is not None and _render_module.is_rendering():
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"):
_module = sys.modules.get("ue_workshop." + _name)
if _module is not None:
importlib.reload(_module)
+1 -1
View File
@@ -13,7 +13,7 @@ def main():
raise RuntimeError("Ten plik uruchom z terminala zwyklym Pythonem, poza edytorem UE.")
# --render zapewnia film takze wtedy, gdy w my_shot.py jest RENDER=False.
# Argumenty launchera, np. --editor i --timeout, mozna dopisac w terminalu.
script = Path(__file__).with_name("my_shot.py")
script = Path(__file__).with_name("advanced_example.py")
return run_workshop([str(script), "--render", *sys.argv[1:]])
+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()
+34
View File
@@ -0,0 +1,34 @@
"""Validate offline catalog timings against UE and play enum clips with automatic ends."""
import math
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
from ue_workshop.animations import FighterAnimations, CreatureAnimations
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
from ue_workshop.skeletal import import_model
for model, name, catalog in ((FIGHTER_MODEL, "Fighter", FighterAnimations),
(CREATURE_MODEL, "Creature", CreatureAnimations)):
imported = import_model(model, name=name, reimport=False)
for clip in catalog:
assert math.isclose(clip.seconds, imported.animations[clip.key].get_play_length(),
rel_tol=0, abs_tol=0.000001), clip.key
scene = Scene("CatalogSmoke")
fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", rotation=(0, 0, 180))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene, location=(450, -550, 300), fov=45)
shot = Sequence(scene, fps=24, frames=120)
shot.camera(cam)
draw = FighterAnimations.DRAW_A_GREAT_SWORD_1
attack = FighterAnimations.GREAT_SWORD_ATTACK
first = shot.animation(fighter, draw, start=0)
second_start = draw.frames(shot.fps) - 6
second = shot.animation(fighter, attack, start=second_start)
fast = shot.animation(fighter, attack, start=60, speed=2)
assert first.get_end_frame() == draw.frames(shot.fps)
assert second.get_end_frame() == second_start + attack.frames(shot.fps)
assert fast.get_end_frame() == 60 + attack.frames(shot.fps, speed=2)
assert second.get_editor_property("params").animation.get_name() == attack.key
shot.save(open_editor=False)
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+32
View File
@@ -0,0 +1,32 @@
"""Import a model under a student name and verify its enum is generated automatically."""
from unittest.mock import patch
from ue_workshop import Scene, Sequence
from ue_workshop import animation_catalog, skeletal
from ue_workshop.characters import FIGHTER_MODEL, _clips
from ue_workshop.paths import PROJECT
scene = Scene("AutomaticCatalogSmoke")
clips = _clips(FIGHTER_MODEL)
output = PROJECT / "build/AutomaticCatalogSmoke/animations.py"
update = animation_catalog.update_catalog
with patch.object(skeletal, "update_catalog", side_effect=lambda name, data, **options:
update(name, data, output=output, **options)):
actor = scene.skeletal_model(
FIGHTER_MODEL, name="StudentCatalogSmoke", reimport=False,
animations={key: clips[key] for key in ("great_sword_idle", "great_sword_attack")})
namespace = {"__name__": "ue_workshop._generated_smoke", "__package__": "ue_workshop"}
exec(compile(output.read_text(encoding="utf-8"), str(output), "exec"), namespace)
catalog = namespace["StudentCatalogSmokeAnimations"]
assert len(catalog) == 2
attack = catalog.GREAT_SWORD_ATTACK
assert attack.seconds > 0
shot = Sequence(scene, frames=120)
section = shot.animation(actor, attack, start=0)
assert section.get_end_frame() == attack.frames(shot.fps)
shot.save(open_editor=False)
# Check the normal hook updates the real existing catalog and preserves models.
skeletal.import_model(FIGHTER_MODEL, name="Fighter", reimport=False)
skeletal.import_model(PROJECT / "source_assets/character/creature/Maw J Laygo.fbx", name="Creature", reimport=False)
+31
View File
@@ -0,0 +1,31 @@
"""Check native pose retention and render a frame after the dying clip."""
import unreal
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
from ue_workshop.animations import CreatureAnimations as C
from ue_workshop.characters import CREATURE_MODEL
scene = Scene("KeepPoseSmoke")
actor = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
child, imported = scene._skeletal_models[actor.get_path_name()]
component = child.skeletal_mesh_component
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene, location=(500, -650, 400), target=(0, 0, 80), fov=60)
shot = Sequence(scene, fps=30, frames=300)
shot.camera(cam)
section = shot.animation(actor, C.MUTANT_DYING, start=0, keep_end_pose=True)
assert section.get_completion_mode() == unreal.MovieSceneCompletionMode.KEEP_STATE
shot.save(open_editor=False)
clip = imported.animations[C.MUTANT_DYING.key]
changed = False
for bone in unreal.AnimationLibrary.get_animation_track_names(clip):
first = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, 0, False)
last = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, clip.get_play_length(), False)
if not first.rotation.equals(last.rotation, 0.001):
changed = True
break
assert changed, "Dying source has no pose change"
assert section.get_end_frame() < 299
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+51
View File
@@ -0,0 +1,51 @@
"""Verify moving slash root travel becomes persistent parent Transform keys."""
import json
import math
import unreal
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
from ue_workshop.animations import FighterAnimations as F
from ue_workshop.characters import FIGHTER_MODEL
from ue_workshop.paths import PROJECT
scene = Scene("KeepEndSmoke")
actor = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
child, imported = scene._skeletal_models[actor.get_path_name()]
original = imported.animations[F.GREAT_SWORD_SLASH_2.key]
bone = child.skeletal_mesh_component.get_bone_name(0)
before = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False)
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
lighting.studio(scene)
cam = camera.create(scene, location=(650, -650, 450), target=(0, 150, 100), fov=55)
shot = Sequence(scene, fps=30, frames=300)
shot.camera(cam)
section = shot.animation(actor, F.GREAT_SWORD_SLASH_2, start=57, keep_end_position=True)
shot.animation(actor, F.GREAT_SWORD_SLASH_3, start=161)
copy = section.get_editor_property("params").animation
assert copy != original
end = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, copy.get_play_length(), False)
start = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, 0, False)
assert abs(end.translation.y - start.translation.y) < 0.001
after = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False)
assert abs(after.translation.y - before.translation.y) < 0.001
channels = shot._transforms[actor.get_path_name()]
last = [channel.get_keys()[-1].get_value() for channel in channels[:3]]
assert math.hypot(last[0], last[1]) > 100, last
assert channels[0].get_keys()[-1].get_time().frame_number.value == section.get_end_frame()
shot.save(open_editor=False)
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(shot.asset)
positions = {}
hips_positions = {}
for frame in (57, 160, 161, 162, 163, 164, 190):
unreal.LevelSequenceEditorBlueprintLibrary.set_current_time(frame)
unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence()
positions[frame] = actor.get_actor_location().to_tuple()
hips_positions[frame] = child.skeletal_mesh_component.get_socket_location(bone).to_tuple()
assert math.dist(positions[163], positions[190]) < 0.01, positions
assert math.dist(positions[162], positions[163]) < 20, positions
assert math.dist(hips_positions[162], hips_positions[163]) < 30, hips_positions
assert abs((hips_positions[160][1] - hips_positions[57][1]) -
(positions[160][1] - positions[57][1])) < 1, hips_positions
(PROJECT / "Saved/Workshop/keep_end_position.json").write_text(json.dumps(positions, indent=2), encoding="utf-8")
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
+23
View File
@@ -0,0 +1,23 @@
"""Check every light type and render keyed color and intensity (frames 0 and 47)."""
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
scene = Scene("LightsSmoke")
floor = materials.color("LightsFloor", rgb=(0.5, 0.5, 0.5))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
scene.primitive("Sphere", name="Ball", location=(0, 0, 50), material=floor)
sun = lighting.sun(scene, color=(1, 0.9, 0.8))
point = lighting.point(scene, location=(-300, 200, 200), color=(0, 1, 0))
spot = lighting.spot(scene, location=(0, -300, 400), target=(0, 0, 0))
cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 50))
shot = Sequence(scene, fps=24, frames=48)
shot.camera(cam)
shot.light_key(spot, 0, color=(1, 0, 0), intensity=5000)
shot.light_key(spot, 47, color=(0, 0, 1), intensity=40000, interpolation="auto")
shot.light_key(point, 0, intensity=0)
shot.light_key(point, 47, intensity=20000)
shot.light_key(sun, 0, intensity=0)
shot.light_key(sun, 24, intensity=500)
shot.light_key(sun, 25, intensity=0, interpolation="constant")
shot.save(open_editor=False)
assert len(shot.asset.get_bindings()) >= 2
renderer = Renderer(width=640, height=360, samples=1, warmup=8, mp4=False)
+24
View File
@@ -0,0 +1,24 @@
"""Verify position queries and relative movement after kept Mixamo root travel."""
import math
from ue_workshop import Scene, Sequence
from ue_workshop.animations import CreatureAnimations as C
from ue_workshop.characters import CREATURE_MODEL
scene = Scene("RelativeMoveSmoke")
creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0))
shot = Sequence(scene, fps=30, frames=180)
shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True)
shot.animation(creature, C.MUTANT_SWIPING, start=20)
shot.animation(creature, C.MUTANT_WALKING, start=80, end=120)
before = shot.position(creature, 80)
assert math.dist(before, (0, -850, 0)) > 100, before
target = shot.move(creature, start=80, end=120, offset=(0, 50, 0), interpolation="auto")
assert math.dist(shot.position(creature, 80), before) < 0.001
assert math.dist(shot.position(creature, 120), target) < 0.001
assert abs(target[1] - before[1] - 50) < 0.001
assert math.dist(shot.position(creature, 179), target) < 0.001
middle = shot.position(creature, 100)
assert before[1] < middle[1] < target[1]
shot.save(open_editor=False)
print("Relative movement from", before, "to", target)
+30
View File
@@ -0,0 +1,30 @@
"""Reverse a walking clip and replace baked travel with an explicit retreat."""
import math
from ue_workshop import Scene, Sequence
from ue_workshop.animations import CreatureAnimations as C
from ue_workshop.characters import CREATURE_MODEL
scene = Scene("ReverseWalkingSmoke")
creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0))
shot = Sequence(scene, fps=30, frames=180)
shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True)
shot.animation(creature, C.MUTANT_SWIPING, start=20)
walk = shot.animation(creature, C.MUTANT_WALKING, start=80, end=120,
keep_end_position=True, reverse=True)
assert walk.get_editor_property("params").get_editor_property("reverse")
before = shot.position(creature, 80)
natural_end = shot.position(creature, 120)
assert natural_end[1] < before[1] - 10, (before, natural_end)
# A later authored key should retain its relative movement after replacement.
shot.transform_key(creature, 150, location=(natural_end[0], natural_end[1] + 30, natural_end[2]))
target = shot.move(creature, start=80, end=120, offset=(0, -200, 0))
assert math.dist(shot.position(creature, 80), before) < 0.001
assert math.dist(shot.position(creature, 120), target) < 0.001
assert abs(target[1] - before[1] + 200) < 0.001
middle = shot.position(creature, 100)
assert target[1] < middle[1] < before[1], middle
assert abs(shot.position(creature, 150)[1] - target[1] - 30) < 0.001
channels = shot._transforms[creature.get_path_name()]
assert all(not 80 < key.get_time().frame_number.value < 120 for key in channels[1].get_keys())
shot.save(open_editor=False)
+12
View File
@@ -0,0 +1,12 @@
"""Optional refresh of bundled enums; other models are preserved. Imports update them automatically."""
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
from ue_workshop.skeletal import import_model
counts = {}
for model, name in (
(FIGHTER_MODEL, "Fighter"),
(CREATURE_MODEL, "Creature"),
):
imported = import_model(model, name=name, reimport=False)
counts[name + "Animations"] = len(imported.animations)
print("Exported animation catalog:", counts)