more features
This commit is contained in:
1 parent
bd8edf5afb
commit
1530b74d04
10 files changed
+560
-36
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,16 +58,31 @@ 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)
|
||||
for cut_start, cut_end, cut_binding in self._camera_cuts:
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
section.set_range(cut_start, cut_end)
|
||||
binding_id = unreal.MovieSceneObjectBindingID()
|
||||
binding_id.set_editor_property("guid", binding.get_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):
|
||||
@@ -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):
|
||||
|
||||
@@ -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:])
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user