diff --git a/Content/ImportedAssets/RobotPolice/MI_RobotPolice.uasset b/Content/ImportedAssets/RobotPolice/MI_RobotPolice.uasset index 3ffc2ea..c73e835 100644 Binary files a/Content/ImportedAssets/RobotPolice/MI_RobotPolice.uasset and b/Content/ImportedAssets/RobotPolice/MI_RobotPolice.uasset differ diff --git a/Content/Python/generate_blueprint_turntable.py b/Content/Python/generate_blueprint_turntable.py index 80fea62..f891005 100644 --- a/Content/Python/generate_blueprint_turntable.py +++ b/Content/Python/generate_blueprint_turntable.py @@ -2,8 +2,11 @@ Run this from Unreal's Output Log with:: - exec(open(r".../Content/Python/generate_blueprint_turntable.py").read()) - generate_turntables("/Game/Blueprints/BP_MyAsset") + py ".../Content/Python/generate_blueprint_turntable.py" /Game/Blueprints/BP_MyAsset + +Append ``--render-now`` to start Movie Render Queue and +``--all-animations`` to render every AnimSequence using the Blueprint's +skeleton. The script creates/saves a small presentation map, so the bindings in the sequences and MRQ jobs are reproducible. Open any generated sequence in @@ -19,6 +22,7 @@ DEFAULT_STAGE_MAP = "/Game/Maps/TurntableStage" DEFAULT_RENDER_DIRECTORY = "{project_dir}/Saved/TurntableRenders" FPS = 30 TURN_DURATION_SECONDS = 5.0 +CAMERA_Z = 40.0 QUEUE_RENDER_IMMEDIATELY = False # True starts MRQ immediately after queueing. RENDER_ALL_SKELETON_ANIMATIONS = False @@ -48,7 +52,7 @@ def _create_or_open_stage(stage_map): def _find_skeletal_components(actor): components = actor.get_components_by_class(unreal.SkeletalMeshComponent) - return [component for component in components if component.get_editor_property("skeletal_mesh")] + return [component for component in components if component.get_skeletal_mesh_asset()] def _animations_set_on_blueprint(actor): @@ -74,14 +78,15 @@ def _all_skeleton_animations(actor): """Optional broad mode: render every AnimSequence sharing the actor skeleton.""" skeletons = set() for component in _find_skeletal_components(actor): - mesh = component.get_editor_property("skeletal_mesh") + mesh = component.get_skeletal_mesh_asset() skeleton = mesh.get_editor_property("skeleton") if mesh else None if skeleton: skeletons.add(skeleton.get_path_name()) animations = [] registry = unreal.AssetRegistryHelpers.get_asset_registry() - for data in registry.get_assets_by_class("AnimSequence", True): + anim_sequence_class = unreal.TopLevelAssetPath("/Script/Engine", "AnimSequence") + for data in registry.get_assets_by_class(anim_sequence_class, True): animation = data.get_asset() if animation and animation.get_editor_property("skeleton").get_path_name() in skeletons: animations.append(animation) @@ -110,7 +115,8 @@ def _set_transform_keys(track, frames, start_location, start_rotation): def _add_camera(sequence, camera, frames): camera_binding = sequence.add_possessable(camera) - cut_track = sequence.add_master_track(unreal.MovieSceneCameraCutTrack) + # UE 5.8 creates master tracks through MovieSceneSequence.add_track. + cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack) cut = cut_track.add_section() cut.set_range(0, frames) # Camera cuts require a MovieSceneObjectBindingID, not the binding proxy. @@ -120,7 +126,7 @@ def _add_camera(sequence, camera, frames): def _add_animation_track(sequence, actor, animation, frames): """Bind the matching skeletal component and play its animation clip.""" for component in _find_skeletal_components(actor): - mesh = component.get_editor_property("skeletal_mesh") + mesh = component.get_skeletal_mesh_asset() skeleton = mesh.get_editor_property("skeleton") if mesh else None if skeleton != animation.get_editor_property("skeleton"): continue @@ -160,6 +166,10 @@ def _make_sequence(sequence_name, output_folder, actor, camera, animation=None): def _queue_render(sequence, stage_map, directory): + # Use a concrete directory rather than an MRQ format token, so its location + # is deterministic both in the queue UI and during PIE execution. + if directory == DEFAULT_RENDER_DIRECTORY: + directory = unreal.Paths.project_saved_dir().rstrip("/\\") + "/TurntableRenders" subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) queue = subsystem.get_queue() job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) @@ -171,10 +181,24 @@ def _queue_render(sequence, stage_map, directory): output.output_directory = unreal.DirectoryPath(directory) output.file_name_format = "{job_name}/{frame_number}" output.output_resolution = unreal.IntPoint(1920, 1080) + output.override_existing_output = True + # A queue constructed in Python has no implicit render pass. Without a + # Deferred Pass MRQ has no frames to submit or write. + config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase) config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG) return subsystem +def _remove_previous_turntable_jobs(bp_name): + """Replace prior generated jobs without touching the user's unrelated MRQ jobs.""" + subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) + queue = subsystem.get_queue() + prefix = "LS_TT_" + bp_name + for job in queue.get_jobs(): + if job.job_name.startswith(prefix): + queue.delete_job(job) + + def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT, stage_map=DEFAULT_STAGE_MAP, render_directory=DEFAULT_RENDER_DIRECTORY, render_immediately=QUEUE_RENDER_IMMEDIATELY, @@ -208,12 +232,21 @@ def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT, actor.set_actor_label("TT_" + bp_name) origin, extent = actor.get_actor_bounds(False) distance = max(extent.x, extent.y, extent.z) * 3.0 + 150.0 + camera_location = unreal.Vector(origin.x - distance, origin.y, CAMERA_Z) + camera_target = unreal.Vector(origin.x, origin.y, CAMERA_Z) camera = unreal.EditorLevelLibrary.spawn_actor_from_class( - unreal.CineCameraActor, unreal.Vector(origin.x - distance, origin.y, origin.z), unreal.Rotator(0, 0, 0)) + unreal.CineCameraActor, camera_location, unreal.Rotator(0, 0, 0)) camera.set_actor_label("TT_Camera_" + bp_name) camera.set_actor_rotation( - unreal.MathLibrary.find_look_at_rotation(camera.get_actor_location(), origin), False) - camera.get_cine_camera_component().set_editor_property("current_focal_length", 50.0) + unreal.MathLibrary.find_look_at_rotation(camera.get_actor_location(), camera_target), False) + cine_camera = camera.get_cine_camera_component() + cine_camera.set_editor_property("current_focal_length", 50.0) + # A library turntable must stay sharp across the whole asset, not only on a + # small cinematic focal plane. + cine_camera.set_editor_property( + "focus_settings", + unreal.CameraFocusSettings(focus_method=unreal.CameraFocusMethod.DISABLE), + ) sequences = [_make_sequence("LS_TT_" + bp_name, folder, actor, camera)] animations = _all_skeleton_animations(actor) if render_all_skeleton_animations else _animations_set_on_blueprint(actor) @@ -222,6 +255,7 @@ def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT, sequences.append(_make_sequence(name, folder, actor, camera, animation)) unreal.EditorLevelLibrary.save_current_level() + _remove_previous_turntable_jobs(bp_name) subsystem = None for sequence in sequences: subsystem = _queue_render(sequence, stage_map, render_directory) @@ -233,7 +267,9 @@ def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT, def _run_from_console_arguments(): - """Support `py "script.py" /Game/Path/BP_Name` in the Unreal console.""" + """Support a concise `py "script.py" /Game/Path/BP_Name [flags]` command.""" + render_now = "--render-now" in sys.argv + all_animations = "--all-animations" in sys.argv for argument in sys.argv[1:]: # Dragging an asset into the Output Log can produce # /Script/Engine.Blueprint'/Game/Foo/BP_X.BP_X'; accept that form too. @@ -241,7 +277,11 @@ def _run_from_console_arguments(): if game_index < 0: continue asset_path = argument[game_index:].split("'", 1)[0].split(".", 1)[0] - generate_turntables(asset_path) + generate_turntables( + asset_path, + render_immediately=render_now, + render_all_skeleton_animations=all_animations, + ) return