more features

This commit is contained in:
Vysyr committed 2026-10-31 23:43:04 +01:00
1 parent bd8edf5afb
commit 1530b74d04
10 files changed
+563 -39

No files matched your search

+47 -1
View File
@@ -1,7 +1,14 @@
import math
from types import SimpleNamespace
import unreal import unreal
from .camera import look_at 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): def _setup(actor, component_class, intensity, color):
light = actor.get_component_by_class(component_class) 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)): intensity=500, color=(1, 1, 1)):
"""Parallel light for the whole scene; only the location-target direction matters.""" """Parallel light for the whole scene; only the location-target direction matters."""
actor = scene.spawn(unreal.DirectionalLight, name, location) 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) look_at(actor, target)
return actor 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)): 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), return [area(scene, "Key", (200, -500, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color), area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
+27 -9
View File
@@ -3,7 +3,7 @@ import unreal
from pathlib import Path 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): 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.") raise TypeError(f"{path} nie jest materialem.")
lib = unreal.MaterialEditingLibrary lib = unreal.MaterialEditingLibrary
lib.delete_all_material_expressions(material) 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 = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -300, 0)
base.set_editor_property("constant", unreal.LinearColor(*rgb, 1.0)) base.set_editor_property("constant", unreal.LinearColor(*rgb, 1.0))
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR) 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 return material
def assign(actor, material, slot=0): def assign(actor, material, slot=None):
component = actor.get_component_by_class(unreal.StaticMeshComponent) """Set a material on an actor and all attached meshes; slot=None replaces every slot."""
if component is None: components = [component for part in [actor, *actor.get_attached_actors(True, True)]
raise TypeError("Material mozna przypisac do aktora ze StaticMeshComponent.") for component in part.get_components_by_class(unreal.MeshComponent)
component.set_material(slot, material) 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): 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).""" """Create one atlas material from adjacent PBR textures (PNG/JPEG).
tiling repeats the textures: one number, or (u, v) for separate directions.
"""
slug(name) 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() tools = unreal.AssetToolsHelpers.get_asset_tools()
path = f"{GENERATED}/Materials/M_{name}" path = f"{GENERATED}/Materials/M_{name}"
material = unreal.load_asset(path) 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()) unreal.Material, unreal.MaterialFactoryNew())
lib = unreal.MaterialEditingLibrary lib = unreal.MaterialEditingLibrary
lib.delete_all_material_expressions(material) 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"), entries = [("BaseColor", base_color, unreal.MaterialProperty.MP_BASE_COLOR, "RGB", "color"),
("Normal", normal, unreal.MaterialProperty.MP_NORMAL, "RGB", "normal"), ("Normal", normal, unreal.MaterialProperty.MP_NORMAL, "RGB", "normal"),
("Roughness", roughness, unreal.MaterialProperty.MP_ROUGHNESS, "R", "linear"), ("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) node.set_editor_property("r", 0.6 if label == "Roughness" else 0.0)
lib.connect_material_property(node, "", prop) lib.connect_material_property(node, "", prop)
continue continue
source = Path(filename).resolve() source = Path(filename).expanduser()
source = (source if source.is_absolute() else PROJECT / source).resolve()
if not source.is_file(): if not source.is_file():
raise FileNotFoundError(source) raise FileNotFoundError(source)
task = unreal.AssetImportTask() 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 node.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL
if role == "normal" else unreal.MaterialSamplerType.SAMPLERTYPE_MASKS if role == "normal" else unreal.MaterialSamplerType.SAMPLERTYPE_MASKS
if role == "linear" else unreal.MaterialSamplerType.SAMPLERTYPE_COLOR) if role == "linear" else unreal.MaterialSamplerType.SAMPLERTYPE_COLOR)
lib.connect_material_expressions(coordinates, "", node, "UVs")
lib.connect_material_property(node, channel, prop) lib.connect_material_property(node, channel, prop)
lib.recompile_material(material) lib.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material) unreal.EditorAssetLibrary.save_loaded_asset(material)
+11 -2
View File
@@ -26,6 +26,7 @@ class Renderer:
warmup: int = 8 warmup: int = 8
mp4: bool = True mp4: bool = True
ffmpeg: str = None ffmpeg: str = None
audio: str = None
def __post_init__(self): def __post_init__(self):
self._validate() self._validate()
@@ -35,6 +36,8 @@ class Renderer:
value = getattr(self, name) value = getattr(self, name)
if type(value) is not int or value < minimum: if type(value) is not int or value < minimum:
raise ValueError(f"{name} musi byc liczba calkowita >= {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 @classmethod
def from_preset(cls, name="preview", **overrides): def from_preset(cls, name="preview", **overrides):
@@ -84,7 +87,7 @@ def _on_finished(executor, success):
if state["mp4"]: if state["mp4"]:
report("encoding") report("encoding")
result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"], result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"],
expected_frames=state["frames"])) expected_frames=state["frames"], audio=state["audio"]))
result["success"] = True result["success"] = True
unreal.log("[Workshop] Gotowe: " + str(state["output"])) unreal.log("[Workshop] Gotowe: " + str(state["output"]))
except Exception as exc: except Exception as exc:
@@ -130,6 +133,12 @@ def _start(sequence, renderer, single_frame=None):
raise ValueError("Klatka poza zakresem sekwencji.") raise ValueError("Klatka poza zakresem sekwencji.")
mp4 = renderer.mp4 and single_frame is None mp4 = renderer.mp4 and single_frame is None
ffmpeg_path = executable("ffmpeg", renderer.ffmpeg) if mp4 else 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) subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
if subsystem.is_rendering(): if subsystem.is_rendering():
raise RuntimeError("Movie Render Queue jest zajete.") 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) executor.on_executor_finished_delegate.add_callable_unique(_on_finished)
_active = dict(executor=executor, queue=queue, output=output, errors=[], fps=sequence.fps, _active = dict(executor=executor, queue=queue, output=output, errors=[], fps=sequence.fps,
frames=1 if single_frame is not None else sequence.frames, 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)) settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps))
try: try:
_write_status({"state": "rendering", "output": str(output)}) _write_status({"state": "rendering", "output": str(output)})
+9 -7
View File
@@ -52,11 +52,11 @@ class Scene:
actor.set_folder_path("Workshop") actor.set_folder_path("Workshop")
return actor 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 mesh = unreal.load_asset(asset) if isinstance(asset, str) else asset
if not isinstance(mesh, unreal.StaticMesh): if not isinstance(mesh, unreal.StaticMesh):
raise TypeError(f"Oczekiwano StaticMesh: {asset}") 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_mobility(unreal.ComponentMobility.MOVABLE)
actor.static_mesh_component.set_static_mesh(mesh) actor.static_mesh_component.set_static_mesh(mesh)
actor.set_actor_scale3d(unreal.Vector(*scale)) actor.set_actor_scale3d(unreal.Vector(*scale))
@@ -69,7 +69,7 @@ class Scene:
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.") raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs) 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. """Import a model, fit its height (cm), and give it a floor-centered pivot.
Returns a parent actor for transform animation; imported materials stay 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) root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD, actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False) unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
root.set_actor_location(unreal.Vector(*location), False, False)
return root return root
def skeletal_model(self, filename, name="Character", height=200, rotation=(0, 0, 0), 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) self._skeletal_models[root.get_path_name()] = (child, imported)
return root return root
def character(self, material=None, head_scale=1.0): def character(self, material=None, head_scale=1.0, name="Character", location=(0, 0, 0)):
root = self.spawn(unreal.StaticMeshActor, "Character") root = self.spawn(unreal.StaticMeshActor, name)
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) 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)), ("Cube", "Body", (0, 0, 90), (0.55, 0.8, 0.9)),
("Sphere", "Head", (0, 0, 175), (0.7 * head_scale,) * 3), ("Sphere", "Head", (0, 0, 175), (0.7 * head_scale,) * 3),
("Cube", "LeftLeg", (0, -23, 25), (0.35, 0.3, 0.5)), ("Cube", "LeftLeg", (0, -23, 25), (0.35, 0.3, 0.5)),
("Cube", "RightLeg", (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)), ("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, actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False) unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
root.set_actor_location(unreal.Vector(*location), False, False)
return root return root
def save(self): def save(self):
+103 -16
View File
@@ -43,6 +43,14 @@ class Sequence:
self._root_motion = {} self._root_motion = {}
self._root_motion_copies = {} self._root_motion_copies = {}
self._pose_holds = {} 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): def bind(self, actor):
key = actor.get_path_name() key = actor.get_path_name()
@@ -50,17 +58,32 @@ class Sequence:
self._bindings[key] = self.asset.add_possessable(actor) self._bindings[key] = self.asset.add_possessable(actor)
return self._bindings[key] 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) 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) tracks = self.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)
track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack) track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack)
for old in track.get_sections(): for old in track.get_sections():
track.remove_section(old) track.remove_section(old)
section = track.add_section() for cut_start, cut_end, cut_binding in self._camera_cuts:
section.set_range(0, self.frames) section = track.add_section()
binding_id = unreal.MovieSceneObjectBindingID() section.set_range(cut_start, cut_end)
binding_id.set_editor_property("guid", binding.get_id()) binding_id = unreal.MovieSceneObjectBindingID()
section.set_camera_binding_id(binding_id) 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): def _animation_blend(self, section, start, end, fade_in, fade_out):
# The skeletal animation section exposes its Weight channel to Python. # 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)) self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
return section 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): def _hold_pose(self, track, clip, start, time):
section = track.add_section() section = track.add_section()
params = section.get_editor_property("params") params = section.get_editor_property("params")
@@ -257,6 +294,32 @@ class Sequence:
for channel, value in zip(channels[offset:offset + 3], values): for channel, value in zip(channels[offset:offset + 3], values):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode) 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"): 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.""" """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: 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 Evaluates Sequencer curves without moving the playhead. Does not include
motion of bones inside an animation that was not transferred to Transform. 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: if type(frame) is not int or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
channels = self._transforms.get(actor.get_path_name()) channels = self._transforms.get(actor.get_path_name())
if channels is None: if channels is None:
return actor.get_actor_location().to_tuple() return initial()
interval = self.asset.make_range(frame, 1) interval = self.asset.make_range(frame, 1)
rate = unreal.FrameRate(self.fps, 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): 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) return tuple(float(axis[0]) for axis in values)
def move(self, actor, start: int, end: int, offset: tuple[float, float, float], 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(). the resulting change, preserving later motion. Call after animation().
Returns the target position. Rotation/scale keys are kept. 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 if (type(start) is not int or type(end) is not int or
not 0 <= start < end < self.frames): 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"}: if interpolation not in {"linear", "auto", "constant"}:
raise ValueError("Interpolacja: linear, auto lub constant.") raise ValueError("Interpolacja: linear, auto lub constant.")
if len(offset) != 3 or any(not math.isfinite(value) for value in offset): if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.") raise ValueError("offset wymaga trzech skonczonych wartosci.")
origin = self.position(actor, start) origin = read(actor, start)
target = tuple(value + delta for value, delta in zip(origin, offset)) target = tuple(value + delta for value, delta in zip(origin, offset))
channels = self._transforms.get(actor.get_path_name()) channels = self._transforms.get(actor.get_path_name())
if channels is not None: if channels is not None:
previous_end = self.position(actor, end) previous_end = read(actor, end)
for axis, channel in enumerate(channels[:3]): for axis, channel in enumerate(channels[first_channel:first_channel + 3]):
correction = target[axis] - previous_end[axis] correction = target[axis] - previous_end[axis]
for key in list(channel.get_keys()): for key in list(channel.get_keys()):
time = key.get_time().frame_number.value time = key.get_time().frame_number.value
@@ -334,8 +421,8 @@ class Sequence:
channel.remove_key(key) channel.remove_key(key)
elif time > end: elif time > end:
key.set_value(key.get_value() + correction) key.set_value(key.get_value() + correction)
self.transform_key(actor, start, location=origin, interpolation=interpolation) self.transform_key(actor, start, interpolation=interpolation, **{keyword: origin})
self.transform_key(actor, end, location=target, interpolation=interpolation) self.transform_key(actor, end, interpolation=interpolation, **{keyword: target})
return target return target
def save(self, open_editor=True): def save(self, open_editor=True):
+10 -3
View File
@@ -5,7 +5,8 @@ import subprocess
from .paths import executable 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() frames = Path(frames).resolve()
if fps <= 0: if fps <= 0:
raise ValueError("FPS musi byc dodatni.") 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}.") raise RuntimeError(f"Render zapisal {count} klatek zamiast {expected_frames}.")
output = Path(output) if output else frames.parent / "film.mp4" output = Path(output) if output else frames.parent / "film.mp4"
output.parent.mkdir(parents=True, exist_ok=True) 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", 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", "-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) result = subprocess.run(command, capture_output=True, text=True)
if result.returncode: if result.returncode:
raise RuntimeError("FFmpeg: " + result.stderr[-4000:]) raise RuntimeError("FFmpeg: " + result.stderr[-4000:])
+21 -1
View File
@@ -43,12 +43,19 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo
| Własna mapa | `scene = Scene("MyShot")` | | 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)` | | 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)` | | 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)` | | 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")` | | Import statycznego modelu | `mesh = assets.import_model("C:/models/model.glb", name="MyModel")` |
| Eksport + import Blendera | `mesh = blender.import_character("C:/models/character.blend")` | | Eksport + import Blendera | `mesh = blender.import_character("C:/models/character.blend")` |
| Model na scenie | `actor = scene.mesh(mesh, name="Hero")` | | 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))` | | 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)` | | Ś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))` | | 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))` | | Ż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))` | | Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` |
| Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` | | Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` |
| Aktywna kamera | `shot.camera(cam)` | | 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 pozycji | `shot.transform_key(actor, 0, location=(0, 0, 0))` |
| Klucz rotacji | `shot.transform_key(actor, 47, rotation=(0, 0, 360))` | | 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 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)` | | 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)` | | Preset z własnymi wartościami | `renderer = Renderer.from_preset("final", samples=8)` |
| Render z własnymi ustawieniami | `renderer.render(shot)` | | 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)` | | Podgląd tym samym rendererem | `renderer.render(shot, single_frame=24)` |
| PNG + MP4 | `render.start(shot, preset="preview")` | | PNG + MP4 | `render.start(shot, preset="preview")` |
| Tylko PNG | `render.start(shot, mp4=False)` | | 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.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 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. równolegle z animacją kości. Do własnych tras wybieraj klipy In Place.
Blender: kolekcja `Character`, siatki przypisane do jednego Armature, aktywna akcja Blender: kolekcja `Character`, siatki przypisane do jednego Armature, aktywna akcja
+12
View File
@@ -173,3 +173,15 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA
oraz wzrost jasności reflektora i żarówki. oraz wzrost jasności reflektora i żarówki.
- Domyślne `intensity=500` słońca dobrano do stałej ekspozycji `camera.create`; - 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. 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.
+265
View File
@@ -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()
+58
View File
@@ -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")