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

+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