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
|
||||
|
||||
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,17 +58,32 @@ class Sequence:
|
||||
self._bindings[key] = self.asset.add_possessable(actor)
|
||||
return self._bindings[key]
|
||||
|
||||
def camera(self, actor):
|
||||
def camera(self, actor, start=0, end=None):
|
||||
"""Use a camera over [start, end); a later call overrides earlier cameras in its range.
|
||||
|
||||
shot.camera(cam_a) followed by shot.camera(cam_b, start=60) cuts at frame 60.
|
||||
"""
|
||||
end = self.frames if end is None else end
|
||||
if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames:
|
||||
raise ValueError(f"Zakres kamery: 0 <= start < end <= {self.frames}.")
|
||||
binding = self.bind(actor)
|
||||
cuts = [(start, end, binding)]
|
||||
for old_start, old_end, old_binding in self._camera_cuts:
|
||||
if old_start < start:
|
||||
cuts.append((old_start, min(old_end, start), old_binding))
|
||||
if old_end > end:
|
||||
cuts.append((max(old_start, end), old_end, old_binding))
|
||||
self._camera_cuts = sorted(cuts, key=lambda cut: cut[0])
|
||||
tracks = self.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)
|
||||
track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack)
|
||||
for old in track.get_sections():
|
||||
track.remove_section(old)
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
binding_id = unreal.MovieSceneObjectBindingID()
|
||||
binding_id.set_editor_property("guid", binding.get_id())
|
||||
section.set_camera_binding_id(binding_id)
|
||||
for cut_start, cut_end, cut_binding in self._camera_cuts:
|
||||
section = track.add_section()
|
||||
section.set_range(cut_start, cut_end)
|
||||
binding_id = unreal.MovieSceneObjectBindingID()
|
||||
binding_id.set_editor_property("guid", cut_binding.get_id())
|
||||
section.set_camera_binding_id(binding_id)
|
||||
|
||||
def _animation_blend(self, section, start, end, fade_in, fade_out):
|
||||
# The skeletal animation section exposes its Weight channel to Python.
|
||||
@@ -188,6 +211,20 @@ class Sequence:
|
||||
self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
|
||||
return section
|
||||
|
||||
def then(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None, overlap=0, **options):
|
||||
"""Play a clip right after the actor's latest clip; overlap frames crossfade them.
|
||||
|
||||
Takes the same options as animation(), except start.
|
||||
"""
|
||||
if type(overlap) is not int or overlap < 0:
|
||||
raise ValueError("overlap musi byc nieujemna liczba klatek.")
|
||||
entry = self.scene._skeletal_models.get(actor.get_path_name())
|
||||
if entry is None:
|
||||
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
|
||||
sections = self._animations.get(entry[0].get_path_name())
|
||||
previous_end = max(item[1] for item in sections) if sections else 0
|
||||
return self.animation(actor, clip, start=max(previous_end - overlap, 0), **options)
|
||||
|
||||
def _hold_pose(self, track, clip, start, time):
|
||||
section = track.add_section()
|
||||
params = section.get_editor_property("params")
|
||||
@@ -257,6 +294,32 @@ class Sequence:
|
||||
for channel, value in zip(channels[offset:offset + 3], values):
|
||||
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
|
||||
|
||||
def visible(self, actor, frame, visible=True):
|
||||
"""Show or hide an actor and everything attached to it from a frame onward.
|
||||
|
||||
The first call decides the earlier state: shot.visible(actor, 60) keeps
|
||||
the actor hidden before frame 60, shot.visible(actor, 60, False) visible.
|
||||
"""
|
||||
if type(frame) is not int or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
if type(visible) is not bool:
|
||||
raise ValueError("visible musi byc True lub False.")
|
||||
for part in [actor, *actor.get_attached_actors(True, True)]:
|
||||
key = part.get_path_name()
|
||||
if key not in self._visibility:
|
||||
track = self.bind(part).add_track(unreal.MovieSceneVisibilityTrack)
|
||||
track.set_property_name_and_path("bHidden", "bHidden")
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
self._visibility[key] = section.get_all_channels()[0]
|
||||
if frame > 0:
|
||||
self._visibility[key].add_key(unreal.FrameNumber(0), not visible)
|
||||
channel = self._visibility[key]
|
||||
for old in list(channel.get_keys()):
|
||||
if old.get_time().frame_number.value == frame:
|
||||
channel.remove_key(old)
|
||||
channel.add_key(unreal.FrameNumber(frame), visible)
|
||||
|
||||
def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"):
|
||||
"""Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame."""
|
||||
if not isinstance(frame, int) or not 0 <= frame < self.frames:
|
||||
@@ -294,16 +357,27 @@ class Sequence:
|
||||
Evaluates Sequencer curves without moving the playhead. Does not include
|
||||
motion of bones inside an animation that was not transferred to Transform.
|
||||
"""
|
||||
return self._read_transform(actor, frame, 0, lambda: actor.get_actor_location().to_tuple())
|
||||
|
||||
def rotation(self, actor, frame: int) -> tuple[float, float, float]:
|
||||
"""Actor (roll, pitch, yaw) at a sequence frame, evaluated like position()."""
|
||||
def initial():
|
||||
rotation = actor.get_actor_rotation()
|
||||
return (rotation.roll, rotation.pitch, rotation.yaw)
|
||||
return self._read_transform(actor, frame, 3, initial)
|
||||
|
||||
def _read_transform(self, actor, frame, first_channel, initial):
|
||||
if type(frame) is not int or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
channels = self._transforms.get(actor.get_path_name())
|
||||
if channels is None:
|
||||
return actor.get_actor_location().to_tuple()
|
||||
return initial()
|
||||
interval = self.asset.make_range(frame, 1)
|
||||
rate = unreal.FrameRate(self.fps, 1)
|
||||
values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]]
|
||||
values = [channel.evaluate_keys(interval, rate)
|
||||
for channel in channels[first_channel:first_channel + 3]]
|
||||
if any(not axis for axis in values):
|
||||
raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.")
|
||||
raise RuntimeError("Nie mozna odczytac aktora z kanalow Transform.")
|
||||
return tuple(float(axis[0]) for axis in values)
|
||||
|
||||
def move(self, actor, start: int, end: int, offset: tuple[float, float, float],
|
||||
@@ -314,19 +388,32 @@ class Sequence:
|
||||
the resulting change, preserving later motion. Call after animation().
|
||||
Returns the target position. Rotation/scale keys are kept.
|
||||
"""
|
||||
return self._relative_keys(actor, start, end, offset, interpolation, "location", 0, self.position)
|
||||
|
||||
def rotate(self, actor, start: int, end: int, offset: tuple[float, float, float],
|
||||
interpolation="auto") -> tuple[float, float, float]:
|
||||
"""Rotate by offset (roll, pitch, yaw degrees) from the actor's rotation at start.
|
||||
|
||||
Works like move() for rotation keys; offset yaw=360 is one full turn.
|
||||
keep_end_position still follows the direction from before the rotation.
|
||||
Returns the target rotation. Position/scale keys are kept.
|
||||
"""
|
||||
return self._relative_keys(actor, start, end, offset, interpolation, "rotation", 3, self.rotation)
|
||||
|
||||
def _relative_keys(self, actor, start, end, offset, interpolation, keyword, first_channel, read):
|
||||
if (type(start) is not int or type(end) is not int or
|
||||
not 0 <= start < end < self.frames):
|
||||
raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.")
|
||||
raise ValueError(f"Zakres wymaga 0 <= start < end < {self.frames}.")
|
||||
if interpolation not in {"linear", "auto", "constant"}:
|
||||
raise ValueError("Interpolacja: linear, auto lub constant.")
|
||||
if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
|
||||
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.")
|
||||
origin = self.position(actor, start)
|
||||
raise ValueError("offset wymaga trzech skonczonych wartosci.")
|
||||
origin = read(actor, start)
|
||||
target = tuple(value + delta for value, delta in zip(origin, offset))
|
||||
channels = self._transforms.get(actor.get_path_name())
|
||||
if channels is not None:
|
||||
previous_end = self.position(actor, end)
|
||||
for axis, channel in enumerate(channels[:3]):
|
||||
previous_end = read(actor, end)
|
||||
for axis, channel in enumerate(channels[first_channel:first_channel + 3]):
|
||||
correction = target[axis] - previous_end[axis]
|
||||
for key in list(channel.get_keys()):
|
||||
time = key.get_time().frame_number.value
|
||||
@@ -334,8 +421,8 @@ class Sequence:
|
||||
channel.remove_key(key)
|
||||
elif time > end:
|
||||
key.set_value(key.get_value() + correction)
|
||||
self.transform_key(actor, start, location=origin, interpolation=interpolation)
|
||||
self.transform_key(actor, end, location=target, interpolation=interpolation)
|
||||
self.transform_key(actor, start, interpolation=interpolation, **{keyword: origin})
|
||||
self.transform_key(actor, end, interpolation=interpolation, **{keyword: target})
|
||||
return target
|
||||
|
||||
def save(self, open_editor=True):
|
||||
|
||||
@@ -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:])
|
||||
|
||||
Reference in new issue
Block a user