workshop rework

This commit is contained in:
Vysyr committed 2026-10-31 17:03:41 +01:00
1 parent a3a80dfa16
commit fb39e290c0
115 files changed
+1865 -497

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
"""Python rendering workshop for Unreal Engine 5.7."""
import importlib
__all__ = ["Scene", "Sequence", "Renderer", "camera", "lighting", "materials", "assets", "blender", "render", "video"]
def __getattr__(name):
# Plain Python launchers can import paths/video/blender without importing unreal.
if name in {"Scene", "Sequence"}:
return getattr(importlib.import_module("." + name.lower(), __name__), name)
if name == "Renderer":
return importlib.import_module(".render", __name__).Renderer
if name in __all__:
return importlib.import_module("." + name, __name__)
raise AttributeError(name)
+105
View File
@@ -0,0 +1,105 @@
"""Synchronize basic glTF PBR properties preserved by UE mesh reimports."""
import json
import base64
import re
import struct
import unreal
from .paths import PROJECT
def link_textures(source, mesh, destination):
if source.suffix.lower() == ".glb":
data = source.read_bytes()
size, kind = struct.unpack_from("<II", data, 12)
if kind != 0x4E4F534A:
raise ValueError("GLB nie zawiera poczatkowego chunka JSON.")
document = json.loads(data[20:20 + size])
binary = data[28 + size:]
else:
document = json.loads(source.read_text(encoding="utf-8"))
binary = b""
def sanitized(value):
return re.sub(r"[^A-Za-z0-9_]", "_", value)
textures = {}
def load_texture(info, parameter):
index = document["textures"][info["index"]]["source"]
role = "Normal" if parameter == "NormalTexture" else (
"Color" if parameter in {"BaseColorTexture", "EmissiveTexture"} else "Linear")
key = (index, role)
if key in textures:
return textures[key]
image = document["images"][index]
name = f"T_{index:03d}_{sanitized(image.get('name', 'Image'))}_{role}"
uri = image.get("uri", "")
if "bufferView" in image:
view = document["bufferViews"][image["bufferView"]]
offset = view.get("byteOffset", 0)
payload = binary[offset:offset + view["byteLength"]]
elif uri.startswith("data:"):
payload = base64.b64decode(uri.split(",", 1)[1])
else:
from urllib.parse import unquote
payload = (source.parent / unquote(uri)).read_bytes()
extension = ".png" if payload.startswith(b"\x89PNG") else ".jpg" if payload.startswith(b"\xff\xd8") else None
if extension is None:
raise ValueError("Tekstury warsztatowe musza byc zapisane jako PNG lub JPEG.")
file = PROJECT / "build/GltfTextures" / source.stem / (name + extension)
file.parent.mkdir(parents=True, exist_ok=True)
file.write_bytes(payload)
task = unreal.AssetImportTask()
task.filename = str(file)
task.destination_path = destination + "/WorkshopTextures"
task.destination_name = name
task.automated = True
task.replace_existing = True
task.save = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
imported = [obj for obj in task.get_objects() if isinstance(obj, unreal.Texture2D)]
if len(imported) != 1:
raise RuntimeError("Nie mozna zaimportowac tekstury " + name)
texture = imported[0]
texture.set_editor_property("srgb", role == "Color")
texture.set_editor_property("flip_green_channel", role == "Normal")
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP
if role == "Normal" else unreal.TextureCompressionSettings.TC_DEFAULT
if role == "Color" else unreal.TextureCompressionSettings.TC_MASKS)
unreal.EditorAssetLibrary.save_loaded_asset(texture)
textures[key] = texture
return texture
materials = {sanitized(item.get("name", "")): item for item in document.get("materials", [])}
lib = unreal.MaterialEditingLibrary
for slot in mesh.static_materials:
material = slot.material_interface
if material is None or material.get_name() not in materials:
continue
spec = materials[material.get_name()]
pbr = spec.get("pbrMetallicRoughness", {})
lib.set_material_instance_vector_parameter_value(
material, "BaseColorFactor", unreal.LinearColor(*pbr.get("baseColorFactor", [1, 1, 1, 1])))
lib.set_material_instance_vector_parameter_value(
material, "EmissiveFactor", unreal.LinearColor(*spec.get("emissiveFactor", [0, 0, 0]), 1))
lib.set_material_instance_scalar_parameter_value(material, "MetallicFactor", pbr.get("metallicFactor", 1))
lib.set_material_instance_scalar_parameter_value(material, "RoughnessFactor", pbr.get("roughnessFactor", 1))
maps = {"BaseColorTexture": pbr.get("baseColorTexture"),
"MetallicRoughnessTexture": pbr.get("metallicRoughnessTexture"),
"NormalTexture": spec.get("normalTexture"),
"OcclusionTexture": spec.get("occlusionTexture"),
"EmissiveTexture": spec.get("emissiveTexture")}
for parameter, info in maps.items():
lib.set_material_instance_static_switch_parameter_value(material, "bHas" + parameter, info is not None)
lib.update_material_instance(material)
for parameter, info in maps.items():
if info is None:
continue
texture = load_texture(info, parameter)
# UE 5.7's setter returns False even when it succeeds; verify by reading.
lib.set_material_instance_texture_parameter_value(material, parameter, texture)
if lib.get_material_instance_texture_parameter_value(material, parameter) != texture:
raise RuntimeError(f"Nie mozna przypisac {parameter} w {material.get_name()}.")
lib.update_material_instance(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
+68
View File
@@ -0,0 +1,68 @@
"""Automated static mesh imports using the engine's enabled import pipelines."""
from pathlib import Path
import unreal
from .paths import GENERATED, slug
def import_model(filename, name="Character"):
source = Path(filename).resolve()
if not source.is_file():
raise FileNotFoundError(f"Brak modelu: {source}")
if source.suffix.lower() not in {".glb", ".gltf", ".fbx", ".obj"}:
raise ValueError("Model musi byc plikiem GLB, glTF, FBX lub OBJ.")
destination = f"{GENERATED}/Imports/{slug(name)}"
task = unreal.AssetImportTask()
task.set_editor_property("filename", str(source))
task.set_editor_property("destination_path", destination)
task.set_editor_property("destination_name", name)
task.set_editor_property("automated", True)
task.set_editor_property("replace_existing", True)
task.set_editor_property("replace_existing_settings", True)
task.set_editor_property("save", True)
task.set_editor_property("async_", False)
if source.suffix.lower() in {".glb", ".gltf"}:
# Defaults reuse existing materials without updating them. Our generated
# import folder belongs to the source file, so apply source properties.
template = unreal.load_asset("/Interchange/Pipelines/DefaultGLTFAssetsPipeline")
pipeline = unreal.load_asset(GENERATED + "/Pipelines/GLTFWorkshop")
if pipeline is None:
pipeline = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(
"GLTFWorkshop", GENERATED + "/Pipelines", template)
pipeline.set_editor_property("reimport_strategy", unreal.ReimportStrategyFlags.APPLY_PIPELINE_PROPERTIES)
pipeline.material_pipeline.set_editor_property("import_materials", True)
# PBR images are imported explicitly into WorkshopTextures below. This
# avoids Interchange's dependency-preserving material reimport behavior.
pipeline.material_pipeline.texture_pipeline.set_editor_property("import_textures", False)
pipeline.set_editor_property("scene_name_sub_folder", True)
pipeline.set_editor_property("asset_type_sub_folders", True)
pipeline.material_pipeline.set_editor_property(
"search_location", unreal.InterchangeMaterialSearchLocation.DO_NOT_SEARCH)
pipeline.material_pipeline.set_editor_property(
"material_import", unreal.InterchangeMaterialImportOption.IMPORT_AS_MATERIAL_INSTANCES)
unreal.EditorAssetLibrary.save_loaded_asset(pipeline)
options = unreal.InterchangePipelineStackOverride()
options.set_editor_property("override_pipelines", [
unreal.SoftObjectPath(pipeline.get_path_name()),
unreal.SoftObjectPath("/Interchange/Pipelines/DefaultGLTFPipeline.DefaultGLTFPipeline")])
task.set_editor_property("options", options)
if source.suffix.lower() == ".fbx":
options = unreal.FbxImportUI()
options.set_editor_property("import_as_skeletal", False)
options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_STATIC_MESH)
options.set_editor_property("automated_import_should_detect_type", False)
options.static_mesh_import_data.set_editor_property("combine_meshes", True)
task.set_editor_property("options", options)
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
meshes = [obj for obj in task.get_objects() if isinstance(obj, unreal.StaticMesh)]
if len(meshes) != 1:
raise RuntimeError(
f"Import {source.name}: oczekiwano jednego StaticMesh, otrzymano {len(meshes)}. "
"Polacz obiekty przed eksportem albo uzyj workshop.blender.import_character().")
if source.suffix.lower() in {".glb", ".gltf"}:
# UE preserves material edits on mesh reimport. In this workflow Blender
# owns the look; explicitly synchronize the supported PBR parameters.
from ._gltf import link_textures
link_textures(source, meshes[0], destination)
return meshes[0]
+25
View File
@@ -0,0 +1,25 @@
"""Export saved Blender files in a separate process, then import them in UE."""
from pathlib import Path
import subprocess
from .paths import PROJECT, executable, slug
def export(filename, name="Character", collection="Character", blender=None):
source = Path(filename).resolve()
if not source.is_file() or source.suffix.lower() != ".blend":
raise FileNotFoundError(f"Brak pliku .blend: {source}")
destination = PROJECT / "build" / slug(name) / (name + ".glb")
destination.parent.mkdir(parents=True, exist_ok=True)
command = [executable("blender", blender), "--background", str(source),
"--python-exit-code", "1", "--python", str(PROJECT / "tools" / "blender_export.py"),
"--", "--output", str(destination), "--collection", collection]
result = subprocess.run(command, capture_output=True, text=True, timeout=180)
if result.returncode or not destination.is_file():
raise RuntimeError("Eksport Blendera nie powiodl sie:\n" + (result.stdout + result.stderr)[-6000:])
return destination
def import_character(filename, name="Character", collection="Character", blender=None):
from .assets import import_model
return import_model(export(filename, name, collection, blender), name)
+30
View File
@@ -0,0 +1,30 @@
import unreal
def look_at(actor, target):
actor.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(
actor.get_actor_location(), unreal.Vector(*target)), False)
def create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45):
actor = scene.spawn(unreal.CineCameraActor, "Camera", location)
component = actor.get_cine_camera_component()
filmback = component.get_editor_property("filmback")
filmback.set_editor_property("sensor_width", 36.0)
filmback.set_editor_property("sensor_height", 20.25)
component.set_editor_property("filmback", filmback)
component.set_editor_property("current_focal_length", float(focal_length))
focus = component.get_editor_property("focus_settings")
focus.set_editor_property("focus_method", unreal.CameraFocusMethod.DISABLE)
component.set_editor_property("focus_settings", focus)
settings = component.get_editor_property("post_process_settings")
settings.set_editor_property("override_auto_exposure_bias", True)
settings.set_editor_property("auto_exposure_bias", 0.0)
settings.set_editor_property("override_auto_exposure_min_brightness", True)
settings.set_editor_property("override_auto_exposure_max_brightness", True)
# Extended luminance range interprets these as EV100, not linear brightness.
settings.set_editor_property("auto_exposure_min_brightness", 8.0)
settings.set_editor_property("auto_exposure_max_brightness", 8.0)
component.set_editor_property("post_process_settings", settings)
look_at(actor, target)
return actor
+23
View File
@@ -0,0 +1,23 @@
import unreal
from .camera import look_at
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
intensity=12000, color=(1, 1, 1), size=180):
actor = scene.spawn(unreal.RectLight, name, location)
light = actor.get_component_by_class(unreal.RectLightComponent)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light.set_editor_property("source_width", float(size))
light.set_editor_property("source_height", float(size))
light.set_editor_property("attenuation_radius", 2000.0)
light.set_light_color(unreal.LinearColor(*color, 1.0))
look_at(actor, target)
return 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, -250, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
+34
View File
@@ -0,0 +1,34 @@
"""Small opaque PBR materials, generated without external assets."""
import unreal
from .paths import GENERATED, slug
def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0):
path = f"{GENERATED}/Materials/M_{slug(name)}"
material = unreal.load_asset(path)
if material is None:
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_" + name, GENERATED + "/Materials", unreal.Material, unreal.MaterialFactoryNew())
if not isinstance(material, unreal.Material):
raise TypeError(f"{path} nie jest materialem.")
lib = unreal.MaterialEditingLibrary
lib.delete_all_material_expressions(material)
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)
for offset, value, prop in [(160, roughness, unreal.MaterialProperty.MP_ROUGHNESS),
(280, metallic, unreal.MaterialProperty.MP_METALLIC)]:
node = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -300, offset)
node.set_editor_property("r", float(value))
lib.connect_material_property(node, "", prop)
lib.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
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)
+23
View File
@@ -0,0 +1,23 @@
"""Paths and process helpers shared by UE, Blender launchers and plain Python."""
import os
from pathlib import Path
import re
import shutil
PROJECT = Path(__file__).resolve().parents[3]
GENERATED = "/Game/Workshop/Generated"
def slug(value):
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", value):
raise ValueError("Nazwa musi zaczynac sie litera i zawierac tylko litery ASCII, cyfry lub _.")
return value
def executable(name, explicit=None):
candidate = explicit or os.environ.get("WORKSHOP_" + name.upper()) or shutil.which(name)
if candidate and Path(candidate).is_file():
return str(Path(candidate).resolve())
raise FileNotFoundError(
f"Nie znaleziono {name}. Dodaj program do PATH lub ustaw WORKSHOP_{name.upper()} "
"na pelna sciezke do pliku wykonywalnego.")
+16
View File
@@ -0,0 +1,16 @@
"""Small append-only progress events shared with the external launcher."""
import json
import os
from pathlib import Path
def report(state, **details):
target = os.environ.get("WORKSHOP_PROGRESS")
if not target:
return
try:
with Path(target).open("a", encoding="utf-8") as stream:
stream.write(json.dumps(dict(state=state, **details), ensure_ascii=False) + "\n")
except OSError:
# Progress reporting must not interrupt a render or hide its result.
pass
+183
View File
@@ -0,0 +1,183 @@
"""MRQ rendering with retained delegates, isolated output and completion status."""
from datetime import datetime
from dataclasses import dataclass
import json
import os
from pathlib import Path
import uuid
import unreal
from .paths import PROJECT, executable
from .video import encode
from .progress import report
_active = None
PRESETS = {"preview": (640, 360, 1, 8), "final": (1280, 720, 4, 32)}
@dataclass
class Renderer:
"""Reusable render settings. Dimensions in pixels; warmup in frames."""
width: int = 640
height: int = 360
samples: int = 1
warmup: int = 8
mp4: bool = True
ffmpeg: str = None
def __post_init__(self):
self._validate()
def _validate(self):
for name, minimum in (("width", 1), ("height", 1), ("samples", 1), ("warmup", 0)):
value = getattr(self, name)
if type(value) is not int or value < minimum:
raise ValueError(f"{name} musi byc liczba calkowita >= {minimum}.")
@classmethod
def from_preset(cls, name="preview", **overrides):
if name not in PRESETS:
raise ValueError("Nieznany preset. Dostepne: " + ", ".join(PRESETS))
settings = dict(zip(("width", "height", "samples", "warmup"), PRESETS[name]))
settings.update(overrides)
return cls(**settings)
def render(self, sequence, *, single_frame=None):
self._validate()
return _start(sequence, self, single_frame=single_frame)
def is_rendering():
return _active is not None
def _write_status(data):
target = os.environ.get("WORKSHOP_STATUS")
if target:
path = Path(target)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def _on_error(executor, pipeline, fatal, message):
if _active is not None:
_active["errors"].append(str(message))
unreal.log_error("[Workshop] MRQ: " + str(message))
def _on_finished(executor, success):
global _active
state = _active
if state is None:
return
result = {"success": False, "output": str(state["output"]), "settings": state["settings"]}
try:
if not success or state["errors"]:
raise RuntimeError("Render nie powiodl sie: " + "; ".join(state["errors"]))
frames = state["output"] / "frames"
count = len(list(frames.glob("frame_*.png")))
if count != state["frames"]:
raise RuntimeError(f"Zapisano {count} klatek zamiast {state['frames']}.")
if state["mp4"]:
report("encoding")
result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"],
expected_frames=state["frames"]))
result["success"] = True
unreal.log("[Workshop] Gotowe: " + str(state["output"]))
except Exception as exc:
result["error"] = str(exc)
unreal.log_error("[Workshop] " + str(exc))
finally:
(state["output"] / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
_write_status(result)
_active = None
if os.environ.get("WORKSHOP_BATCH") == "1":
report("closing")
unreal.EditorPythonScripting.set_keep_python_script_alive(False)
def _begin_render(delta_seconds):
"""Start after the script and pending Slate window updates have returned."""
if _active is None:
return
_active["ticks"] -= 1
if _active["ticks"] > 0:
return
unreal.unregister_slate_post_tick_callback(_active.pop("tick_handle"))
try:
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
report("rendering", output=str(_active["output"]), frames=_active["frames"])
# This installs the Sequencer playback-context guard needed during PIE.
subsystem.render_queue_instance_with_executor_instance(_active["queue"], _active["executor"])
except Exception as exc:
_active["errors"].append(str(exc))
_on_finished(_active["executor"], False)
def start(sequence, preset="preview", mp4=True, ffmpeg=None, single_frame=None):
"""Shortcut preserved for existing workshop scripts."""
return Renderer.from_preset(preset, mp4=mp4, ffmpeg=ffmpeg).render(sequence, single_frame=single_frame)
def _start(sequence, renderer, single_frame=None):
global _active
if _active is not None:
raise RuntimeError("Trwa juz render warsztatowy.")
if single_frame is not None and not 0 <= single_frame < sequence.frames:
raise ValueError("Klatka poza zakresem sekwencji.")
mp4 = renderer.mp4 and single_frame is None
ffmpeg_path = executable("ffmpeg", renderer.ffmpeg) if mp4 else None
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
if subsystem.is_rendering():
raise RuntimeError("Movie Render Queue jest zajete.")
report("preparing_render")
# Own queue: do not clear the user's Movie Render Queue.
queue = unreal.MoviePipelineQueue()
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
sequence.save(open_editor=False)
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
job.map = unreal.SoftObjectPath(sequence.scene.map_path)
job.sequence = unreal.SoftObjectPath(sequence.asset.get_path_name())
job.job_name = sequence.scene.name
width, height, samples, warmup = renderer.width, renderer.height, renderer.samples, renderer.warmup
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
output = PROJECT / "renders" / sequence.scene.name / stamp
(output / "frames").mkdir(parents=True)
config = job.get_configuration()
settings = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
settings.output_directory = unreal.DirectoryPath(str(output / "frames"))
settings.file_name_format = "frame_{frame_number_rel}"
settings.zero_pad_frame_numbers = 4
settings.output_resolution = unreal.IntPoint(width, height)
settings.use_custom_frame_rate = True
settings.output_frame_rate = unreal.FrameRate(sequence.fps, 1)
settings.use_custom_playback_range = True
settings.custom_start_frame = single_frame if single_frame is not None else 0
settings.custom_end_frame = single_frame + 1 if single_frame is not None else sequence.frames
settings.flush_disk_writes_per_shot = True
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
aa = config.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
aa.spatial_sample_count = samples
aa.temporal_sample_count = 1
aa.engine_warm_up_count = warmup
aa.render_warm_up_count = warmup
aa.render_warm_up_frames = True
executor = unreal.MoviePipelinePIEExecutor()
executor.on_executor_errored_delegate.add_callable_unique(_on_error)
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,
settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps))
try:
_write_status({"state": "rendering", "output": str(output)})
_active["tick_handle"] = unreal.register_slate_post_tick_callback(_begin_render)
except Exception:
_active = None
raise
unreal.log("[Workshop] Render: " + str(output))
return output
+86
View File
@@ -0,0 +1,86 @@
"""An isolated generated map, simple meshes and a ready-to-use character."""
import unreal
from .paths import GENERATED, slug
class Scene:
"""Owns one map. Building again replaces only that generated map's actors."""
def __init__(self, name="MyShot"):
from . import render
if render.is_rendering() or unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem).is_rendering():
raise RuntimeError("Poczekaj na zakonczenie renderu przed przebudowa sceny.")
# Discard Sequencer's live bindings before replacing their actors.
current_sequence = unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence()
if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
self.name = slug(name)
self.map_path = f"{GENERATED}/{name}/Map"
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
current = editor.get_current_level()
current_path = current.get_outer().get_path_name().split(".")[0] if current else ""
if current_path == self.map_path:
# This map is wholly generated by this exercise, never a user's other map.
for actor in self.subsystem.get_all_level_actors():
if "Workshop" in [str(tag) for tag in actor.tags]:
self.subsystem.destroy_actor(actor)
else:
if unreal.EditorLoadingAndSavingUtils.get_dirty_map_packages():
raise RuntimeError("Zapisz zmiany aktualnej mapy przed utworzeniem sceny warsztatowej.")
if unreal.EditorAssetLibrary.does_asset_exist(self.map_path):
if not editor.load_level(self.map_path):
raise RuntimeError("Nie mozna otworzyc " + self.map_path)
for actor in self.subsystem.get_all_level_actors():
if "Workshop" in [str(tag) for tag in actor.tags]:
self.subsystem.destroy_actor(actor)
elif not editor.new_level(self.map_path):
raise RuntimeError("Nie mozna utworzyc " + self.map_path)
def spawn(self, actor_class, name, location=(0, 0, 0), rotation=(0, 0, 0)):
actor = self.subsystem.spawn_actor_from_class(actor_class, unreal.Vector(*location),
unreal.Rotator(*rotation))
if actor is None:
raise RuntimeError("Nie mozna utworzyc aktora " + name)
actor.set_actor_label(name)
actor.tags = ["Workshop"]
actor.set_folder_path("Workshop")
return actor
def mesh(self, asset, name="Model", location=(0, 0, 0), scale=(1, 1, 1), material=None):
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.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
actor.static_mesh_component.set_static_mesh(mesh)
actor.set_actor_scale3d(unreal.Vector(*scale))
if material is not None:
actor.static_mesh_component.set_material(0, material)
return actor
def primitive(self, shape="Cube", **kwargs):
if shape not in {"Cube", "Sphere", "Cylinder", "Cone", "Plane"}:
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs)
def character(self, material=None, head_scale=1.0):
root = self.spawn(unreal.StaticMeshActor, "Character")
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
for shape, name, 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.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return root
def save(self):
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if not editor.save_current_level():
raise RuntimeError("Nie mozna zapisac mapy " + self.map_path)
+81
View File
@@ -0,0 +1,81 @@
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
import unreal
from .paths import GENERATED, slug
class Sequence:
def __init__(self, scene, name="Shot", fps=24, frames=48):
if not isinstance(fps, int) or fps < 1 or not isinstance(frames, int) or frames < 1:
raise ValueError("FPS i liczba klatek musza byc dodatnimi liczbami calkowitymi.")
self.scene, self.fps, self.frames = scene, fps, frames
path = f"{GENERATED}/{scene.name}/LS_{slug(name)}"
self.asset = unreal.load_asset(path)
if self.asset and unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence() == self.asset:
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
if self.asset is None:
self.asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"LS_" + name, f"{GENERATED}/{scene.name}", unreal.LevelSequence,
unreal.LevelSequenceFactoryNew())
for binding in self.asset.get_bindings():
binding.remove()
for track in self.asset.get_tracks():
self.asset.remove_track(track)
self.asset.set_display_rate(unreal.FrameRate(fps, 1))
self.asset.set_playback_start(0)
self.asset.set_playback_end(frames)
self._bindings = {}
self._transforms = {}
def bind(self, actor):
key = actor.get_path_name()
if key not in self._bindings:
self._bindings[key] = self.asset.add_possessable(actor)
return self._bindings[key]
def camera(self, actor):
binding = self.bind(actor)
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)
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
if interpolation not in modes:
raise ValueError("Interpolacja: linear, auto lub constant.")
key = actor.get_path_name()
if key not in self._transforms:
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
initial = actor.get_actor_transform()
r = initial.rotation.rotator()
defaults = [*initial.translation.to_tuple(), r.roll, r.pitch, r.yaw,
*initial.scale3d.to_tuple()]
for channel, value in zip(channels, defaults):
channel.set_default(float(value))
self._transforms[key] = channels
channels = self._transforms[key]
for offset, values in [(0, location), (3, rotation), (6, scale)]:
if values is not None:
if len(values) != 3:
raise ValueError("Transformacja wymaga trzech wartosci.")
for channel, value in zip(channels[offset:offset + 3], values):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
def save(self, open_editor=True):
self.scene.save()
unreal.EditorAssetLibrary.save_loaded_asset(self.asset)
if open_editor:
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(self.asset)
+27
View File
@@ -0,0 +1,27 @@
"""FFmpeg encoding; this module also works outside Unreal."""
from pathlib import Path
import subprocess
from .paths import executable
def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None):
frames = Path(frames).resolve()
if fps <= 0:
raise ValueError("FPS musi byc dodatni.")
images = sorted(frames.glob("frame_*.png"))
count = len(images)
if not count or any(p.name != f"frame_{i:04d}.png" for i, p in enumerate(images)):
raise RuntimeError(f"Brak kompletnej numeracji frame_0000.png, frame_0001.png... w {frames}")
if expected_frames is not None and count != expected_frames:
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)
command = [executable("ffmpeg", ffmpeg), "-hide_banner", "-loglevel", "error", "-y",
"-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"),
"-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)]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise RuntimeError("FFmpeg: " + result.stderr[-4000:])
return output.resolve()