Files
PythonInUnreal/Content/Python/ue_workshop/sequence.py
T
2026-10-31 23:11:57 +01:00

346 lines
18 KiB
Python

"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
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:
def __init__(self, scene, name="Shot", fps=24, frames=48):
if not isinstance(fps, int) or fps < 1 or not isinstance(frames, int) or frames < 1:
raise ValueError("FPS i liczba klatek musza byc dodatnimi liczbami calkowitymi.")
self.scene, self.fps, self.frames = scene, fps, frames
path = f"{GENERATED}/{scene.name}/LS_{slug(name)}"
self.asset = unreal.load_asset(path)
if self.asset and unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence() == self.asset:
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
if self.asset is None:
self.asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"LS_" + name, f"{GENERATED}/{scene.name}", unreal.LevelSequence,
unreal.LevelSequenceFactoryNew())
for binding in self.asset.get_bindings():
binding.remove()
for track in self.asset.get_tracks():
self.asset.remove_track(track)
self.asset.set_display_rate(unreal.FrameRate(fps, 1))
self.asset.set_playback_start(0)
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()
if key not in self._bindings:
self._bindings[key] = self.asset.add_possessable(actor)
return self._bindings[key]
def camera(self, actor):
binding = self.bind(actor)
tracks = self.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)
track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack)
for old in track.get_sections():
track.remove_section(old)
section = track.add_section()
section.set_range(0, self.frames)
binding_id = unreal.MovieSceneObjectBindingID()
binding_id.set_editor_property("guid", binding.get_id())
section.set_camera_binding_id(binding_id)
def _animation_blend(self, section, start, end, fade_in, fade_out):
# The skeletal animation section exposes its Weight channel to Python.
# add_key uses display frames and converts them to the sequence tick rate.
weight = section.get_all_channels()[0]
for key in weight.get_keys():
weight.remove_key(key)
weight.set_default(1.0)
if fade_in or fade_out:
keys = {start: 0.0 if fade_in else 1.0}
if fade_in:
keys[start + fade_in] = 1.0
if fade_out:
keys[end - fade_out] = 1.0
keys[end] = 0.0
for frame, value in sorted(keys.items()):
weight.add_key(unreal.FrameNumber(frame), value,
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
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.
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().")
child, imported = entry
if clip is None:
if len(imported.animations) != 1:
raise ValueError("Wybierz animacje: " + ", ".join(imported.animations))
clip = next(iter(imported.animations.values()))
elif isinstance(clip, str):
if clip not in imported.animations:
raise ValueError("Nieznana animacja. Dostepne: " + ", ".join(imported.animations))
clip = imported.animations[clip]
if not isinstance(clip, unreal.AnimSequence):
raise TypeError("clip musi byc nazwa zaimportowanej animacji lub AnimSequence.")
if clip.get_editor_property("skeleton") != imported.mesh.get_editor_property("skeleton"):
raise ValueError("Animacja i model musza uzywac tego samego szkieletu.")
key = child.get_path_name()
sections = self._animations.setdefault(key, [])
overlaps = [old for old in sections if start < old[1] and old[0] < end]
previous = None
blend_frames = 0
if overlaps:
if (len(overlaps) != 1 or not overlaps[0][0] < start or
overlaps[0][1] >= end or
overlaps[0][0] + overlaps[0][4] > start):
raise ValueError("Dodawaj klipy w kolejnosci odtwarzania. Blend wymaga czesciowego "
"nakladania dwoch klipow, bez trzech jednoczesnych animacji.")
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}.")
mode = _key_mode(interpolation)
key = actor.get_path_name()
if key not in self._transforms:
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
initial = actor.get_actor_transform()
r = initial.rotation.rotator()
defaults = [*initial.translation.to_tuple(), r.roll, r.pitch, r.yaw,
*initial.scale3d.to_tuple()]
for channel, value in zip(channels, defaults):
channel.set_default(float(value))
self._transforms[key] = channels
channels = self._transforms[key]
for offset, values in [(0, location), (3, rotation), (6, scale)]:
if values is not None:
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=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()
unreal.EditorAssetLibrary.save_loaded_asset(self.asset)
if open_editor:
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(self.asset)