I created a Python script for Unreal Engine that automatically generates sequences and renders characters.

“H:\SandBox\UE583SequenceRender\run_auto_render.bat”
@echo off
rem Auto Sequence Render: launch UE -> build sequences -> render (MRQ) -> MP4 -> quit
rem Usage: run_auto_render.bat [config.json]
setlocal EnableExtensions
rem ===================== Settings (edit per PC) =====================
rem Leave empty for auto-detect. Write WITHOUT extra quotes inside: set "X=C:\path"
rem USER_UE_EDITOR : UnrealEditor.exe (needed for source/custom engine builds)
rem USER_PROJECT : .uproject to open (default: UE583SequenceRender next to this bat)
set "USER_UE_EDITOR="
set "USER_PROJECT="
rem ==================================================================
set "ROOT=%~dp0"
set "PROJECT=%ROOT%UE583SequenceRender\UE583SequenceRender.uproject"
if defined USER_PROJECT set "PROJECT=%USER_PROJECT%"
if defined USER_UE_EDITOR set "UE_EDITOR=%USER_UE_EDITOR%"
rem Strip quotes in case they were written as set X="C:\path"
set "PROJECT=%PROJECT:"=%"
if defined UE_EDITOR set "UE_EDITOR=%UE_EDITOR:"=%"
for %%p in ("%PROJECT%") do set "PROJ_DIR=%%~dpp"
set "SCRIPT=%ROOT%auto_sequence_render.py"
set "CONFIG=%~1"
if "%CONFIG%"=="" set "CONFIG=sequence_jobs.json"
set "DIAG=%ROOT%run_auto_render_last.txt"
if not exist "%PROJECT%" goto :err_project
if not exist "%SCRIPT%" goto :err_script
rem --- Engine version from the .uproject (EngineAssociation) ---
set "UE_VER="
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -LiteralPath $env:PROJECT -Raw | ConvertFrom-Json).EngineAssociation"`) do set "UE_VER=%%v"
if "%UE_VER%"=="" set "UE_VER=5.8"
rem --- Find UnrealEditor.exe: env UE_EDITOR -> Epic Launcher install list -> common paths ---
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
set "UE_EDITOR="
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$f='C:\ProgramData\Epic\UnrealEngineLauncher\LauncherInstalled.dat'; if (Test-Path $f) { ((Get-Content $f -Raw | ConvertFrom-Json).InstallationList | Where-Object { $_.AppName -eq ('UE_' + $env:UE_VER) } | Select-Object -First 1).InstallLocation }"`) do set "UE_ROOT=%%i"
if defined UE_ROOT set "UE_EDITOR=%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe"
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
for %%d in ("C:\Program Files\Epic Games" "D:\Program Files\Epic Games" "E:\Program Files\Epic Games" "C:\Epic Games" "D:\Epic Games") do (
if exist "%%~d\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe" set "UE_EDITOR=%%~d\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe"
)
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
goto :err_ue
:found_ue
rem UE treats backslashes in -ExecutePythonScript as escapes (\U...), so pass forward slashes
set "SCRIPT_FS=%SCRIPT:\=/%"
set "CONFIG_FS=%CONFIG:\=/%"
set "PROJECT_FS=%PROJECT:\=/%"
> "%DIAG%" echo [run_auto_render] %DATE% %TIME%
>>"%DIAG%" echo UE_VER = %UE_VER%
>>"%DIAG%" echo UE_EDITOR = %UE_EDITOR%
>>"%DIAG%" echo PROJECT = %PROJECT_FS%
>>"%DIAG%" echo SCRIPT = %SCRIPT_FS%
>>"%DIAG%" echo CONFIG = %CONFIG_FS%
type "%DIAG%"
powershell -NoProfile -Command "if ($env:PROJECT -match '[^\x00-\x7F]') { exit 1 }"
if errorlevel 1 echo [WARN] The project path contains non-ASCII characters. If UE opens the Project Browser, move the folder to an ASCII-only path such as C:\UEWork\
rem -noxgeshadercompile: compile shaders locally (an idle IncrediBuild/XGE install can stall "Compiling Shaders")
echo Launching Unreal Editor... (render runs automatically, editor quits when done)
"%UE_EDITOR%" "%PROJECT_FS%" -ExecutePythonScript="%SCRIPT_FS% --config %CONFIG_FS% --quit" -noxgeshadercompile -log
echo Done. Output: %PROJ_DIR%Saved\AutoRender
goto :eof
:err_project
echo [ERROR] .uproject not found. Keep this folder layout:
echo ^<folder^>\run_auto_render.bat
echo ^<folder^>\auto_sequence_render.py
echo ^<folder^>\sequence_jobs.json
echo ^<folder^>\UE583SequenceRender\UE583SequenceRender.uproject
echo Looked for: "%PROJECT%"
echo Or set USER_PROJECT at the top of this bat.
pause
exit /b 1
:err_script
echo [ERROR] auto_sequence_render.py not found next to this bat: "%SCRIPT%"
pause
exit /b 1
:err_ue
echo [ERROR] UnrealEditor.exe for UE %UE_VER% not found.
echo Set it manually, e.g.: set "UE_EDITOR=C:\Program Files\Epic Games\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe"
pause
exit /b 1“H:\SandBox\UE583SequenceRender\auto_sequence_render.py”
# -*- coding: utf-8 -*-
"""
UE 5.8 自動シーケンサー作成 + キャラクター自動レンダリング
使い方:
(A) エディタ内: Output Log の Python で
import auto_sequence_render as asr; asr.main()
または py "H:/SandBox/UE583SequenceRender/auto_sequence_render.py" --config sequence_jobs.json
(B) バッチ起動: run_auto_render.bat (エディタを起動 → 生成 → レンダー → 終了)
(C) 選択して撮影: コンテンツブラウザで BP_ThirdPersonCharacter 等 (+任意で AnimSequence) を選択、
またはレベル上のキャラを選択 → 右クリック「自動撮影」 (init_unreal.py がメニュー登録)
import auto_sequence_render as asr; asr.run_selected()
引数:
--config <path> ジョブ定義 JSON (省略時はこの .py と同じフォルダの sequence_jobs.json, 無ければ DEFAULT_CONFIG)
--no-render シーケンス生成のみ
--quit レンダー完了後にエディタを終了
"""
import json
import glob
import math
import os
import re
import shutil
import subprocess
import sys
import unreal
# ---------------------------------------------------------------------------
# デフォルト設定 (JSON が無い場合に使用)
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = {
"map": "/Game/ThirdPerson/Lvl_ThirdPerson",
"sequence_dir": "/Game/AutoSequences",
"output_dir": "{project_dir}/Saved/AutoRender",
"fps": 30,
"resolution": [1920, 1080],
"output_format": "png", # png / jpg / exr / bmp
# レンダー後に ffmpeg で連番 → MP4 (H.264) に変換
"mp4": {
"enabled": True,
"ffmpeg": "", # 空なら PATH / 既知の場所から自動検出
"codec": "libx264",
"crf": 18, # 小さいほど高画質 (18 ≒ 見た目ロスレス)
"preset": "medium",
"keep_images": True, # False なら変換成功後に連番画像を削除
},
"spatial_samples": 1,
"temporal_samples": 1,
"warmup_frames": 10,
"jobs": [
{
"name": "SEQ_Manny_Attack",
"characters": [
{
"mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_02",
],
"location": [0, 0, 0],
"yaw": 0,
}
],
"camera": {"type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8,
"radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35},
},
],
# 選択撮影 (run_selected) 用テンプレート。アニメを選択していればそちらを優先
"selection_job": {
"name_prefix": "SEQ_Auto_",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
],
"duration_frames_without_anims": 150,
"camera": {"type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8,
"radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35},
},
}
_RENDER_STATE = {} # executor を GC から守るためのグローバル参照
# ---------------------------------------------------------------------------
# ユーティリティ
# ---------------------------------------------------------------------------
def log(msg):
unreal.log("[AutoSeq] " + str(msg))
def warn(msg):
unreal.log_warning("[AutoSeq] " + str(msg))
def project_dir():
return os.path.abspath(unreal.Paths.project_dir())
def load_asset(path):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError("アセットが見つかりません: " + path)
return asset
def vec(v):
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def parse_args(argv):
args = {"config": None, "render": True, "quit": False, "selected": False}
i = 0
while i < len(argv):
a = argv[i]
if a == "--config" and i + 1 < len(argv):
args["config"] = argv[i + 1]
i += 1
elif a == "--no-render":
args["render"] = False
elif a == "--quit":
args["quit"] = True
elif a == "--selected":
args["selected"] = True
i += 1
return args
def load_config(path=None):
if path is None:
candidate = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sequence_jobs.json")
path = candidate if os.path.exists(candidate) else None
if path is None:
log("JSON が無いため DEFAULT_CONFIG を使用")
return dict(DEFAULT_CONFIG)
if not os.path.isabs(path):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
merged = dict(DEFAULT_CONFIG)
merged.update(cfg)
log("設定読み込み: " + path)
return merged
def anim_length_seconds(anim):
try:
return float(anim.get_play_length())
except Exception:
return float(unreal.AnimationLibrary.get_sequence_length(anim))
def get_channels(section):
try:
return section.get_all_channels()
except AttributeError:
return section.get_channels()
def binding_id(sequence, binding):
try:
return unreal.MovieSceneSequenceExtensions.get_portable_binding_id(sequence, sequence, binding)
except Exception:
return sequence.get_binding_id(binding)
# ---------------------------------------------------------------------------
# シーケンス生成
# ---------------------------------------------------------------------------
def create_sequence_asset(seq_dir, name):
full = "{}/{}".format(seq_dir, name)
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
seq = tools.create_asset(name, seq_dir, unreal.LevelSequence, unreal.LevelSequenceFactoryNew())
if seq is None:
raise RuntimeError("LevelSequence 作成失敗: " + full)
return seq
def add_spawnable(sequence, actor):
"""UE5.5+ はシーケンスを Sequencer で開いた状態で Subsystem 経由で追加する。"""
binding = None
try:
subsystem = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
binding = subsystem.add_spawnable_from_instance(sequence, actor)
except Exception as e:
warn("Subsystem で spawnable 追加失敗: {}".format(e))
if binding is None or not binding.is_valid():
binding = sequence.add_spawnable_from_instance(actor) # 旧 API フォールバック
remove_auto_tracks(binding)
return binding
def remove_auto_tracks(binding):
"""Sequencer がバインド時に自動追加する空の Transform トラックを削除。
残すと自前の Transform トラックと平均ブレンドされ、位置・向きが半分になる。"""
for t in list(binding.find_tracks_by_type(unreal.MovieScene3DTransformTrack)):
binding.remove_track(t)
def cleanup_auto_bindings(sequence):
"""UE 5.8 が SkeletalMesh に自動追加する FK Control Rig トラックを削除し、空になった子バインドも消す。"""
cr_cls = getattr(unreal, "MovieSceneControlRigParameterTrack", None)
if cr_cls is None:
return
# BP はメッシュの子バインド、SkeletalMeshActor はアクターのバインド自体に付くので全バインドを見る
for b in list(sequence.get_bindings()):
for t in list(b.find_tracks_by_type(cr_cls)):
b.remove_track(t)
log(" 自動追加の Control Rig トラックを削除: {}".format(b.get_display_name()))
for b in list(sequence.get_possessables()):
if not b.get_tracks() and not b.get_child_possessables():
b.remove()
def add_transform_keys(binding, keys, linear=False):
"""keys: [(frame, location(Vector), rotation(Rotator)), ...]
linear=True で等速 (カメラの周回用)"""
track = binding.add_track(unreal.MovieScene3DTransformTrack)
section = track.add_section()
section.set_range(keys[0][0], keys[-1][0] + 1)
ch = get_channels(section)
interp = unreal.MovieSceneKeyInterpolation.LINEAR if linear else unreal.MovieSceneKeyInterpolation.AUTO
for frame, loc, rot in keys:
fn = unreal.FrameNumber(int(frame))
for idx, val in ((0, loc.x), (1, loc.y), (2, loc.z),
(3, rot.roll), (4, rot.pitch), (5, rot.yaw)):
ch[idx].add_key(fn, float(val), 0.0, unreal.MovieSceneTimeUnit.DISPLAY_RATE, interp)
return section
def capsule_half_height(actor):
comp = actor.get_component_by_class(unreal.CapsuleComponent)
return float(comp.get_scaled_capsule_half_height()) if comp else 0.0
def character_height(actor):
"""キャラの身長 (cm)。Character はカプセル、それ以外は SkeletalMesh のバウンズから。"""
hh = capsule_half_height(actor)
if hh > 0:
return hh * 2.0
comp = actor.get_component_by_class(unreal.SkeletalMeshComponent)
if comp:
try:
bmin, bmax = comp.get_local_bounds()
return float(bmax.z - bmin.z) * float(comp.get_world_scale().z)
except Exception:
pass
return 180.0
def facing_yaw(actor):
"""キャラ正面の yaw。Mannequin メッシュ単体は +Y 向きなので +90。"""
yaw = actor.get_actor_rotation().yaw
return yaw + 90.0 if isinstance(actor, unreal.SkeletalMeshActor) else yaw
def snap_to_floor(feet, up=500.0, down=5000.0):
"""足元 XY の真下の床に Z を合わせる (床や台の中に埋まらないように)。"""
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
start = unreal.Vector(feet.x, feet.y, feet.z + up)
end = unreal.Vector(feet.x, feet.y, feet.z - down)
try:
trace = unreal.TraceTypeQuery.ECC_VISIBILITY
except AttributeError:
trace = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1
hit = unreal.SystemLibrary.line_trace_single(world, start, end, trace, False, [],
unreal.DrawDebugTrace.NONE, True)
if hit:
z = hit.to_tuple()[4].z
if abs(z - feet.z) > 1.0:
log(" 床にスナップ: Z {:.0f} -> {:.0f}".format(feet.z, z))
return unreal.Vector(feet.x, feet.y, z)
warn(" 床が見つかりません (Z はそのまま): {}".format(feet))
return feet
def add_character(sequence, actor_sub, char_cfg, fps, fill_frames=0):
"""char_cfg のキャラ指定:
"actor": レベル上の Actor (possessable として使用)
"blueprint": BP パス (spawnable)
"mesh": SkeletalMesh パス (SkeletalMeshActor の spawnable)
"location" は足元の位置。
戻り値: (終了フレーム, 足元位置, 正面 yaw, 身長)
"""
if char_cfg.get("actor") is not None:
actor = char_cfg["actor"]
binding = sequence.add_possessable(actor)
binding.set_display_name(actor.get_actor_label())
remove_auto_tracks(binding)
else:
if char_cfg.get("location") in (None, "auto"):
spot, ps_yaw = _player_start_spot(actor_sub)
feet = vec(spot)
yaw = float(char_cfg.get("yaw", ps_yaw))
else:
feet = vec(char_cfg["location"])
yaw = float(char_cfg.get("yaw", 0))
if char_cfg.get("snap_to_floor", True):
feet = snap_to_floor(feet)
rot = unreal.Rotator(0.0, 0.0, yaw)
if char_cfg.get("blueprint"):
cls = unreal.EditorAssetLibrary.load_blueprint_class(char_cfg["blueprint"])
if cls is None:
raise RuntimeError("Blueprint クラスを読み込めません: " + char_cfg["blueprint"])
actor = actor_sub.spawn_actor_from_class(cls, feet, rot)
hh = capsule_half_height(actor)
if hh > 0: # Character はカプセル中心が原点なので持ち上げる
actor.set_actor_location(feet + unreal.Vector(0, 0, hh), False, False)
name = cls.get_name().replace("_C", "")
else:
mesh = load_asset(char_cfg["mesh"])
actor = actor_sub.spawn_actor_from_class(unreal.SkeletalMeshActor, feet, rot)
comp = actor.skeletal_mesh_component
try:
comp.set_skeletal_mesh_asset(mesh)
except AttributeError:
comp.set_skeletal_mesh(mesh)
name = mesh.get_name()
binding = add_spawnable(sequence, actor)
binding.set_display_name(name)
loc = actor.get_actor_location()
rot = actor.get_actor_rotation()
feet = loc - unreal.Vector(0, 0, capsule_half_height(actor))
face = facing_yaw(actor)
height = character_height(actor)
if char_cfg.get("actor") is None:
actor_sub.destroy_actor(actor)
# アニメーションを順番に並べる (Force Custom Mode で AnimBP を上書き)
# fill_frames より短ければアニメ列をループして埋める (途中で T ポーズにならないように)
cursor = 0
anims = [load_asset(a) if isinstance(a, str) else a for a in char_cfg.get("anims", [])]
if anims:
anim_track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
playlist = list(anims)
total = sum(max(1, int(round(anim_length_seconds(a) * fps))) for a in anims)
while fill_frames and total < fill_frames:
playlist += anims
total += sum(max(1, int(round(anim_length_seconds(a) * fps))) for a in anims)
for anim in playlist:
frames = max(1, int(round(anim_length_seconds(anim) * fps)))
if fill_frames and len(playlist) > len(anims):
if cursor >= fill_frames:
break
frames = min(frames, fill_frames - cursor)
section = anim_track.add_section()
section.set_range(cursor, cursor + frames)
params = section.get_editor_property("params")
params.set_editor_property("animation", anim)
params.set_editor_property("force_custom_mode", bool(char_cfg.get("force_custom_mode", True)))
section.set_editor_property("params", params)
log(" anim {} : {} - {}".format(anim.get_name(), cursor, cursor + frames))
cursor += frames
end = max(cursor, int(char_cfg.get("duration_frames", 1)), fill_frames)
add_transform_keys(binding, [(0, loc, rot), (end - 1, loc, rot)])
return end, feet, face, height
def look_at(cam_loc, target):
d = target - cam_loc
horiz = math.sqrt(d.x * d.x + d.y * d.y)
yaw = math.degrees(math.atan2(d.y, d.x))
pitch = math.degrees(math.atan2(d.z, horiz))
return unreal.Rotator(0.0, pitch, yaw) # (roll, pitch, yaw)
def cine_half_tans(cine):
"""シネカメラの (水平, 垂直) 半画角の tan をフィルムバックと焦点距離から求める。"""
try:
fb = cine.get_editor_property("filmback")
sensor_w = float(fb.get_editor_property("sensor_width"))
sensor_h = float(fb.get_editor_property("sensor_height"))
except Exception:
sensor_w, sensor_h = 23.76, 13.365 # 16:9 DSLR 既定
focal = float(cine.get_editor_property("current_focal_length"))
return (sensor_w * 0.5) / focal, (sensor_h * 0.5) / focal
def fit_distance(tan_h, tan_v, subject_height, subject_spread, margin):
"""全身 (身長 × margin) と横幅が画面に収まるカメラ距離を画角から算出。"""
# 被写体の奥行き (周回中に手前へ来る分) を足しておく
d_vertical = (subject_height * margin * 0.5) / tan_v + subject_spread
d_horizontal = (subject_spread * margin) / tan_h + subject_spread
return max(d_vertical, d_horizontal)
def add_camera(sequence, actor_sub, cam_cfg, center, end_frame, base_yaw=0.0,
subject_height=180.0, subject_spread=40.0, aspect=16.0 / 9.0):
"""center: 足元中心。キャラの全身を中心に捉えて周回する。
cam_cfg["camera_actor"]: "cine" (CineCameraActor, 既定) / "camera" (通常の CameraActor)"""
kind = str(cam_cfg.get("camera_actor", "cine")).lower()
margin = float(cam_cfg.get("framing_margin", 1.15))
if kind == "camera":
# 通常カメラ: 水平 FOV 指定。被写界深度 (ボケ) なし
cam_actor = actor_sub.spawn_actor_from_class(unreal.CameraActor, center, unreal.Rotator())
comp = cam_actor.get_component_by_class(unreal.CameraComponent)
fov = float(cam_cfg.get("fov", 60))
comp.set_editor_property("field_of_view", fov)
tan_h = math.tan(math.radians(fov) * 0.5)
tan_v = tan_h / aspect
else:
cam_actor = actor_sub.spawn_actor_from_class(unreal.CineCameraActor, center, unreal.Rotator())
comp = cam_actor.get_cine_camera_component()
comp.set_editor_property("current_focal_length", float(cam_cfg.get("focal_length", 35)))
tan_h, tan_v = cine_half_tans(comp)
radius_cfg = cam_cfg.get("radius", "auto")
if radius_cfg in (None, "auto"):
radius = fit_distance(tan_h, tan_v, subject_height, subject_spread, margin)
else:
radius = float(radius_cfg)
if kind != "camera":
focus = comp.get_editor_property("focus_settings")
if cam_cfg.get("depth_of_field", True):
# キャラにピントを合わせ、背景をぼかす
focus.set_editor_property("focus_method", unreal.CameraFocusMethod.MANUAL)
focus.set_editor_property("manual_focus_distance", radius)
if cam_cfg.get("aperture") is not None:
comp.set_editor_property("current_aperture", float(cam_cfg["aperture"]))
else:
focus.set_editor_property("focus_method", unreal.CameraFocusMethod.DISABLE)
comp.set_editor_property("focus_settings", focus)
binding = add_spawnable(sequence, cam_actor)
actor_sub.destroy_actor(cam_actor)
binding.set_display_name("AutoCamera")
log(" camera actor: {}".format("CameraActor" if kind == "camera" else "CineCameraActor"))
# 注視点 = 体の中心 (身長の半分) → 頭からつま先までが画面中央に入る
target = center + unreal.Vector(0, 0, subject_height * float(cam_cfg.get("target_ratio", 0.5)))
elev = math.radians(float(cam_cfg.get("elevation", 5))) # 少し見下ろす角度
horiz = radius * math.cos(elev)
cam_z = radius * math.sin(elev)
# 0 度 = キャラの正面 (base_yaw 方向) から撮る
start = base_yaw + float(cam_cfg.get("start_angle", 0))
sweep = float(cam_cfg.get("sweep", 360)) if cam_cfg.get("type", "orbit") == "orbit" else 0.0
log(" camera: 距離 {:.0f}cm 身長 {:.0f}cm 回転 {:.0f}°".format(radius, subject_height, sweep))
# 10 度刻みのリニアキーで等速回転
key_count = max(2, int(math.ceil(abs(sweep) / 10.0)) + 1)
keys = []
prev_yaw = None
for i in range(key_count):
t = i / float(key_count - 1)
ang = math.radians(start + sweep * t)
pos = target + unreal.Vector(math.cos(ang) * horiz, math.sin(ang) * horiz, cam_z)
rot = look_at(pos, target)
# yaw の ±180 跨ぎで回転が飛ばないよう連続化
if prev_yaw is not None:
while rot.yaw - prev_yaw > 180:
rot.yaw -= 360
while rot.yaw - prev_yaw < -180:
rot.yaw += 360
prev_yaw = rot.yaw
keys.append((int(round(t * (end_frame - 1))), pos, rot))
add_transform_keys(binding, keys, linear=True)
cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack)
cut = cut_track.add_section()
cut.set_range(0, end_frame)
cut.set_camera_binding_id(binding_id(sequence, binding))
def build_sequence(cfg, job):
fps = int(cfg["fps"])
seq = create_sequence_asset(cfg["sequence_dir"], job["name"])
seq.set_display_rate(unreal.FrameRate(fps, 1))
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(seq)
actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
end_frame = 1
centers = []
heights = []
base_yaw = None
# 1 周にかける最低秒数 (アニメが短くても回転が速すぎないように)
fill = int(float(job.get("camera", {}).get("orbit_seconds", 0)) * fps)
for char_cfg in job["characters"]:
e, feet, face, h = add_character(seq, actor_sub, char_cfg, fps, fill_frames=fill)
end_frame = max(end_frame, e)
centers.append(feet)
heights.append(h)
if base_yaw is None:
base_yaw = face
if job.get("duration_frames"):
end_frame = int(job["duration_frames"])
center = unreal.Vector(sum(c.x for c in centers) / len(centers),
sum(c.y for c in centers) / len(centers),
sum(c.z for c in centers) / len(centers))
# 横幅 = 中心から一番遠いキャラまでの距離 + 体の半径 (腕を広げても収まる程度)
spread = max(math.hypot(c.x - center.x, c.y - center.y) for c in centers) + 60.0
add_camera(seq, actor_sub, job.get("camera", {}), center, end_frame, base_yaw or 0.0,
subject_height=max(heights), subject_spread=spread,
aspect=float(cfg["resolution"][0]) / float(cfg["resolution"][1]))
seq.set_playback_start(0)
seq.set_playback_end(end_frame)
try:
seq.set_view_range_start(0)
seq.set_view_range_end(end_frame / float(fps))
seq.set_work_range_start(0)
seq.set_work_range_end(end_frame / float(fps))
except Exception:
pass
cleanup_auto_bindings(seq)
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
unreal.EditorAssetLibrary.save_loaded_asset(seq)
log("シーケンス作成: {} ({} frames @ {}fps)".format(seq.get_path_name(), end_frame, fps))
return seq
# ---------------------------------------------------------------------------
# Movie Render Queue
# ---------------------------------------------------------------------------
_FORMATS = {
"png": "MoviePipelineImageSequenceOutput_PNG",
"jpg": "MoviePipelineImageSequenceOutput_JPG",
"exr": "MoviePipelineImageSequenceOutput_EXR",
"bmp": "MoviePipelineImageSequenceOutput_BMP",
}
_MP4_DEFAULTS = DEFAULT_CONFIG["mp4"]
def mp4_settings(cfg):
s = dict(_MP4_DEFAULTS)
s.update(cfg.get("mp4") or {})
return s
def find_ffmpeg(cfg):
path = mp4_settings(cfg).get("ffmpeg")
if path and os.path.exists(path):
return path
found = shutil.which("ffmpeg")
if found:
return found
for p in (r"C:\python3.11.0\ffmpeg.exe", r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
if os.path.exists(p):
return p
return None
def encode_mp4(cfg, seq_name, image_dir, ext, out_path):
"""{seq_name}.{0000}.{ext} の連番を MP4 に変換。成功で True。"""
ffmpeg = find_ffmpeg(cfg)
if not ffmpeg:
warn("ffmpeg が見つかりません。config の mp4.ffmpeg にパスを指定してください")
return False
pat = re.compile(re.escape(seq_name) + r"\.(\d+)\." + ext + "$", re.IGNORECASE)
files = sorted(f for f in glob.glob(os.path.join(image_dir, "*." + ext)) if pat.search(os.path.basename(f)))
if not files:
warn("連番画像が見つかりません: {}".format(image_dir))
return False
first = pat.search(os.path.basename(files[0])).group(1)
s = mp4_settings(cfg)
cmd = [
ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
"-framerate", str(int(cfg["fps"])),
"-start_number", str(int(first)),
"-i", os.path.join(image_dir, "{}.%0{}d.{}".format(seq_name, len(first), ext)),
"-c:v", s["codec"], "-crf", str(s["crf"]), "-preset", s["preset"],
"-pix_fmt", "yuv420p",
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", # yuv420p は偶数解像度が必要
"-movflags", "+faststart",
out_path,
]
log("MP4 変換: " + " ".join('"{}"'.format(c) if " " in c else c for c in cmd))
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
r = subprocess.run(cmd, capture_output=True, text=True, creationflags=flags)
if r.returncode != 0:
warn("ffmpeg 失敗 ({}): {}".format(r.returncode, r.stderr.strip()))
return False
log("MP4 出力: {} ({} frames)".format(out_path, len(files)))
if not s.get("keep_images", True):
for f in files:
os.remove(f)
return True
def set_keep_alive(value):
try:
unreal.EditorPythonScripting.set_keep_python_script_alive(bool(value))
except Exception:
pass
def queue_render(cfg, sequences, quit_after=False, map_path=None):
mrq = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
if mrq.is_rendering():
warn("既にレンダー中のためスキップします")
return
queue = mrq.get_queue()
queue.delete_all_jobs()
map_soft = unreal.SoftObjectPath(map_path or cfg["map"])
out_root = cfg["output_dir"].replace("{project_dir}", project_dir().replace("\\", "/"))
fmt = cfg.get("output_format", "png")
fmt_cls = getattr(unreal, _FORMATS.get(fmt, _FORMATS["png"]))
want_mp4 = bool(mp4_settings(cfg).get("enabled"))
# EXR は ffmpeg 入力に向かないので、MP4 用に PNG も併せて出力
mp4_ext = fmt if fmt in ("png", "jpg", "bmp") else "png"
encode_list = []
for seq in sequences:
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
job.job_name = seq.get_name()
job.sequence = unreal.SoftObjectPath(seq.get_path_name())
job.map = map_soft
conf = job.get_configuration()
out = conf.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
out.output_directory = unreal.DirectoryPath("{}/{}".format(out_root, seq.get_name()))
out.output_resolution = unreal.IntPoint(int(cfg["resolution"][0]), int(cfg["resolution"][1]))
out.file_name_format = "{sequence_name}.{frame_number}"
out.zero_pad_frame_numbers = 4
conf.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
conf.find_or_add_setting_by_class(fmt_cls)
if want_mp4 and mp4_ext != fmt:
conf.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
if want_mp4:
encode_list.append((seq.get_name(), out.output_directory.path))
# GameMode を MRQ 用 (既定: MoviePipelineGameMode) に上書き
# → プレイヤーポーン (BP_ThirdPersonCharacter) や HUD が映り込まない
conf.find_or_add_setting_by_class(unreal.MoviePipelineGameOverrideSetting)
aa = conf.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
aa.spatial_sample_count = int(cfg.get("spatial_samples", 1))
aa.temporal_sample_count = int(cfg.get("temporal_samples", 1))
aa.engine_warm_up_count = int(cfg.get("warmup_frames", 10))
aa.render_warm_up_frames = True
log("レンダージョブ追加: {} -> {}".format(seq.get_name(), out.output_directory.path))
def _on_finished(executor, success):
log("レンダー完了 success={} 出力: {}".format(success, out_root))
_RENDER_STATE.clear()
if success and encode_list:
with unreal.ScopedSlowTask(len(encode_list), "MP4 に変換中...") as task:
task.make_dialog()
for name, img_dir in encode_list:
task.enter_progress_frame(1, "MP4 変換: " + name)
encode_mp4(cfg, name, img_dir, mp4_ext, "{}/{}.mp4".format(out_root, name))
set_keep_alive(False)
if quit_after:
unreal.SystemLibrary.quit_editor()
executor = mrq.render_queue_with_executor(unreal.MoviePipelinePIEExecutor)
executor.on_executor_finished_delegate.add_callable_unique(_on_finished)
_RENDER_STATE["executor"] = executor
# -ExecutePythonScript 起動時は、スクリプトが返った次のフレームでエディタが終了してしまう。
# レンダー (非同期) が終わるまでエディタを生かしておく。
set_keep_alive(True)
log("レンダー開始 ({} jobs)".format(len(sequences)))
# ---------------------------------------------------------------------------
# 選択から自動撮影
# ---------------------------------------------------------------------------
def _editor_world():
return unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
def _player_start_spot(actor_sub):
"""PlayerStart の足元位置と yaw。無ければ原点。"""
for a in actor_sub.get_all_level_actors():
if isinstance(a, unreal.PlayerStart):
feet = a.get_actor_location() - unreal.Vector(0, 0, capsule_half_height(a))
return [feet.x, feet.y, feet.z], a.get_actor_rotation().yaw
return [0, 0, 0], 0.0
def collect_selection(cfg):
"""レベル選択アクター → possessable、コンテンツブラウザの BP / SkeletalMesh → spawnable。
同時に選択された AnimSequence はアニメとして使う。"""
actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
targets = []
anims = []
for asset in unreal.EditorUtilityLibrary.get_selected_assets():
if isinstance(asset, unreal.AnimSequence):
anims.append(asset)
elif isinstance(asset, unreal.Blueprint):
targets.append({"blueprint": asset.get_path_name().split(".")[0], "_name": asset.get_name()})
elif isinstance(asset, unreal.SkeletalMesh):
targets.append({"mesh": asset.get_path_name().split(".")[0], "_name": asset.get_name()})
if not targets:
for a in actor_sub.get_selected_level_actors():
if a.get_component_by_class(unreal.SkeletalMeshComponent):
targets.append({"actor": a, "_name": a.get_actor_label()})
if targets and any("actor" not in t for t in targets):
spot, yaw = _player_start_spot(actor_sub)
for t in targets:
if "actor" not in t:
t["location"] = cfg["selection_job"].get("location", spot)
t["yaw"] = cfg["selection_job"].get("yaw", yaw)
return targets, anims
def run_selected(config_path=None, render=True):
cfg = load_config(config_path)
sel_cfg = dict(DEFAULT_CONFIG["selection_job"])
sel_cfg.update(cfg.get("selection_job", {}))
cfg["selection_job"] = sel_cfg
targets, anims = collect_selection(cfg)
if not targets:
unreal.EditorDialog.show_message(
"自動撮影", "キャラクター (Blueprint / SkeletalMesh / レベル上のアクター) を選択してください。",
unreal.AppMsgType.OK)
return []
anim_list = anims or sel_cfg.get("anims", [])
jobs = []
for t in targets:
char = {k: v for k, v in t.items() if not k.startswith("_")}
char["anims"] = anim_list
if not anim_list:
char["duration_frames"] = int(sel_cfg.get("duration_frames_without_anims", 150))
jobs.append({
"name": sel_cfg.get("name_prefix", "SEQ_Auto_") + t["_name"],
"characters": [char],
"camera": sel_cfg.get("camera", {}),
})
world = _editor_world()
sequences = [build_sequence(cfg, job) for job in jobs]
if render:
queue_render(cfg, sequences, map_path=world.get_path_name())
return sequences
# ---------------------------------------------------------------------------
def main(argv=None):
args = parse_args(sys.argv[1:] if argv is None else argv)
if args["selected"]:
return run_selected(args["config"], args["render"])
cfg = load_config(args["config"])
level_sub = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
level_sub.load_level(cfg["map"])
sequences = [build_sequence(cfg, job) for job in cfg["jobs"]]
if args["render"]:
queue_render(cfg, sequences, quit_after=args["quit"])
elif args["quit"]:
unreal.SystemLibrary.quit_editor()
return sequences
if __name__ == "__main__":
main()
“H:\SandBox\UE583SequenceRender\sequence_jobs.json”
{
"map": "/Game/ThirdPerson/Lvl_ThirdPerson",
"sequence_dir": "/Game/AutoSequences",
"output_dir": "{project_dir}/Saved/AutoRender",
"fps": 30,
"resolution": [
1920,
1080
],
"output_format": "png",
"mp4": {
"enabled": true,
"ffmpeg": "",
"codec": "libx264",
"crf": 18,
"preset": "medium",
"keep_images": true
},
"spatial_samples": 1,
"temporal_samples": 1,
"warmup_frames": 10,
"selection_job": {
"name_prefix": "SEQ_Auto_",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01"
],
"duration_frames_without_anims": 150,
"camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
},
"jobs": [
{
"name": "SEQ_BP_ThirdPerson",
"characters": [
{
"blueprint": "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
"/Game/Characters/Mannequins/Anims/Unarmed/Jump/MM_Dash"
],
"location": [0, 0, 0],
"yaw": 0
}
],
"camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
},
{
"name": "SEQ_Manny_Attack",
"characters": [
{
"mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_02",
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_03"
],
"location": [0, 0, 0],
"yaw": 0
}
],
"camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
},
{
"name": "SEQ_Duo_Reaction",
"characters": [
{
"mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
"anims": [
"/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_ChargedAttack"
],
"location": [0, -80, 0],
"yaw": 90
},
{
"mesh": "/Game/Characters/Mannequins/Meshes/SKM_Quinn_Simple",
"anims": [
"/Game/Characters/Mannequins/Anims/Rifle/HitReact/MM_HitReact_Front_Hvy_01",
"/Game/Characters/Mannequins/Anims/Death/MM_Death_Back_01"
],
"location": [0, 80, 0],
"yaw": -90
}
],
"camera": { "camera_actor": "cine", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
}
]
}
“H:\SandBox\UE583SequenceRender\UE583SequenceRender\Content\Python\init_unreal.py”
# -*- coding: utf-8 -*-
# エディタ起動時に自動実行され、「自動撮影」メニューを登録する。
# auto_sequence_render.py / sequence_jobs.json はプロジェクトの 1 つ上 (run_auto_render.bat と同じフォルダ) に置く。
import os
import sys
import unreal
TOOL_DIR = os.path.abspath(os.path.join(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()), ".."))
if TOOL_DIR not in sys.path:
sys.path.append(TOOL_DIR)
_COMMAND = (
"import importlib, auto_sequence_render as asr; "
"importlib.reload(asr); asr.run_selected()"
)
_MENUS = [
"ContentBrowser.AssetContextMenu", # コンテンツブラウザでアセットを右クリック
"LevelEditor.ActorContextMenu", # レベル上のアクターを右クリック
"LevelEditor.MainMenu.Tools", # メインメニュー Tools
]
def register_menus():
menus = unreal.ToolMenus.get()
for menu_name in _MENUS:
menu = menus.extend_menu(menu_name)
if menu is None:
continue
menu.add_section("AutoSequenceRender", "Auto Sequence Render")
entry = unreal.ToolMenuEntry(
name="AutoSequenceRender_Selected",
type=unreal.MultiBlockType.MENU_ENTRY,
)
entry.set_label("自動撮影 (シーケンス作成 + レンダー)")
entry.set_tool_tip("選択中のキャラクター (BP / SkeletalMesh / レベルのアクター) でシーケンスを作成し、Movie Render Queue でレンダーします。AnimSequence も選択するとそのアニメを使用します。")
entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, "", _COMMAND)
menu.add_menu_entry("AutoSequenceRender", entry)
menus.refresh_all_widgets()
unreal.log("[AutoSeq] 自動撮影メニューを登録しました (スクリプト: {})".format(TOOL_DIR))
register_menus()
以下のものが必要です。
| 部品 | 役割 | ライセンス |
|---|---|---|
unreal モジュール(UE 5.8 に標準で入っている Python API) | シーケンスの作成、キャラやカメラの配置、レンダー | Unreal Engine EULA |
| ├ PythonScriptPlugin | UE で Python を動かす | 同上(UE 同梱) |
| ├ SequencerScripting | シーケンサーの操作 | 同上 |
| └ MovieRenderPipeline | Movie Render Queue でのレンダー | 同上 |
| Python 標準ライブラリ(json, math, os, re, glob, shutil, subprocess) | 設定の読み込み、計算、ファイル操作 | PSF ライセンス(商用利用 OK) |
| ffmpeg(C:\python3.11.0\ffmpeg.exe) | PNG 連番を MP4 に変換 | GPL v3(gyan.dev のビルドで、libx264 を含む) |
https://www.gyan.dev/ffmpeg/builds
「release builds」の中にある ffmpeg-release-essentials.zip を選んでください。
https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip





















































































































