184 lines
7.6 KiB
Python
184 lines
7.6 KiB
Python
"""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
|