workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user