more features
This commit is contained in:
1 parent
bd8edf5afb
commit
1530b74d04
10 files changed
+563
-39
No files matched your search
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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