"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw.""" import math import unreal from .paths import GENERATED, slug 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._animations = {} 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=None, start=0, end=None, speed=1.0): """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. Overlapping clips automatically crossfade over their entire shared range. Add clips in timeline order; at most two clips can overlap at a time. """ 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.") 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) section = track.add_section() section.set_range(start, end) params = section.get_editor_property("params") params.set_editor_property("animation", clip) 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) 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)) return section 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.") 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=modes[interpolation]) 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)