import glob import json import os import traceback import unreal def _log(message: str) -> None: unreal.log(f"[HyperTwistMoviePipelineReplayExecutor] {message}") def _log_error(message: str) -> None: unreal.log_error(f"[HyperTwistMoviePipelineReplayExecutor] {message}") def _get_parameter(parameters, key: str, default: str = "") -> str: value = parameters.get(key, default) return value.strip() if isinstance(value, str) else default def _get_int_parameter(parameters, key: str, default: int) -> int: value = _get_parameter(parameters, key, "") if value == "": return default try: return int(value) except ValueError: return default def _resolve_render_success(results, matching_files) -> bool: success_value = getattr(results, "success", None) if success_value is None: return len(matching_files) > 0 try: return bool(success_value) except Exception: return len(matching_files) > 0 @unreal.uclass() class HyperTwistMoviePipelineReplayExecutor(unreal.MoviePipelinePythonHostExecutor): active_movie_pipeline = unreal.uproperty(unreal.MoviePipeline) output_directory = unreal.uproperty(str) output_base_name = unreal.uproperty(str) output_kind = unreal.uproperty(str) report_path = unreal.uproperty(str) replay_path = unreal.uproperty(str) render_map_path = unreal.uproperty(str) def _post_init(self): self.active_movie_pipeline = None self.output_directory = "" self.output_base_name = "" self.output_kind = "video" self.report_path = "" self.replay_path = "" self.render_map_path = "" @unreal.ufunction(override=True) def execute_delayed(self, in_pipeline_queue): try: (_cmd_tokens, _cmd_switches, cmd_parameters) = ( unreal.SystemLibrary.parse_command_line(unreal.SystemLibrary.get_command_line()) ) level_sequence_path = _get_parameter(cmd_parameters, "LevelSequence") if level_sequence_path == "": raise RuntimeError("Missing -LevelSequence= argument for replay export render.") self.output_directory = _get_parameter( cmd_parameters, "HyperTwistOutputDirectory", "C:/HyperTwist/media-export" ) self.output_base_name = _get_parameter( cmd_parameters, "HyperTwistOutputBaseName", "classic-cube-replay" ) self.output_kind = _get_parameter( cmd_parameters, "HyperTwistOutputKind", "video" ).lower() self.report_path = _get_parameter(cmd_parameters, "HyperTwistExportReportPath") self.replay_path = _get_parameter(cmd_parameters, "HyperTwistReplayPath") self.render_map_path = _get_parameter(cmd_parameters, "HyperTwistRenderMapPath") output_width = max( 16, _get_int_parameter(cmd_parameters, "HyperTwistOutputWidth", 1280) ) output_height = max( 16, _get_int_parameter(cmd_parameters, "HyperTwistOutputHeight", 720) ) output_frame_rate = max( 1, _get_int_parameter(cmd_parameters, "HyperTwistOutputFrameRate", 30) ) custom_start_frame = max( 0, _get_int_parameter(cmd_parameters, "HyperTwistCustomStartFrame", 0) ) custom_end_frame = max( custom_start_frame + 1, _get_int_parameter( cmd_parameters, "HyperTwistCustomEndFrame", custom_start_frame + 1 ), ) pipeline_queue = ( in_pipeline_queue if in_pipeline_queue is not None else unreal.new_object(unreal.MoviePipelineQueue, outer=self) ) self.pipeline_queue = pipeline_queue new_job = pipeline_queue.allocate_new_job(unreal.MoviePipelineExecutorJob) if self.render_map_path: new_job.map = unreal.SoftObjectPath(self.render_map_path) new_job.sequence = unreal.SoftObjectPath(level_sequence_path) job_config = new_job.get_configuration() output_setting = job_config.find_or_add_setting_by_class( unreal.MoviePipelineOutputSetting ) output_setting.output_directory = unreal.DirectoryPath(self.output_directory) output_setting.file_name_format = self.output_base_name output_setting.output_resolution = unreal.IntPoint(output_width, output_height) output_setting.use_custom_frame_rate = True output_setting.output_frame_rate = unreal.FrameRate( numerator=output_frame_rate, denominator=1 ) output_setting.override_existing_output = True output_setting.use_custom_playback_range = True output_setting.custom_start_frame = custom_start_frame output_setting.custom_end_frame = custom_end_frame output_setting.zero_pad_frame_numbers = 4 output_setting.flush_disk_writes_per_shot = True job_config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase) if self.output_kind == "still": job_config.find_or_add_setting_by_class( unreal.MoviePipelineImageSequenceOutput_PNG ) else: mp4_output = job_config.find_or_add_setting_by_class( unreal.MoviePipelineMP4EncoderOutput ) mp4_output.include_audio = False mp4_output.constant_rate_factor = 18 mp4_output.encoding_rate_control = ( unreal.MoviePipelineMP4EncodeRateControlMode.QUALITY ) job_config.initialize_transient_settings() target_world = self.get_last_loaded_world() if target_world is None: raise RuntimeError( "Movie render pipeline could not resolve the last loaded world." ) self.active_movie_pipeline = unreal.new_object( unreal.MoviePipeline, outer=target_world, ) self.active_movie_pipeline.on_movie_pipeline_work_finished_delegate.add_function_unique( self, "on_movie_pipeline_finished" ) _log( "Starting replay export render " f"(kind={self.output_kind}, map={self.render_map_path or ''}, " f"sequence={level_sequence_path}, " f"frames={custom_start_frame}-{custom_end_frame}, output={self.output_directory})" ) self.active_movie_pipeline.initialize(new_job) except Exception as error: _log_error( f"Replay export render setup failed: {error}\n{traceback.format_exc()}" ) self.active_movie_pipeline = None self.on_executor_finished_impl() @unreal.ufunction(override=True) def on_begin_frame(self): super(HyperTwistMoviePipelineReplayExecutor, self).on_begin_frame() @unreal.ufunction(override=True) def on_map_load(self, in_world): pass @unreal.ufunction(override=True) def is_rendering(self): return self.active_movie_pipeline is not None @unreal.ufunction(ret=None, params=[unreal.MoviePipelineOutputData]) def on_movie_pipeline_finished(self, results): extension = ".png" if self.output_kind == "still" else ".mp4" pattern = os.path.join(self.output_directory, "**", f"{self.output_base_name}*{extension}") matching_files = sorted(glob.glob(pattern, recursive=True)) success = _resolve_render_success(results, matching_files) report = { "reportVersion": "ht-classic-cube-mrq-export/v1", "success": success, "outputKind": self.output_kind, "outputDirectory": self.output_directory, "outputBaseName": self.output_base_name, "replayPath": self.replay_path, "files": matching_files, } if self.report_path: report_directory = os.path.dirname(self.report_path) if report_directory: os.makedirs(report_directory, exist_ok=True) with open(self.report_path, "w", encoding="utf-8") as handle: json.dump(report, handle, indent=2) _log( f"Finished replay export render (success={success}, files={len(matching_files)})" ) self.active_movie_pipeline = None self.on_executor_finished_impl()