"""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