Files
gig-line/Tools/unreal/ue_build_review_map.py
zwitschi ff9eb0a4ad fix(hero): the albedo renders - two blockers, both at the map level
The generated ashlar albedo never showed for two reasons that had nothing to do with the material graph,
which was verified correct the whole time (TextureSampleParameter2D -> Multiply -> Base Color, colour sampler,
texture set, 6x UV tiling):

1. **The map overrode every hero slot.** The generic per-slot material pass in ue_build_demo_map.py assigned
   the facade KIT's flat stone to every actor whose slot names looked like stone, banner, glass - including
   Hero_Facade. Whatever the import authored was replaced at map build time. The hero is now excluded and gets
   its own materials.
2. **Mesh slot assignment does not persist.** Assigning to mesh.get_editor_property('static_materials') returns a
   copy; after a reload the mesh still pointed at the original imported materials (measured twice). Component
   level overrides do persist in the saved map, so apply_hero_materials() sets them in both builders.

The elevation now renders as coursed, grainy warm limestone with banners, window bays and recessed portals.

Rows 4-6 re-rendered and pending review; the comparison plate is regenerated. Still missing against the
reference: carved inscription, medallion relief, banner lettering, crimson banner tone. The two-model judge
returns no/no (0.90/0.95) - it wants the text and detail that are still absent, which is at least the right
complaint this time.
2026-11-10 13:57:57 +01:00

476 lines
26 KiB
Python

# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
# map asset. Run before the screenshot pass (Tools/review/render_screenshots.sh does
# this automatically).
#
# Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character,
# the three 1 m scale cubes, and an AGigLineShotActor with the camera list.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/unreal/ue_build_review_map.py \
# -nullrhi -unattended -nopause -stdout
#
# Do NOT load_map here: after a map load the commandlet's spawn context dies
# (every spawn_actor call returns None). Everything is spawned into the
# default world and saved as a new map asset.
import os
import unreal
RESULT = "/tmp/ue-review-map-result.txt"
MAP_PATH = "/Game/Test/ReviewMap"
lines = []
# EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi
# commandlets (they return without switching worlds, so staged actors land in
# whatever world is current and get saved over the wrong map). The
# LevelEditorSubsystem actually switches.
LES = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
def switch_level(path):
return LES.load_level(path)
def log(msg):
lines.append(str(msg))
def apply_hero_materials(actor):
"""Point the hero's component slots at the authored *_Ref materials, if they exist.
The mesh's own slot list cannot be relied on: `get_editor_property("static_materials")` returns a copy, so
assigning to it does not persist across a reload. A component-level override does persist in the saved map,
and it is what the captures actually render.
"""
if actor is None or actor.get_actor_label() != "Hero_Facade":
return 0
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
slots = comp.static_mesh.get_editor_property("static_materials")
applied = 0
for index, slot in enumerate(slots):
current = slot.get_editor_property("material_interface")
if not current:
continue
ref = unreal.EditorAssetLibrary.load_asset(
f"/Game/Hollywood/ElCapitan/{current.get_name()}_Ref")
if ref:
comp.set_material(index, ref)
applied += 1
if applied:
log(f"hero materials: {applied}/{len(slots)} slots use the authored *_Ref materials")
return applied
def ensure_writable(path):
# A packed .umap left read-only (git sets 644->444 on checkout, or the
# editor re-flags it) makes save_map silently return False with no error.
# chmod u+w it so the staged camera list and actors actually persist.
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/") + ".umap")
try:
mode = os.stat(p).st_mode & 0o777
if mode & 0o200 == 0:
os.chmod(p, 0o644)
log(f"map writable: chmod u+w {p} (was {oct(mode)})")
except FileNotFoundError:
pass
def clear_level():
"""Destroy every actor before staging.
Without this the review map accumulated whatever previous runs and experiments left behind, and the
captures showed a scene nobody staged. The map is rebuilt from empty on every run.
"""
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
except Exception: # noqa: BLE001
pass
log(f"cleared {removed} actors before staging")
return removed
def sit_on_floor(actor, floor_z=2.0):
"""Shift an actor so the mesh's lowest point lands on `floor_z`.
Staging heights were hand-typed and every one of them was a little wrong - the mannequin's root
is not at the soles, the scale cube's origin is not at its base - which is what the review keeps
catching. Measuring the bounds and correcting is the only version that stays right when an
asset changes. The default 2 cm keeps soles and tyres on TOP of the floor plane rather than
exactly level with it, where they read as sunk.
"""
try:
origin, box = actor.get_actor_bounds(False)
except Exception: # noqa: BLE001
return None
lowest = origin.z - box.z
loc = actor.get_actor_location()
actor.set_actor_location(unreal.Vector(loc.x, loc.y, loc.z + (floor_z - lowest)), False, False)
return floor_z - lowest
def spawn(cls, loc, label=None, scale=None, rot=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
if a is None:
log(f"spawn {cls.__name__} returned None")
return None
if rot is not None:
a.set_actor_rotation(rot, False)
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
# --- scene -------------------------------------------------------------------
try:
# Start from an empty level: the review map used to keep whatever previous runs left behind, and
# captures showed a scene nobody staged.
clear_level()
# Neutral gray floor material (the scale-test mesh ships red)
floor_mat = None
if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"):
floor_mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_ReviewFloor", "/Game/Test", unreal.Material, unreal.MaterialFactoryNew())
n = unreal.MaterialEditingLibrary.create_material_expression(
floor_mat, unreal.MaterialExpressionConstant3Vector)
n.set_editor_property("constant", unreal.LinearColor(0.35, 0.35, 0.35, 1.0))
unreal.MaterialEditingLibrary.connect_material_property(
n, "", unreal.MaterialProperty.MP_BASE_COLOR)
unreal.MaterialEditingLibrary.recompile_material(floor_mat)
unreal.EditorAssetLibrary.save_loaded_asset(floor_mat)
else:
floor_mat = unreal.EditorAssetLibrary.load_asset("/Game/Test/M_ReviewFloor")
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
if floor_mesh and floor_mat:
# 1 m reference cube scaled to 90 m x 90 m x 0.1 m, top face at Z 0. The
# scale is applied on the actor, so Z is -5 cm (+/- 5 cm around centre).
# Covers the review scene AND the hero blockout 100 m away, so facade shots
# no longer end at the black horizon band (160 m square, top face at Z 0).
floor = spawn(unreal.StaticMeshActor, unreal.Vector(-5000, 450, -5.0), "ReviewFloor",
scale=(160.0, 160.0, 0.1))
if floor:
floor.static_mesh_component.set_static_mesh(floor_mesh)
floor.static_mesh_component.set_material(0, floor_mat)
log("staged: floor (160 m square, gray, top face Z 0)")
else:
log("floor spawn failed")
# Lights are Movable and the project disables static lighting
# (r.AllowStaticLighting=False), so no baked lighting build is needed and
# the editor stops asking to rebuild lighting.
sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0))
if sun:
comp = sun.get_component_by_class(unreal.DirectionalLightComponent)
if comp:
try:
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
except Exception as exc: # noqa: BLE001
log(f"key light mobility not set: {exc!r}")
log("staged: DirectionalLight (movable)")
sky = spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky")
if sky:
comp = sky.get_component_by_class(unreal.SkyLightComponent)
if comp:
try:
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
except Exception as exc: # noqa: BLE001
log(f"fill sky mobility not set: {exc!r}")
log("staged: SkyLight (movable)")
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
log("staged: SkyAtmosphere")
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
if char_mesh:
ch = spawn(unreal.SkeletalMeshActor, unreal.Vector(0, 0, 0), "ReviewCharacter",
rot=unreal.Rotator(pitch=0.0, yaw=180.0, roll=0.0))
if ch:
comp = ch.skeletal_mesh_component
comp.set_skinned_asset_and_update(char_mesh)
# Root at the soles, not the world origin: the composes put feet on the
# floor's top face (Z = 0). If the skeletal root is not at the soles the
# character would float or sink, so report the bounds for the review.
# Actor bounds on a freshly assigned skeletal mesh read as zero, so the generic
# correction came out at +2 cm while the mannequin sat visibly low. 8 cm was then
# 2 cm too high on review, so it sits at 6.
shift = sit_on_floor(ch, floor_z=6.0)
if shift is not None:
log(f"staged: MPFB character sat on the floor (shift {shift:+.1f} cm)")
mn, mx = (None, None)
try:
_origin, _box = ch.get_actor_bounds(False)
log(f"staged: MPFB character at origin, bounds z {_box.z:.1f} cm")
except Exception as exc: # noqa: BLE001
log(f"character bounds unavailable: {exc!r}")
log("staged: MPFB character at origin, facing -Y")
else:
log("MISSING: /Game/Test/Character/character")
# Two cubes only, on purpose: one resting on the floor gives the ground-contact
# and scale reference, one held at 2 m gives a clean cast shadow to compare
# against. Their positions are chosen so both are in the overview frame and
# neither overlaps the character.
cube_specs = (
("ScaleTest_A", -250.0, 600.0, 50.0, "on the ground: base at Z 0"),
("ScaleTest_B", 250.0, 600.0, 250.0, "floating at 2 m: casts a separation shadow"),
)
for name, x, y, z, why in cube_specs:
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
if m:
c = spawn(unreal.StaticMeshActor, unreal.Vector(x, y, z), f"Cube_{name}")
if c:
c.static_mesh_component.set_static_mesh(m)
if name == "ScaleTest_A":
# The resting cube is the ground-contact reference, so its base must be
# exactly on the floor rather than wherever its origin happens to be.
shift = sit_on_floor(c)
log(f"staged: {name} sat on the floor (shift {shift:+.1f} cm)")
log(f"staged: {name} at ({x}, {y}, {z}) - {why}")
# Hero facade, if the import has run: two views a reviewer needs — straight on
# from the north sidewalk, and the oblique street view from further west.
# Hero placement in Unreal centimetres (see Geo/manifests/local-origin.md):
# front plane on the building line, body behind it.
# Corrected 2026-11-07 with the block frontage: UE south is +Y (south
# sidewalk Y +718..+1447) and 6840 occupies the frontage gap X -14591..-12082.
hero_x = -13336.0
hero_y = 1307.0
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
# The imported mesh spans Y -7..+1250 cm in local space (front plane at -7, the
# body behind it) and is centred on X, so the actor sits at building line + 7 cm.
if hero:
actor = spawn(unreal.StaticMeshActor, unreal.Vector(hero_x, hero_y, 0.0), "Hero_Facade")
if actor:
actor.static_mesh_component.set_static_mesh(hero)
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
apply_hero_materials(actor)
log("staged: hero facade at the measured building line")
else:
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/unreal/ue_hero_facade_import.py)")
# --- player pawn ---------------------------------------------------------
# The review rounds must show the pawn the game actually spawns, not only the
# mannequin. Spawn the real character class so its capsule, camera height and
# ground contact are what gets rendered.
pawn = None
char_class = unreal.load_class(None, "/Script/GigLine.GigLineCharacter")
if char_class:
pawn = spawn(char_class, unreal.Vector(0, 0, 90), "PlayerPawn")
if pawn:
log("staged: PlayerPawn (AGigLineCharacter) at the spawn point")
else:
log("MISSING: /Script/GigLine.GigLineCharacter")
# --- vehicle, from the latest Blender import ------------------------------
# Staged from the same assets the demo map uses, so the review always shows what
# Tools/blender/vehicle_from_template.py most recently produced rather than a copy that can
# drift. The panels are plain static meshes because the vehicle pawn keeps its
# physics only.
vehicle_origin = unreal.Vector(-1600.0, 600.0, 0.0)
vehicle_systems = ("SM_Vehicle_Body", "SM_Vehicle_Trim", "SM_Vehicle_Glass",
"SM_Vehicle_Lights", "SM_Vehicle_Wheels")
staged_vehicle = 0
for index, mesh_name in enumerate(vehicle_systems):
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}")
if not mesh:
log(f"MISSING vehicle system: {mesh_name}")
continue
piece = spawn(unreal.StaticMeshActor, vehicle_origin, f"Review_Vehicle_{index:02d}")
if piece:
piece.static_mesh_component.set_static_mesh(mesh)
piece.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
if mesh_name == "SM_Vehicle_Wheels":
shift = sit_on_floor(piece)
log(f"staged: vehicle wheels sat on the floor (shift {shift:+.1f} cm)")
if mesh_name == "SM_Vehicle_Body":
paint = unreal.EditorAssetLibrary.load_asset(
"/Game/Hollywood/Vehicle/M_Vehicle_HatchPaint")
if paint:
piece.static_mesh_component.set_material(0, paint)
staged_vehicle += 1
log(f"staged: {staged_vehicle} vehicle systems at the staging area")
# --- NPC prefab -----------------------------------------------------------
# The reusable NPC person: a collision capsule + visible MPFB body, standing
# feet on Z 0, placed clear of the pawn so the prefab reads on its own.
npc = None
npc_class = unreal.load_class(None, "/Script/GigLine.GigLineNPCPerson")
if npc_class:
npc = spawn(npc_class, unreal.Vector(-4500, 300, 0), "NPC_Person")
if npc:
log("staged: NPC_Person (AGigLineNPCPerson) at Z 0")
else:
log("MISSING: /Script/GigLine.GigLineNPCPerson")
# --- camera list ---------------------------------------------------------
# Every camera is a (name, location, target) triple; the look-at rotation is
# computed from the pair so a camera cannot drift out of aim when one number
# changes. Keep retired shots out of this list entirely.
cams = [
# wide scene: character, both cubes, floor
("overview", unreal.Vector(700, -900, 620), unreal.Vector(0, 300, 80)),
# the vehicle staging, three-quarter front, same framing idea as the demo shot
("vehicle", unreal.Vector(-900, -200, 260), unreal.Vector(-1600, 600, 90)),
# ground-level contact check: feet on the floor, shadow at the contact point
("character-feet", unreal.Vector(220, -420, 45), unreal.Vector(0, 0, 25)),
# the MPFB mannequin, mid shot
("character", unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)),
# both cubes from the south: contact shadow vs cast shadow
("scale-cubes", unreal.Vector(0, -900, 200), unreal.Vector(0, 600, 80)),
# the pawn the game spawns, with the grounded cube beside it
("player-placement", unreal.Vector(320, -520, 140), unreal.Vector(0, 0, 80)),
# the NPC prefab: capsule + visible body, feet on Z 0, at double scale
# distance because it stands off to the west of the main staging
("npc-prefab", unreal.Vector(-4200, 600, 160), unreal.Vector(-4500, 300, 90)),
# low, close on feet and cube base: ground contact must be obvious
("ground-contact", unreal.Vector(180, -260, 35), unreal.Vector(0, 40, 20)),
# shadow comparison, low angle so both shadows read
("shadow-compare", unreal.Vector(-650, -700, 120), unreal.Vector(0, 600, 60)),
# the three entry portals at street level
# street level, aimed at the base of the centre portal: the portals sit at
# Z 0..340 cm behind a 145 cm plinth, so the camera looks slightly down and
# targets the portal sill rather than the centre of the elevation.
# Standing 25 m out at eye height frames the whole elevation, which is what
# the portal detail needs: the portals are small against a 14.5 m façade.
# On the pavement in front of the flight: a pedestrian's own eye line, the
# angle the photographs that established the portals were taken from.
# eye height at 8 m: fills the frame with the three doorways, the flights and
# the plinth, which is what "portal detail" means here.
("hero-portals", unreal.Vector(hero_x + 100, -2250, 170), unreal.Vector(hero_x + 100, hero_y, 230)),
# straight on from the north sidewalk; the building body spans Y -1443..+250
# Level horizon: camera height == target height, so the elevation's
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a
# 7.6 m target, which converged the columns — the one thing an
# architectural elevation must not do (archviz: "straight verticals").
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760), 42.0),
# oblique from the north-west, reading the colonnade in depth
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700), 55.0),
]
# --- review shot cameras + save -------------------------------------------
# Order matters: the review scene is staged in the default (unsaved) world.
# Save it as ReviewMap BEFORE touching DemoMap — switching back to a map
# that only exists in memory fails silently, and the final save would then
# write the demo world over ReviewMap (this exact corruption happened once).
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if shots:
shots.set_editor_property("shot_locations", [c[1] for c in cams])
shots.set_editor_property(
"shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
shots.set_editor_property("shot_names", [c[0] for c in cams])
# Optional 4th element: field of view in degrees. 0 keeps the default 90.
# Elevations shot from across the street use a long lens so the building
# fills the frame without pitching the camera (archviz: straight
# verticals, telephoto for elevations).
shots.set_editor_property(
"shot_field_of_view", [float(c[3]) if len(c) > 3 else 0.0 for c in cams])
shots.set_editor_property("quit_when_done", True)
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
ensure_writable(MAP_PATH)
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP_PATH)
log(f"save_map {MAP_PATH}: {saved}")
# --- demo map views: what the player actually sees ----------------------
demo = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/DemoMap")
if demo and unreal.EditorAssetLibrary.does_asset_exist("/Game/Hollywood/DemoMap"):
# The demo map has terrain, street, sidewalks and the facade; the review
# floor does not. Load it, stage the shot cameras, save. No switch back:
# the review map is already saved, and the run ends here.
switch_level("/Game/Hollywood/DemoMap")
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if actor.get_actor_label() == "ShotSequencer":
unreal.EditorLevelLibrary.destroy_actor(actor)
# Unreal signs, measured from the imported site meshes: north sidewalk at
# Y -1912..-1053, road -1382..+1047, south sidewalk +718..+1447, facade at
# Y -1450. So "opposite the facade" is Y -1650 and the view faces -Y.
demo_shots = [
# 14 m back so the 14.5 m elevation fits: at 2 m the camera sees only
# the colonnade's underside.
# Level horizon for the same reason: a block elevation is read by its
# verticals, and a pitched camera makes them lean.
("demo-third-person-walk", unreal.Vector(-12940, -1280, 500), unreal.Vector(-13336, -1450, 220), 55.0),
# The sedan, staged on the carriageway at (-12950, -900). Three-quarter front
# view from the road so both the body and the wheels are visible: a side-on
# or rear view hides the wheel stances that the build is judged on.
("demo-vehicle", unreal.Vector(-12560, -2150, 420), unreal.Vector(-12950, -1450, 220), 45.0),
# Ground-level angles on the contact points. The wide shots cannot settle whether
# the feet and wheels touch the surface - that question needs the camera near the
# ground, looking along it, where a gap is a visible sliver of surface.
("demo-feet", unreal.Vector(-13140, -1280, 200), unreal.Vector(-13336, -1450, 170), 40.0),
("demo-wheel", unreal.Vector(-12380, -1050, 300), unreal.Vector(-12950, -1450, 175), 55.0),
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
# Street level, from the north sidewalk: the old camera sat at Y -400,
# inside the facade body (which spans Y -1457..-200), so it looked at
# the unlit back face. Y -2600 is on the sidewalk in front of the lit
# elevation.
("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 170)),
# The designated mid-block crossing. Street arc length runs from the
# west end (s=0 at X -27966 cm), so s=130.1 m is UE X -14956, not the
# -24291 the old camera used (that is the s=36.6 crossing). Camera sits
# on the north sidewalk looking along the crosswalk itself, which is
# the pedestrian's view and shows the zebra bars as bars.
("demo-crossing", unreal.Vector(-15250, -2500, 400), unreal.Vector(-14956, 400, 150)),
# 0.1.6 retail frontage: 6800 Lee Drug (X -3340..-1417, face Y +1467)
# and 6806-6820 (X -6618..-3297, face Y +1252). Stand on the north
# sidewalk at 2 m, look south with a level horizon so the parapets and
# the cornice line read as horizontals. 68 deg covers both frontages at
# this distance; the ends crop rather than the elevation leaning.
("demo-retail-frontage", unreal.Vector(-4700, -2600, 210), unreal.Vector(-4700, 1400, 620), 66.0),
# the two north-side background buildings (TCL, Tussaud's) seen from
# the south across Hollywood Blvd. Probed world-space (2026-11-08):
# TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..
# -22799, Y -7586..-1888. +Y = south, -Y = north (axis mapping). So
# stand SOUTH of the road (Y +1600) at the combined mass centre
# (X -22750) and look NORTH toward the mass. The old camera was at
# X -22800 Y +2600 aiming at its own X, giving an 85%-sky frame.
("demo-north-buildings", unreal.Vector(-22750, 1600, 500), unreal.Vector(-22750, -5200, 350)),
# 0.2.0 third-person body. Stands where the pawn's own boom camera sits —
# 3.4 m behind the spawn point, 1 m above it — and looks at the pawn, so
# the visible body and its idle clip are what the shot is for.
# Demo_PlayerStart is at (-13336, -1150, 600), yaw 90 (facing +Y).
("demo-third-person", unreal.Vector(-13336, -2050, 620), unreal.Vector(-13336, -1450, 250), 58.0),
# Front view of the same pawn: the third-person shot only ever shows its
# back, so a pose defect there is invisible. The pawn spawns facing +Y
# (yaw 90), so the camera stands on the road side and looks back north.
("demo-third-person-front", unreal.Vector(-13336, -820, 620), unreal.Vector(-13336, -1450, 240), 55.0),
]
seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if seq:
seq.set_editor_property("shot_locations", [c[1] for c in demo_shots])
seq.set_editor_property("shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
# No follow shots in the current list: the stride capture was retired
# because the walk clip does not visibly advance in game (see TODO.md).
# The follow flag stays wired for when it does.
seq.set_editor_property("shot_follow", [True] + [False] * (len(demo_shots) - 1))
seq.set_editor_property(
"shot_field_of_view",
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
# quit after the demo shots so a dedicated DemoMap capture run
# terminates cleanly (render_screenshots.sh runs both maps).
seq.set_editor_property("quit_when_done", True)
ensure_writable("/Game/Hollywood/DemoMap")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap")
log(f"demo map staged: {len(demo_shots)} cameras, save {saved}")
else:
log("MISSING: /Game/Hollywood/DemoMap (run Tools/unreal/ue_build_demo_map.py)")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")