346 lines
18 KiB
Python
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)
|