From 1530b74d04f758cc3720534b51ccda8f92542e70 Mon Sep 17 00:00:00 2001 From: Vysyr Date: Sat, 31 Oct 2026 23:43:04 +0100 Subject: [PATCH] more features --- Content/Python/ue_workshop/lighting.py | 48 ++++- Content/Python/ue_workshop/materials.py | 36 +++- Content/Python/ue_workshop/render.py | 13 +- Content/Python/ue_workshop/scene.py | 16 +- Content/Python/ue_workshop/sequence.py | 119 +++++++++-- Content/Python/ue_workshop/video.py | 13 +- docs/cheatsheet.md | 22 +- docs/validation.md | 12 ++ tests/test_basics.py | 265 ++++++++++++++++++++++++ tools/smoke_basics.py | 58 ++++++ 10 files changed, 563 insertions(+), 39 deletions(-) create mode 100644 tests/test_basics.py create mode 100644 tools/smoke_basics.py diff --git a/Content/Python/ue_workshop/lighting.py b/Content/Python/ue_workshop/lighting.py index dab1f39..403501a 100644 --- a/Content/Python/ue_workshop/lighting.py +++ b/Content/Python/ue_workshop/lighting.py @@ -1,7 +1,14 @@ +import math +from types import SimpleNamespace + import unreal from .camera import look_at +# Calibrated in tools/smoke_basics.py against the fixed exposure from camera.create. +_CUBEMAP_AMBIENT = 30.0 +_FOG_LUMINANCE = 30.0 + def _setup(actor, component_class, intensity, color): light = actor.get_component_by_class(component_class) @@ -46,11 +53,50 @@ 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) + light = _setup(actor, unreal.DirectionalLightComponent, intensity, color) + light.set_atmosphere_sun_light(True) look_at(actor, target) return actor +def environment(scene, sky=True, sun_intensity=500, sun_angle=45, sun_yaw=-45, + ambient=1.0, fog=0.0, fog_color=(0.6, 0.7, 0.9)): + """Add a world around the scene: sky, sun, ambient light and height fog. + + sun_angle is the height above the horizon in degrees (low = sunset), sun_yaw + its compass direction. sky=False keeps a black background with ambient light. + The sky is lit only by the sun: for night use sky=False, sun_intensity=0, ambient=0.3. + Returns .sun (None when sun_intensity=0), usable with shot.light_key. + """ + for name, value in (("sun_intensity", sun_intensity), ("ambient", ambient), ("fog", fog)): + if value < 0: + raise ValueError(f"{name} nie moze byc ujemne.") + sun_actor = None + if sun_intensity > 0: + height, compass = math.radians(sun_angle), math.radians(sun_yaw) + direction = (math.cos(height) * math.cos(compass), math.cos(height) * math.sin(compass), math.sin(height)) + sun_actor = sun(scene, location=tuple(1000 * value for value in direction), intensity=sun_intensity) + sun_actor.get_component_by_class(unreal.DirectionalLightComponent).set_atmosphere_sun_light(sky) + # Also for sky=False: an unlit atmosphere keeps the background black, not white. + scene.spawn(unreal.SkyAtmosphere, "Sky") + ambient_actor = scene.spawn(unreal.SkyLight, "Ambient") + light = ambient_actor.light_component + light.set_mobility(unreal.ComponentMobility.MOVABLE) + if sky: + light.set_editor_property("real_time_capture", True) + light.set_intensity(float(ambient)) + else: + light.set_editor_property("source_type", unreal.SkyLightSourceType.SLS_SPECIFIED_CUBEMAP) + light.set_cubemap(unreal.load_asset("/Engine/MapTemplates/Sky/DaylightAmbientCubemap")) + light.set_intensity(float(ambient) * _CUBEMAP_AMBIENT) + if fog > 0: + component = scene.spawn(unreal.ExponentialHeightFog, "Fog").component + component.set_fog_density(float(fog)) + component.set_editor_property("fog_inscattering_luminance", unreal.LinearColor( + *(value * _FOG_LUMINANCE for value in fog_color), 1.0)) + return SimpleNamespace(sun=sun_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, -500, 350), color=key_color), area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color), diff --git a/Content/Python/ue_workshop/materials.py b/Content/Python/ue_workshop/materials.py index 0dc4054..f0f8002 100644 --- a/Content/Python/ue_workshop/materials.py +++ b/Content/Python/ue_workshop/materials.py @@ -3,7 +3,7 @@ import unreal from pathlib import Path -from .paths import GENERATED, slug +from .paths import GENERATED, PROJECT, slug def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0): @@ -16,6 +16,7 @@ def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0): raise TypeError(f"{path} nie jest materialem.") lib = unreal.MaterialEditingLibrary lib.delete_all_material_expressions(material) + material.set_editor_property("used_with_skeletal_mesh", True) base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -300, 0) base.set_editor_property("constant", unreal.LinearColor(*rgb, 1.0)) lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR) @@ -29,16 +30,27 @@ def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0): return material -def assign(actor, material, slot=0): - component = actor.get_component_by_class(unreal.StaticMeshComponent) - if component is None: - raise TypeError("Material mozna przypisac do aktora ze StaticMeshComponent.") - component.set_material(slot, material) +def assign(actor, material, slot=None): + """Set a material on an actor and all attached meshes; slot=None replaces every slot.""" + components = [component for part in [actor, *actor.get_attached_actors(True, True)] + for component in part.get_components_by_class(unreal.MeshComponent) + if component.get_num_materials() > 0] + if not components: + raise TypeError("Material mozna przypisac do aktora z siatka.") + for component in components: + for index in range(component.get_num_materials()) if slot is None else [slot]: + component.set_material(index, material) -def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, emissive=None): - """Create one atlas material from adjacent PBR textures (PNG/JPEG).""" +def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, emissive=None, tiling=1): + """Create one atlas material from adjacent PBR textures (PNG/JPEG). + + tiling repeats the textures: one number, or (u, v) for separate directions. + """ slug(name) + u_tiling, v_tiling = tiling if isinstance(tiling, (tuple, list)) else (tiling, tiling) + if u_tiling <= 0 or v_tiling <= 0: + raise ValueError("tiling musi byc dodatnie.") tools = unreal.AssetToolsHelpers.get_asset_tools() path = f"{GENERATED}/Materials/M_{name}" material = unreal.load_asset(path) @@ -47,6 +59,10 @@ def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, unreal.Material, unreal.MaterialFactoryNew()) lib = unreal.MaterialEditingLibrary lib.delete_all_material_expressions(material) + material.set_editor_property("used_with_skeletal_mesh", True) + coordinates = lib.create_material_expression(material, unreal.MaterialExpressionTextureCoordinate, -600, 0) + coordinates.set_editor_property("u_tiling", float(u_tiling)) + coordinates.set_editor_property("v_tiling", float(v_tiling)) entries = [("BaseColor", base_color, unreal.MaterialProperty.MP_BASE_COLOR, "RGB", "color"), ("Normal", normal, unreal.MaterialProperty.MP_NORMAL, "RGB", "normal"), ("Roughness", roughness, unreal.MaterialProperty.MP_ROUGHNESS, "R", "linear"), @@ -59,7 +75,8 @@ def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, node.set_editor_property("r", 0.6 if label == "Roughness" else 0.0) lib.connect_material_property(node, "", prop) continue - source = Path(filename).resolve() + source = Path(filename).expanduser() + source = (source if source.is_absolute() else PROJECT / source).resolve() if not source.is_file(): raise FileNotFoundError(source) task = unreal.AssetImportTask() @@ -85,6 +102,7 @@ def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, node.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL if role == "normal" else unreal.MaterialSamplerType.SAMPLERTYPE_MASKS if role == "linear" else unreal.MaterialSamplerType.SAMPLERTYPE_COLOR) + lib.connect_material_expressions(coordinates, "", node, "UVs") lib.connect_material_property(node, channel, prop) lib.recompile_material(material) unreal.EditorAssetLibrary.save_loaded_asset(material) diff --git a/Content/Python/ue_workshop/render.py b/Content/Python/ue_workshop/render.py index 6997bd8..a9fe7fb 100644 --- a/Content/Python/ue_workshop/render.py +++ b/Content/Python/ue_workshop/render.py @@ -26,6 +26,7 @@ class Renderer: warmup: int = 8 mp4: bool = True ffmpeg: str = None + audio: str = None def __post_init__(self): self._validate() @@ -35,6 +36,8 @@ class Renderer: value = getattr(self, name) if type(value) is not int or value < minimum: raise ValueError(f"{name} musi byc liczba calkowita >= {minimum}.") + if self.audio is not None and not self.mp4: + raise ValueError("audio wymaga mp4=True.") @classmethod def from_preset(cls, name="preview", **overrides): @@ -84,7 +87,7 @@ def _on_finished(executor, success): if state["mp4"]: report("encoding") result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"], - expected_frames=state["frames"])) + expected_frames=state["frames"], audio=state["audio"])) result["success"] = True unreal.log("[Workshop] Gotowe: " + str(state["output"])) except Exception as exc: @@ -130,6 +133,12 @@ def _start(sequence, renderer, single_frame=None): raise ValueError("Klatka poza zakresem sekwencji.") mp4 = renderer.mp4 and single_frame is None ffmpeg_path = executable("ffmpeg", renderer.ffmpeg) if mp4 else None + audio = None + if mp4 and renderer.audio is not None: + audio = Path(renderer.audio).expanduser() + audio = (audio if audio.is_absolute() else PROJECT / audio).resolve() + if not audio.is_file(): + raise FileNotFoundError(audio) subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) if subsystem.is_rendering(): raise RuntimeError("Movie Render Queue jest zajete.") @@ -171,7 +180,7 @@ def _start(sequence, renderer, single_frame=None): executor.on_executor_finished_delegate.add_callable_unique(_on_finished) _active = dict(executor=executor, queue=queue, output=output, errors=[], fps=sequence.fps, frames=1 if single_frame is not None else sequence.frames, - mp4=mp4, ffmpeg=ffmpeg_path, ticks=2, + mp4=mp4, ffmpeg=ffmpeg_path, audio=audio, ticks=2, settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps)) try: _write_status({"state": "rendering", "output": str(output)}) diff --git a/Content/Python/ue_workshop/scene.py b/Content/Python/ue_workshop/scene.py index 34ffd34..6dbc582 100644 --- a/Content/Python/ue_workshop/scene.py +++ b/Content/Python/ue_workshop/scene.py @@ -52,11 +52,11 @@ class Scene: actor.set_folder_path("Workshop") return actor - def mesh(self, asset, name="Model", location=(0, 0, 0), scale=(1, 1, 1), material=None): + def mesh(self, asset, name="Model", location=(0, 0, 0), scale=(1, 1, 1), material=None, rotation=(0, 0, 0)): mesh = unreal.load_asset(asset) if isinstance(asset, str) else asset if not isinstance(mesh, unreal.StaticMesh): raise TypeError(f"Oczekiwano StaticMesh: {asset}") - actor = self.spawn(unreal.StaticMeshActor, name, location) + actor = self.spawn(unreal.StaticMeshActor, name, location, rotation) actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) actor.static_mesh_component.set_static_mesh(mesh) actor.set_actor_scale3d(unreal.Vector(*scale)) @@ -69,7 +69,7 @@ class Scene: raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.") return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs) - def model(self, filename, name="Character", height=200, rotation=(0, 0, 0), reimport=False): + def model(self, filename, name="Character", height=200, rotation=(0, 0, 0), reimport=False, location=(0, 0, 0)): """Import a model, fit its height (cm), and give it a floor-centered pivot. Returns a parent actor for transform animation; imported materials stay @@ -102,6 +102,7 @@ class Scene: root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False) + root.set_actor_location(unreal.Vector(*location), False, False) return root def skeletal_model(self, filename, name="Character", height=200, rotation=(0, 0, 0), @@ -145,19 +146,20 @@ class Scene: self._skeletal_models[root.get_path_name()] = (child, imported) return root - def character(self, material=None, head_scale=1.0): - root = self.spawn(unreal.StaticMeshActor, "Character") + def character(self, material=None, head_scale=1.0, name="Character", location=(0, 0, 0)): + root = self.spawn(unreal.StaticMeshActor, name) root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) - for shape, name, position, scale in [ + for shape, part, position, scale in [ ("Cube", "Body", (0, 0, 90), (0.55, 0.8, 0.9)), ("Sphere", "Head", (0, 0, 175), (0.7 * head_scale,) * 3), ("Cube", "LeftLeg", (0, -23, 25), (0.35, 0.3, 0.5)), ("Cube", "RightLeg", (0, 23, 25), (0.35, 0.3, 0.5)), ("Sphere", "Nose", (34, 0, 177), (0.24, 0.2, 0.2)), ]: - actor = self.primitive(shape, name=name, location=position, scale=scale, material=material) + actor = self.primitive(shape, name=name + part, location=position, scale=scale, material=material) actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False) + root.set_actor_location(unreal.Vector(*location), False, False) return root def save(self): diff --git a/Content/Python/ue_workshop/sequence.py b/Content/Python/ue_workshop/sequence.py index fd6c764..3055453 100644 --- a/Content/Python/ue_workshop/sequence.py +++ b/Content/Python/ue_workshop/sequence.py @@ -43,6 +43,14 @@ class Sequence: self._root_motion = {} self._root_motion_copies = {} self._pose_holds = {} + self._camera_cuts = [] + self._visibility = {} + + def seconds(self, seconds) -> int: + """Convert seconds to the nearest frame number at this sequence's FPS.""" + if not isinstance(seconds, (int, float)) or not math.isfinite(seconds) or seconds < 0: + raise ValueError("Czas musi byc nieujemna liczba sekund.") + return round(seconds * self.fps) def bind(self, actor): key = actor.get_path_name() @@ -50,17 +58,32 @@ class Sequence: self._bindings[key] = self.asset.add_possessable(actor) return self._bindings[key] - def camera(self, actor): + def camera(self, actor, start=0, end=None): + """Use a camera over [start, end); a later call overrides earlier cameras in its range. + + shot.camera(cam_a) followed by shot.camera(cam_b, start=60) cuts at frame 60. + """ + 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 kamery: 0 <= start < end <= {self.frames}.") binding = self.bind(actor) + cuts = [(start, end, binding)] + for old_start, old_end, old_binding in self._camera_cuts: + if old_start < start: + cuts.append((old_start, min(old_end, start), old_binding)) + if old_end > end: + cuts.append((max(old_start, end), old_end, old_binding)) + self._camera_cuts = sorted(cuts, key=lambda cut: cut[0]) 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) + for cut_start, cut_end, cut_binding in self._camera_cuts: + section = track.add_section() + section.set_range(cut_start, cut_end) + binding_id = unreal.MovieSceneObjectBindingID() + binding_id.set_editor_property("guid", cut_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. @@ -188,6 +211,20 @@ class Sequence: self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time)) return section + def then(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None, overlap=0, **options): + """Play a clip right after the actor's latest clip; overlap frames crossfade them. + + Takes the same options as animation(), except start. + """ + if type(overlap) is not int or overlap < 0: + raise ValueError("overlap musi byc nieujemna liczba klatek.") + entry = self.scene._skeletal_models.get(actor.get_path_name()) + if entry is None: + raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().") + sections = self._animations.get(entry[0].get_path_name()) + previous_end = max(item[1] for item in sections) if sections else 0 + return self.animation(actor, clip, start=max(previous_end - overlap, 0), **options) + def _hold_pose(self, track, clip, start, time): section = track.add_section() params = section.get_editor_property("params") @@ -257,6 +294,32 @@ class Sequence: for channel, value in zip(channels[offset:offset + 3], values): channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode) + def visible(self, actor, frame, visible=True): + """Show or hide an actor and everything attached to it from a frame onward. + + The first call decides the earlier state: shot.visible(actor, 60) keeps + the actor hidden before frame 60, shot.visible(actor, 60, False) visible. + """ + if type(frame) is not int or not 0 <= frame < self.frames: + raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") + if type(visible) is not bool: + raise ValueError("visible musi byc True lub False.") + for part in [actor, *actor.get_attached_actors(True, True)]: + key = part.get_path_name() + if key not in self._visibility: + track = self.bind(part).add_track(unreal.MovieSceneVisibilityTrack) + track.set_property_name_and_path("bHidden", "bHidden") + section = track.add_section() + section.set_range(0, self.frames) + self._visibility[key] = section.get_all_channels()[0] + if frame > 0: + self._visibility[key].add_key(unreal.FrameNumber(0), not visible) + channel = self._visibility[key] + for old in list(channel.get_keys()): + if old.get_time().frame_number.value == frame: + channel.remove_key(old) + channel.add_key(unreal.FrameNumber(frame), visible) + 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: @@ -294,16 +357,27 @@ class Sequence: Evaluates Sequencer curves without moving the playhead. Does not include motion of bones inside an animation that was not transferred to Transform. """ + return self._read_transform(actor, frame, 0, lambda: actor.get_actor_location().to_tuple()) + + def rotation(self, actor, frame: int) -> tuple[float, float, float]: + """Actor (roll, pitch, yaw) at a sequence frame, evaluated like position().""" + def initial(): + rotation = actor.get_actor_rotation() + return (rotation.roll, rotation.pitch, rotation.yaw) + return self._read_transform(actor, frame, 3, initial) + + def _read_transform(self, actor, frame, first_channel, initial): 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() + return initial() interval = self.asset.make_range(frame, 1) rate = unreal.FrameRate(self.fps, 1) - values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]] + values = [channel.evaluate_keys(interval, rate) + for channel in channels[first_channel:first_channel + 3]] if any(not axis for axis in values): - raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.") + raise RuntimeError("Nie mozna odczytac 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], @@ -314,19 +388,32 @@ class Sequence: the resulting change, preserving later motion. Call after animation(). Returns the target position. Rotation/scale keys are kept. """ + return self._relative_keys(actor, start, end, offset, interpolation, "location", 0, self.position) + + def rotate(self, actor, start: int, end: int, offset: tuple[float, float, float], + interpolation="auto") -> tuple[float, float, float]: + """Rotate by offset (roll, pitch, yaw degrees) from the actor's rotation at start. + + Works like move() for rotation keys; offset yaw=360 is one full turn. + keep_end_position still follows the direction from before the rotation. + Returns the target rotation. Position/scale keys are kept. + """ + return self._relative_keys(actor, start, end, offset, interpolation, "rotation", 3, self.rotation) + + def _relative_keys(self, actor, start, end, offset, interpolation, keyword, first_channel, read): 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}.") + raise ValueError(f"Zakres 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) + raise ValueError("offset wymaga trzech skonczonych wartosci.") + origin = read(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]): + previous_end = read(actor, end) + for axis, channel in enumerate(channels[first_channel:first_channel + 3]): correction = target[axis] - previous_end[axis] for key in list(channel.get_keys()): time = key.get_time().frame_number.value @@ -334,8 +421,8 @@ class Sequence: 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) + self.transform_key(actor, start, interpolation=interpolation, **{keyword: origin}) + self.transform_key(actor, end, interpolation=interpolation, **{keyword: target}) return target def save(self, open_editor=True): diff --git a/Content/Python/ue_workshop/video.py b/Content/Python/ue_workshop/video.py index dc888f9..8294e2d 100644 --- a/Content/Python/ue_workshop/video.py +++ b/Content/Python/ue_workshop/video.py @@ -5,7 +5,8 @@ import subprocess from .paths import executable -def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None): +def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None, audio=None): + """Encode PNG frames to MP4. Audio is cut to the film; shorter audio ends in silence.""" frames = Path(frames).resolve() if fps <= 0: raise ValueError("FPS musi byc dodatni.") @@ -17,10 +18,16 @@ def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None): raise RuntimeError(f"Render zapisal {count} klatek zamiast {expected_frames}.") output = Path(output) if output else frames.parent / "film.mp4" output.parent.mkdir(parents=True, exist_ok=True) + audio_input, audio_output = [], [] + if audio is not None: + if not Path(audio).is_file(): + raise FileNotFoundError(audio) + audio_input = ["-i", str(audio)] + audio_output = ["-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac", "-b:a", "192k", "-t", str(count / fps)] command = [executable("ffmpeg", ffmpeg), "-hide_banner", "-loglevel", "error", "-y", - "-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"), + "-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"), *audio_input, "-frames:v", str(count), "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", - "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-movflags", "+faststart", str(output)] + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", *audio_output, "-movflags", "+faststart", str(output)] result = subprocess.run(command, capture_output=True, text=True) if result.returncode: raise RuntimeError("FFmpeg: " + result.stderr[-4000:]) diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 6aaa048..b5abfd6 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -43,12 +43,19 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo | Własna mapa | `scene = Scene("MyShot")` | | Kolor PBR (wartości liniowe 0–1) | `mat = materials.color("Red", (0.8, 0.02, 0.01), roughness=0.4)` | | Prosta postać | `actor = scene.character(material=mat, head_scale=1.3)` | +| Kilka prostych postaci | `scene.character(material=mat, name="Bob", location=(200, 0, 0))` | | Bryła | `scene.primitive("Sphere", name="Ball", location=(0, 100, 50), material=mat)` | +| Pochyła bryła | `scene.primitive("Cube", name="Ramp", scale=(2, 2, 0.2), rotation=(0, 30, 0))` | +| Zmiana materiału, także modelu | `materials.assign(character, mat)` | +| Powtarzana tekstura | `materials.texture_set("Floor", base_color="textures/floor.png", tiling=8)` | | Import statycznego modelu | `mesh = assets.import_model("C:/models/model.glb", name="MyModel")` | | Eksport + import Blendera | `mesh = blender.import_character("C:/models/character.blend")` | | Model na scenie | `actor = scene.mesh(mesh, name="Hero")` | -| Import z dopasowaniem rozmiaru i pivotu | `actor = scene.model("source_assets/models/hero.glb", name="Hero", height=200)` | +| Import z dopasowaniem rozmiaru i pivotu | `actor = scene.model("source_assets/models/hero.glb", name="Hero", height=200, location=(0, 100, 0))` | | Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` | +| Niebo, słońce, światło otoczenia | `lighting.environment(scene)` | +| Zachód słońca z mgłą | `lighting.environment(scene, sun_angle=5, sun_yaw=90, fog=0.02)` | +| Noc | `lighting.environment(scene, sky=False, sun_intensity=0, ambient=0.3)` | | Ś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))` | @@ -58,6 +65,14 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo | Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` | | Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` | | Aktywna kamera | `shot.camera(cam)` | +| Cięcie na drugą kamerę | `shot.camera(cam2, start=60)` albo `shot.camera(cam2, start=60, end=90)` | +| Sekundy na klatki | `shot.seconds(2.5)` | +| Następny klip po poprzednim | `shot.then(character, clip)` | +| To samo z przejściem 10 klatek | `shot.then(character, clip, overlap=10)` | +| Obrót względny | `shot.rotate(actor, start=0, end=24, offset=(0, 0, 90))` | +| Odczyt rotacji | `shot.rotation(actor, frame=24)` | +| Pojawienie się od klatki 60 | `shot.visible(actor, 60)` | +| Zniknięcie od klatki 60 | `shot.visible(actor, 60, False)` | | 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")` | @@ -67,6 +82,7 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo | 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)` | | Render z własnymi ustawieniami | `renderer.render(shot)` | +| Muzyka w MP4 (przycięta do długości filmu) | `Renderer(audio="source_assets/music.mp3")` | | Podgląd tym samym rendererem | `renderer.render(shot, single_frame=24)` | | PNG + MP4 | `render.start(shot, preset="preview")` | | Tylko PNG | `render.start(shot, mp4=False)` | @@ -99,6 +115,10 @@ shot.animation(character) # tylko gdy postac ma jedna dostepna animacje # shot.animation(character, "idle", start=48, end=120) # automatyczny blend 12 klatek ``` +`shot.rotate` obraca aktora, ale `keep_end_position` dalej przesuwa go w kierunku +sprzed obrotu. Po skręcie użyj klipu In Place i `shot.move` w nowym kierunku. +Muzyka trafia tylko do MP4; Sequencer odtwarza film bez dźwięku. + Krótsza animacja zapętla się w swojej sekcji. `transform_key` porusza całym aktorem równolegle z animacją kości. Do własnych tras wybieraj klipy In Place. Blender: kolekcja `Character`, siatki przypisane do jednego Armature, aktywna akcja diff --git a/docs/validation.md b/docs/validation.md index cc84cee..9229f56 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -173,3 +173,15 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA 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. + +## Podstawowe operacje sceny + +- 86 testów poza UE: cięcia kamer, widoczność z dołączonymi aktorami, względna + rotacja, `then`, `seconds`, `materials.assign` oraz MP4 z dźwiękiem przyciętym do filmu. +- `tools/smoke_basics.py` w UE: 48 klatek, trzy odcinki dwóch kamer, render MP4 + ze ścieżką AAC. Wizualnie sprawdzono cięcia, pojawianie i znikanie obiektów, + czerwony materiał na fighterze, `tiling=8`, pochyłą bryłę i obrót postaci. +- `lighting.environment` sprawdzone wizualnie: dzień, mgła, zachód, noc i `sky=False`. +- Bez `lighting.environment` odsłonięte tło może być białe, np. przy samej żarówce. + Przy niebie na małej podłodze widać ciemny pas pod horyzontem; mgła go ukrywa. +- `shot.visible` wyłącza też światło: sprawdzone na żarówce między dwiema klatkami. diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 0000000..2995275 --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,265 @@ +"""Validate basic scene operations without an Unreal installation.""" +import importlib.util +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch +import wave + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "Content/Python")) +sys.path.insert(0, str(ROOT / "tests")) +from test_offline import png +from ue_workshop.video import encode + + +def load(unreal, filename): + path = ROOT / "Content/Python/ue_workshop" / filename + spec = importlib.util.spec_from_file_location("ue_workshop._basics_test_" + path.stem, path) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {"unreal": unreal}): + spec.loader.exec_module(module) + return module + + +def keyed_channel(frames): + channel = MagicMock() + keys = [] + for frame in frames: + key = MagicMock() + key.get_time.return_value.frame_number.value = frame + keys.append(key) + channel.get_keys.return_value = keys + return channel, keys + + +class SequenceBasicsTests(unittest.TestCase): + def setUp(self): + self.unreal = MagicMock() + self.unreal.FrameNumber.side_effect = lambda frame: frame + self.module = load(self.unreal, "sequence.py") + self.shot = self.module.Sequence.__new__(self.module.Sequence) + self.shot.frames, self.shot.fps = 48, 24 + self.shot.asset = MagicMock() + self.shot.scene = MagicMock() + self.shot.bind = MagicMock(side_effect=lambda actor: actor.binding) + self.shot._camera_cuts, self.shot._visibility, self.shot._animations = [], {}, {} + self.shot._transforms = {} + self.actor = MagicMock() + self.actor.get_path_name.return_value = "Actor" + self.actor.get_attached_actors.return_value = [] + self.channel = self.actor.binding.add_track.return_value.add_section.return_value.get_all_channels.return_value[0] + self.channel.get_keys.return_value = [] + + def cuts(self): + return [(start, end) for start, end, _ in self.shot._camera_cuts] + + def test_later_camera_cuts_the_rest_of_the_sequence(self): + self.shot.camera(MagicMock()) + self.shot.camera(MagicMock(), start=24) + self.assertEqual(self.cuts(), [(0, 24), (24, 48)]) + + def test_camera_inside_another_splits_it(self): + first = MagicMock() + self.shot.camera(first) + self.shot.camera(MagicMock(), start=12, end=24) + self.assertEqual(self.cuts(), [(0, 12), (12, 24), (24, 48)]) + self.assertIs(self.shot._camera_cuts[2][2], first.binding) + + def test_camera_covering_another_replaces_it(self): + self.shot.camera(MagicMock(), start=12, end=24) + self.shot.camera(MagicMock(), start=0, end=48) + self.assertEqual(self.cuts(), [(0, 48)]) + + def test_rerunning_camera_replaces_track_sections(self): + track = self.shot.asset.find_tracks_by_type.return_value[0] + track.get_sections.return_value = ["old"] + self.shot.camera(MagicMock()) + track.remove_section.assert_called_once_with("old") + + def test_invalid_camera_range_changes_nothing(self): + for start, end in [(24, 24), (-1, 10), (0, 49)]: + with self.assertRaises(ValueError): + self.shot.camera(MagicMock(), start=start, end=end) + self.assertEqual(self.shot._camera_cuts, []) + + def test_first_show_hides_actor_before_that_frame(self): + self.shot.visible(self.actor, 12) + self.assertEqual([call.args for call in self.channel.add_key.call_args_list], [(0, False), (12, True)]) + + def test_first_hide_shows_actor_before_that_frame(self): + self.shot.visible(self.actor, 12, False) + self.assertEqual([call.args for call in self.channel.add_key.call_args_list], [(0, True), (12, False)]) + + def test_key_on_first_frame_adds_no_opposite_state(self): + self.shot.visible(self.actor, 0, False) + self.channel.add_key.assert_called_once_with(0, False) + + def test_visibility_includes_attached_actors_and_reuses_tracks(self): + child = MagicMock() + child.get_path_name.return_value = "Child" + self.actor.get_attached_actors.return_value = [child] + self.shot.visible(self.actor, 12) + self.shot.visible(self.actor, 24, False) + self.actor.get_attached_actors.assert_called_with(True, True) + self.actor.binding.add_track.assert_called_once() + child.binding.add_track.assert_called_once() + + def test_same_frame_replaces_visibility_key(self): + channel, keys = keyed_channel([12]) + self.shot._visibility["Actor"] = channel + self.shot.visible(self.actor, 12, False) + channel.remove_key.assert_called_once_with(keys[0]) + + def test_visibility_validation(self): + for frame, value in [(48, True), (0, "no")]: + with self.assertRaises(ValueError): + self.shot.visible(self.actor, frame, value) + self.shot.bind.assert_not_called() + + def transform_channels(self, values): + channels = [] + for value in values: + channel, _ = keyed_channel([]) + channel.evaluate_keys.return_value = [value] + channels.append(channel) + self.shot._transforms["Actor"] = channels + return channels + + def test_rotation_without_track_reads_actor(self): + rotation = self.actor.get_actor_rotation.return_value + rotation.roll, rotation.pitch, rotation.yaw = 1, 2, 3 + self.assertEqual(self.shot.rotation(self.actor, 0), (1, 2, 3)) + + def test_rotation_reads_rotation_channels(self): + self.transform_channels([1, 2, 3, 10, 20, 30, 1, 1, 1]) + self.assertEqual(self.shot.rotation(self.actor, 5), (10, 20, 30)) + self.assertEqual(self.shot.position(self.actor, 5), (1, 2, 3)) + + def test_rotate_adds_relative_keys_past_full_turn(self): + channels = self.transform_channels([0, 0, 0, 0, 0, 45, 1, 1, 1]) + self.assertEqual(self.shot.rotate(self.actor, 0, 47, (0, 0, 360)), (0, 0, 405)) + self.assertEqual([call.args[:2] for call in channels[5].add_key.call_args_list], [(0, 45), (47, 405)]) + channels[0].add_key.assert_not_called() + + def test_rotate_replaces_range_and_shifts_later_keys_only_for_rotation(self): + channels = self.transform_channels([0, 0, 0, 0, 0, 0, 1, 1, 1]) + inside, later = MagicMock(), MagicMock() + inside.get_time.return_value.frame_number.value = 10 + later.get_time.return_value.frame_number.value = 30 + later.get_value.return_value = 5.0 + channels[5].get_keys.return_value = [inside, later] + position_key = MagicMock() + channels[0].get_keys.return_value = [position_key] + self.shot.rotate(self.actor, 0, 20, (0, 0, 90)) + channels[5].remove_key.assert_called_once_with(inside) + later.set_value.assert_called_once_with(95.0) + position_key.set_value.assert_not_called() + + def test_rotate_validation_creates_no_keys(self): + channels = self.transform_channels([0] * 9) + for start, end, offset in [(10, 10, (0, 0, 1)), (0, 48, (0, 0, 1)), (0, 10, (0, 1))]: + with self.assertRaises(ValueError): + self.shot.rotate(self.actor, start, end, offset) + channels[5].add_key.assert_not_called() + + def test_seconds_rounds_to_frames(self): + self.assertEqual(self.shot.seconds(1.5), 36) + self.assertEqual(self.shot.seconds(0.03), 1) + with self.assertRaises(ValueError): + self.shot.seconds(-1) + + def test_then_starts_after_latest_clip_with_overlap(self): + child = MagicMock() + child.get_path_name.return_value = "Child" + self.shot.scene._skeletal_models = {"Actor": (child, MagicMock())} + self.shot._animations = {"Child": [(0, 24), (24, 30)]} + self.shot.animation = MagicMock() + self.shot.then(self.actor, "walk", overlap=6, end=48) + self.shot.animation.assert_called_once_with(self.actor, "walk", start=24, end=48) + + def test_then_on_first_clip_starts_at_zero(self): + child = MagicMock() + self.shot.scene._skeletal_models = {"Actor": (child, MagicMock())} + self.shot.animation = MagicMock() + self.shot.then(self.actor, "walk") + self.shot.animation.assert_called_once_with(self.actor, "walk", start=0) + + def test_then_requires_skeletal_model(self): + self.shot.scene._skeletal_models = {} + with self.assertRaises(ValueError): + self.shot.then(self.actor, "walk") + + +class MaterialAssignTests(unittest.TestCase): + def setUp(self): + self.module = load(MagicMock(), "materials.py") + + def actor(self, *slot_counts, children=()): + actor = MagicMock() + components = [] + for count in slot_counts: + component = MagicMock() + component.get_num_materials.return_value = count + components.append(component) + actor.get_components_by_class.return_value = components + actor.get_attached_actors.return_value = list(children) + return actor, components + + def test_model_root_without_mesh_reaches_attached_mesh(self): + child, [mesh] = self.actor(2) + root, [empty] = self.actor(0, children=[child]) + self.module.assign(root, "red") + empty.set_material.assert_not_called() + self.assertEqual([call.args for call in mesh.set_material.call_args_list], [(0, "red"), (1, "red")]) + + def test_slot_replaces_one_material(self): + actor, [mesh] = self.actor(3) + self.module.assign(actor, "red", slot=1) + mesh.set_material.assert_called_once_with(1, "red") + + def test_actor_without_any_mesh_is_rejected(self): + actor, _ = self.actor(0) + with self.assertRaises(TypeError): + self.module.assign(actor, "red") + + +class RendererAudioTests(unittest.TestCase): + def test_audio_requires_mp4(self): + module = load(MagicMock(), "render.py") + with self.assertRaises(ValueError): + module.Renderer(mp4=False, audio="music.mp3") + + def test_missing_audio_stops_encoding(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + png(folder / "frame_0000.png", (255, 0, 0)) + with self.assertRaises(FileNotFoundError): + encode(folder, 24, audio=folder / "missing.wav") + + @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg missing") + def test_audio_is_cut_to_film_length(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "frames" + folder.mkdir() + for index in range(24): + png(folder / f"frame_{index:04d}.png", (255, 0, 0)) + music = Path(directory) / "music.wav" + with wave.open(str(music), "wb") as target: + target.setnchannels(1) + target.setsampwidth(2) + target.setframerate(8000) + target.writeframes(b"\x00\x10" * 8000 * 3) + movie = encode(folder, 24, expected_frames=24, audio=music) + probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,duration", + "-of", "csv=p=0", str(movie)], capture_output=True, text=True, check=True) + streams = dict(line.split(",") for line in probe.stdout.split()) + self.assertAlmostEqual(float(streams["video"]), 1.0, places=2) + self.assertAlmostEqual(float(streams["audio"]), 1.0, delta=0.1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/smoke_basics.py b/tools/smoke_basics.py new file mode 100644 index 0000000..94b5921 --- /dev/null +++ b/tools/smoke_basics.py @@ -0,0 +1,58 @@ +"""Check environment, camera cuts, visibility, rotate, materials, tiling, then and audio.""" +import math +import wave + +import unreal + +from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials +from ue_workshop.animations import FighterAnimations as F +from ue_workshop.characters import FIGHTER_MODEL +from ue_workshop.paths import PROJECT + +scene = Scene("BasicsSmoke") +lighting.environment(scene, fog=0.05) +tiled = materials.texture_set("BasicsFloor", tiling=8, + base_color="source_assets/character/creature/Maw J Laygo.fbm/MAW_diffuse.png") +red = materials.color("BasicsRed", (0.8, 0.02, 0.01)) +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=tiled) +ramp = scene.primitive("Cube", name="Ramp", location=(0, 250, 50), scale=(2, 2, 0.2), rotation=(0, 30, 0)) +assert abs(ramp.get_actor_rotation().pitch - 30) < 0.01 +fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200, location=(0, -150, 0)) +materials.assign(fighter, red) +skeletal = fighter.get_attached_actors()[0].skeletal_mesh_component +assert all(skeletal.get_material(index) == red for index in range(skeletal.get_num_materials())) +ghost = scene.character(material=red, name="Ghost", location=(-200, 100, 0)) +assert math.dist(ghost.get_attached_actors()[0].get_actor_location().to_tuple()[:2], (-200, 100)) < 60 +ball = scene.primitive("Sphere", name="Ball", location=(150, 0, 100), material=red) +wide = camera.create(scene, location=(600, -700, 300), target=(0, 0, 100)) +close = camera.create(scene, location=(300, -400, 150), target=(0, -150, 120), fov=30) + +shot = Sequence(scene, fps=24, frames=48) +assert shot.seconds(1.5) == 36 +shot.camera(wide) +shot.camera(close, start=24, end=36) +assert [cut[:2] for cut in shot._camera_cuts] == [(0, 24), (24, 36), (36, 48)] +tracks = shot.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack) +assert len(tracks[0].get_sections()) == 3 +shot.visible(ball, 12) +shot.visible(ball, 24, False) +shot.visible(ghost, 12, False) +shot.animation(fighter, F.GREAT_SWORD_IDLE, start=0, end=24) +shot.then(fighter, F.GREAT_SWORD_IDLE, overlap=6, end=48) +assert shot.rotation(fighter, 0) == (0, 0, 0) +assert shot.rotate(fighter, 0, 24, (0, 0, 90)) == (0, 0, 90) +assert abs(shot.rotation(fighter, 24)[2] - 90) < 0.001 +assert 0 < shot.rotation(fighter, 12)[2] < 90 +assert abs(shot.rotation(fighter, 47)[2] - 90) < 0.001 +assert shot.rotate(fighter, 24, 47, (0, 0, 360))[2] == 450 +shot.save(open_editor=False) + +music = PROJECT / "Saved/Workshop/basics_smoke.wav" +music.parent.mkdir(parents=True, exist_ok=True) +with wave.open(str(music), "wb") as target: + target.setnchannels(1) + target.setsampwidth(2) + target.setframerate(8000) + target.writeframes(b"\x00\x10" * 8000 * 5) +renderer = Renderer(width=640, height=360, samples=1, warmup=8, audio=str(music)) +print("Basics smoke ready")