Files
HartOMat/render-worker/scripts/cinematic_render.py
T

875 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Blender Python script: cinematic product highlight render.
4-segment camera animation (480 frames @ 24fps = 20s):
Segment 1 (1-120): Establishing shot — slow orbit + push-in, 50mm
Segment 2 (121-240): Detail sweep — low arc, telephoto 85mm, shallow DOF
Segment 3 (241-360): Crane up — rising pull-back, wide 35mm
Segment 4 (361-480): Hero close — final push-in, 65mm, smooth deceleration
Usage (from Blender):
blender --background --python cinematic_render.py -- \
<glb_path> <frames_dir> <frame_count> <degrees> <width> <height> \
<engine> <samples> <part_colors_json> \
[template_path] [target_collection] [material_library_path] [material_map_json] \
[part_names_ordered_json] [lighting_only] [cycles_device] [shadow_catcher] \
[rotation_x] [rotation_y] [rotation_z] [turntable_axis] [bg_color] [transparent_bg]
Named arguments (after --):
--mesh-attributes <json>
--usd-path <path>
--focal-length <mm> (ignored — cinematic uses per-segment lenses)
--sensor-width <mm>
--material-override <name>
--camera-orbit (always true for cinematic — camera moves, not model)
"""
import bpy
import sys
import os
import json
import math
from mathutils import Vector, Matrix
# ── Colour palette (matches turntable_render.py / blender_render.py) ─────────
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def _srgb_to_linear(c: int) -> float:
v = c / 255.0
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
def _hex_to_linear(hex_color: str) -> tuple:
h = hex_color.lstrip('#')
return (
_srgb_to_linear(int(h[0:2], 16)),
_srgb_to_linear(int(h[2:4], 16)),
_srgb_to_linear(int(h[4:6], 16)),
1.0,
)
PALETTE_LINEAR = [_hex_to_linear(h) for h in PALETTE_HEX]
SMOOTH_ANGLE = 30 # degrees
# ── Helper functions (copied from turntable_render.py) ───────────────────────
def _ensure_collection(name: str):
"""Return a collection by name, creating it if needed."""
if name in bpy.data.collections:
return bpy.data.collections[name]
col = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(col)
return col
def _assign_palette_material(part_obj, index):
"""Assign a palette colour material to a mesh part."""
color = PALETTE_LINEAR[index % len(PALETTE_LINEAR)]
mat = bpy.data.materials.new(name=f"Part_{index}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = 0.35
bsdf.inputs["Roughness"].default_value = 0.40
try:
bsdf.inputs["Specular IOR Level"].default_value = 0.5
except KeyError:
pass
part_obj.data.materials.clear()
part_obj.data.materials.append(mat)
def _apply_smooth(part_obj, angle_deg):
"""Apply smooth or flat shading to a mesh object."""
bpy.context.view_layer.objects.active = part_obj
part_obj.select_set(True)
if angle_deg > 0:
try:
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
except AttributeError:
bpy.ops.object.shade_smooth()
part_obj.data.use_auto_smooth = True
part_obj.data.auto_smooth_angle = math.radians(angle_deg)
else:
bpy.ops.object.shade_flat()
import re as _re
def _apply_rotation(parts, rx, ry, rz):
"""Apply Euler XYZ rotation (degrees) to all parts by modifying matrix_world."""
if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):
return
from mathutils import Euler
rot_mat = Euler((math.radians(rx), math.radians(ry), math.radians(rz)), 'XYZ').to_matrix().to_4x4()
for p in parts:
p.matrix_world = rot_mat @ p.matrix_world
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
print(f"[cinematic_render] applied rotation ({rx}, {ry}, {rz}) to {len(parts)} parts")
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
"""Apply topology-based shading settings from OCC analysis."""
if not mesh_attributes or mesh_attributes.get("error"):
return
curved_ratio = mesh_attributes.get("curved_ratio", 0.0)
threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0)
threshold_rad = threshold_deg * math.pi / 180.0
for obj in objects:
if obj.type != 'MESH':
continue
if curved_ratio > 0.3:
for poly in obj.data.polygons:
poly.use_smooth = True
obj.data.use_auto_smooth = True
obj.data.auto_smooth_angle = threshold_rad
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Returns list of Blender mesh objects, centred at world origin.
"""
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
if not parts:
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
print(f"[cinematic_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
all_imported = list(bpy.context.selected_objects)
root_objects = [o for o in all_imported if o.parent is None]
for obj in root_objects:
obj.location -= center
return parts
def _resolve_part_name(index, part_obj, part_names_ordered):
"""Get the STEP part name for a Blender part by index."""
base_name = _re.sub(r'\.\d{3}$', '', part_obj.name)
if part_names_ordered and index < len(part_names_ordered):
return part_names_ordered[index]
return base_name
def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=None):
"""Append materials from library .blend and assign to parts via material_map."""
if not mat_lib_path or not os.path.isfile(mat_lib_path):
print(f"[cinematic_render] material library not found: {mat_lib_path}")
return
needed = set(mat_map.values())
if not needed:
return
appended = {}
for mat_name in needed:
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[cinematic_render] appended material: {mat_name}")
else:
print(f"[cinematic_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[cinematic_render] WARNING: failed to append material '{mat_name}': {exc}")
if not appended:
return
assigned_count = 0
for i, part in enumerate(parts):
base_name = _re.sub(r'\.\d{3}$', '', part.name)
_prev = None
while _prev != base_name:
_prev = base_name
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
if not mat_name:
for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
if len(key) >= 5 and len(part_key) >= 5 and (
part_key.startswith(key) or key.startswith(part_key)
):
mat_name = val
break
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
step_key = step_name.lower().strip()
mat_name = mat_map.get(step_key)
if not mat_name:
_p2 = None
while _p2 != step_key:
_p2 = step_key
step_key = _re.sub(r'_af\d+$', '', step_key)
mat_name = mat_map.get(step_key)
if mat_name and mat_name in appended:
part.data.materials.clear()
part.data.materials.append(appended[mat_name])
assigned_count += 1
print(f"[cinematic_render] assigned '{mat_name}' to part '{part.name}'")
print(f"[cinematic_render] material assignment: {assigned_count}/{len(parts)} parts matched")
# ── Cinematic camera animation ───────────────────────────────────────────────
TOTAL_FRAMES = 250
NUM_SEGMENTS = 3
SEGMENT_LENGTH = TOTAL_FRAMES // NUM_SEGMENTS # ~83 frames per segment
# 3 segments — full product always visible, no clipping
# Distance factor is multiplied by bsphere_radius — keep > 2.5× to ensure full object is in frame
SEGMENTS = [
# Segment 1: Establishing orbit — slow 60° orbit at comfortable distance
{
"az_start": 0.0, "az_end": 60.0,
"el_start": 25.0, "el_end": 20.0,
"dist_start": 3.5, "dist_end": 3.0,
"lens_start": 50.0, "lens_end": 50.0,
"dof": False,
},
# Segment 2: Low angle sweep — arc 50° from lower perspective
{
"az_start": 60.0, "az_end": 110.0,
"el_start": 15.0, "el_end": 20.0,
"dist_start": 3.0, "dist_end": 3.2,
"lens_start": 50.0, "lens_end": 65.0,
"dof": False,
},
# Segment 3: Crane up — rise to high angle with pull-back
{
"az_start": 110.0, "az_end": 150.0,
"el_start": 20.0, "el_end": 50.0,
"dist_start": 3.2, "dist_end": 4.0,
"lens_start": 50.0, "lens_end": 40.0,
"dof": False,
},
]
def _lerp(a: float, b: float, t: float) -> float:
"""Linear interpolation."""
return a + (b - a) * t
def _spherical_to_xyz(azimuth_deg: float, elevation_deg: float,
distance: float, center: Vector) -> Vector:
"""Convert spherical coordinates to Cartesian position."""
az = math.radians(azimuth_deg)
el = math.radians(elevation_deg)
x = center.x + distance * math.cos(el) * math.cos(az)
y = center.y + distance * math.cos(el) * math.sin(az)
z = center.z + distance * math.sin(el)
return Vector((x, y, z))
def _get_segment_params(frame: int, bsphere_radius: float):
"""Compute camera parameters for a given frame.
Returns (azimuth_deg, elevation_deg, distance, lens_mm, use_dof).
"""
# Determine which segment and local t (0-1) — linear interpolation
seg_index = min((frame - 1) // SEGMENT_LENGTH, len(SEGMENTS) - 1)
local_frame = (frame - 1) - seg_index * SEGMENT_LENGTH
t = local_frame / max(SEGMENT_LENGTH - 1, 1) # linear, no easing
seg = SEGMENTS[seg_index]
azimuth = _lerp(seg["az_start"], seg["az_end"], t)
elevation = _lerp(seg["el_start"], seg["el_end"], t)
dist_factor = _lerp(seg["dist_start"], seg["dist_end"], t)
distance = dist_factor * bsphere_radius
lens = _lerp(seg["lens_start"], seg["lens_end"], t)
use_dof = seg["dof"]
return azimuth, elevation, distance, lens, use_dof
def _setup_cinematic_camera(parts, bbox_center, bsphere_radius, total_frames, width=1920, height=1080):
"""Create camera and keyframe the cinematic animation.
Takes aspect ratio into account — wider formats push the camera
further back so the product isn't clipped vertically.
Returns the camera object.
"""
# Starting azimuth: offset so segment 1 starts from a good angle (40deg)
base_azimuth = 40.0
# Aspect ratio correction: for wide formats (16:9), vertical FOV is tighter
# so we need more distance. For square/portrait, no correction needed.
aspect_correction = max(1.0, (width / height) * 0.7) if height > 0 else 1.0
# Create camera
start_az, start_el, start_dist, start_lens, _ = _get_segment_params(1, bsphere_radius)
start_pos = _spherical_to_xyz(base_azimuth + start_az, start_el, start_dist, bbox_center)
bpy.ops.object.camera_add(location=start_pos)
cam_obj = bpy.context.active_object
bpy.context.scene.camera = cam_obj
cam_obj.data.lens = start_lens
cam_obj.data.clip_start = max(bsphere_radius * 0.001, 0.0001)
cam_obj.data.clip_end = bsphere_radius * 20.0
# Set sensor width
cam_obj.data.sensor_width = 36.0
# DOF defaults (will be toggled per-segment)
cam_obj.data.dof.use_dof = False
cam_obj.data.dof.focus_distance = bsphere_radius * 2.0
cam_obj.data.dof.aperture_fstop = bsphere_radius * 8.0
print(f"[cinematic_render] animating {total_frames} frames, bsphere_radius={bsphere_radius:.4f}")
# Keyframe every frame for smooth cinematic motion
for frame in range(1, total_frames + 1):
bpy.context.scene.frame_set(frame)
azimuth, elevation, distance, lens, use_dof = _get_segment_params(frame, bsphere_radius)
distance *= aspect_correction # scale for aspect ratio
azimuth += base_azimuth # offset from base viewing angle
# Camera position from spherical coordinates
position = _spherical_to_xyz(azimuth, elevation, distance, bbox_center)
cam_obj.location = position
cam_obj.keyframe_insert(data_path="location", frame=frame)
# Point camera at center using track quaternion
direction = bbox_center - cam_obj.location
rot = direction.to_track_quat('-Z', 'Y')
cam_obj.rotation_euler = rot.to_euler()
cam_obj.keyframe_insert(data_path="rotation_euler", frame=frame)
# Focal length animation
cam_obj.data.lens = lens
cam_obj.data.keyframe_insert(data_path="lens", frame=frame)
# DOF animation
cam_obj.data.dof.use_dof = use_dof
cam_obj.data.dof.keyframe_insert(data_path="use_dof", frame=frame)
if use_dof:
cam_obj.data.dof.focus_distance = direction.length
cam_obj.data.dof.aperture_fstop = bsphere_radius * 8.0
cam_obj.data.dof.keyframe_insert(data_path="focus_distance", frame=frame)
cam_obj.data.dof.keyframe_insert(data_path="aperture_fstop", frame=frame)
# Set all keyframes to LINEAR interpolation (no bezier smoothing)
def _set_linear(anim_data):
if not anim_data or not anim_data.action:
return
action = anim_data.action
# Blender 5.0+: action.fcurves may be action.channels or similar
curves = None
if hasattr(action, 'fcurves'):
curves = action.fcurves
elif hasattr(action, 'channels'):
curves = action.channels
if curves:
for fc in curves:
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
try:
_set_linear(cam_obj.animation_data)
_set_linear(cam_obj.data.animation_data)
if hasattr(cam_obj.data, 'dof') and cam_obj.data.dof:
_set_linear(getattr(cam_obj.data.dof, 'animation_data', None))
except Exception as e:
print(f"[cinematic_render] WARNING: could not set LINEAR interpolation: {e}", flush=True)
print(f"[cinematic_render] camera keyframed: {total_frames} frames across {len(SEGMENTS)} segments", flush=True)
return cam_obj
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
argv = sys.argv
# Everything after "--" is our args
args = argv[argv.index("--") + 1:]
glb_path = args[0]
frames_dir = args[1]
frame_count = int(args[2])
degrees = int(args[3]) # kept for arg compatibility, not used in cinematic
width = int(args[4])
height = int(args[5])
engine = args[6]
samples = int(args[7])
part_colors_json = args[8] if len(args) > 8 else "{}"
# Template + material library args (same positional layout as turntable_render.py)
template_path = args[9] if len(args) > 9 and args[9] else ""
target_collection = args[10] if len(args) > 10 else "Product"
material_library_path = args[11] if len(args) > 11 and args[11] else ""
material_map_raw = args[12] if len(args) > 12 else "{}"
part_names_ordered_raw = args[13] if len(args) > 13 else "[]"
lighting_only = args[14] == "1" if len(args) > 14 else False
cycles_device = args[15].lower() if len(args) > 15 else "auto"
shadow_catcher = args[16] == "1" if len(args) > 16 else False
rotation_x = float(args[17]) if len(args) > 17 else 0.0
rotation_y = float(args[18]) if len(args) > 18 else 0.0
rotation_z = float(args[19]) if len(args) > 19 else 0.0
turntable_axis = args[20] if len(args) > 20 else "world_z" # unused in cinematic
bg_color = args[21] if len(args) > 21 else ""
transparent_bg = args[22] == "1" if len(args) > 22 else False
# Named argument: --mesh-attributes <json>
_mesh_attrs: dict = {}
if "--mesh-attributes" in argv:
_idx = argv.index("--mesh-attributes")
try:
_mesh_attrs = json.loads(argv[_idx + 1])
except Exception:
pass
# Named argument: --usd-path <path>
usd_path = ""
if "--usd-path" in argv:
_usd_idx = argv.index("--usd-path")
usd_path = argv[_usd_idx + 1] if _usd_idx + 1 < len(argv) else ""
# Named argument: --sensor-width <mm>
_sensor_width = None
if "--sensor-width" in argv:
_idx = argv.index("--sensor-width")
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
# Named argument: --material-override <material_name>
_material_override = None
if "--material-override" in argv:
_idx = argv.index("--material-override")
_material_override = argv[_idx + 1] if _idx + 1 < len(argv) else None
# Cinematic always uses camera orbit (camera moves, model stays)
camera_orbit = True
# Override frame count to cinematic default if not explicitly set differently
if frame_count <= 0:
frame_count = TOTAL_FRAMES
# Ensure scripts dir is on path for shared module imports
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
# Pre-load USD import helper
_import_usd_file = None
if usd_path:
from import_usd import import_usd_file as _import_usd_file # type: ignore[assignment]
# Shared material helpers (handle USD stub collisions correctly)
from _blender_materials import (
apply_material_library_direct as _apply_material_library_direct,
apply_material_library as _apply_material_library_shared,
build_mat_map_lower as _build_mat_map_lower,
assign_failed_material as _assign_failed_material,
)
os.makedirs(frames_dir, exist_ok=True)
try:
part_colors = json.loads(part_colors_json)
except json.JSONDecodeError:
part_colors = {}
try:
material_map = json.loads(material_map_raw) if material_map_raw else {}
except json.JSONDecodeError:
material_map = {}
try:
part_names_ordered = json.loads(part_names_ordered_raw) if part_names_ordered_raw else []
except json.JSONDecodeError:
part_names_ordered = []
# Validate template path
if template_path and not os.path.isfile(template_path):
print(f"[cinematic_render] ERROR: template_path was provided but file not found: {template_path}")
print("[cinematic_render] Ensure the blend-templates directory is accessible on this worker.")
sys.exit(1)
use_template = bool(template_path)
print(f"[cinematic_render] engine={engine}, samples={samples}, size={width}x{height}, "
f"frames={frame_count}")
print(f"[cinematic_render] part_names_ordered: {len(part_names_ordered)} entries")
if use_template:
print(f"[cinematic_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
else:
print("[cinematic_render] no template -- using factory settings (Mode A)")
if material_library_path:
print(f"[cinematic_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
# ── SCENE SETUP ──────────────────────────────────────────────────────────
_usd_mat_lookup: dict = {}
if use_template:
# ── MODE B: Template-based render ────────────────────────────────────
print(f"[cinematic_render] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
target_col = _ensure_collection(target_collection)
if usd_path and _import_usd_file:
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
_apply_mesh_attributes(parts, _mesh_attrs)
# Move imported parts into target collection
for part in parts:
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
# Apply smooth shading
for part in parts:
_apply_smooth(part, SMOOTH_ANGLE)
# Apply material override if set
if _material_override:
print(f"[cinematic_render] material_override active: all parts -> {_material_override}", flush=True)
if _usd_mat_lookup:
_usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
if material_map:
material_map = {k: _material_override for k in material_map}
# Material assignment: USD primvar path first, then name-matching fallback
if material_library_path and _usd_mat_lookup:
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
if material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned:
print(f"[cinematic_render] {len(_unassigned)} parts without USD primvar -- "
f"falling back to name-matching", flush=True)
_apply_material_library_shared(
_unassigned, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
elif material_library_path and material_map:
_apply_material_library_shared(
parts, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
# Palette fallback for any parts still without materials
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
# ── Shadow catcher (Cycles only, template mode only) ─────────────────
if shadow_catcher:
sc_col_name = "Shadowcatcher"
sc_obj_name = "Shadowcatcher"
for vl in bpy.context.scene.view_layers:
def _enable_col_recursive(layer_col):
if layer_col.collection.name == sc_col_name:
layer_col.exclude = False
layer_col.collection.hide_render = False
layer_col.collection.hide_viewport = False
return True
for child in layer_col.children:
if _enable_col_recursive(child):
return True
return False
_enable_col_recursive(vl.layer_collection)
sc_obj = bpy.data.objects.get(sc_obj_name)
if sc_obj:
all_world_z = []
for part in parts:
for corner in part.bound_box:
all_world_z.append((part.matrix_world @ Vector(corner)).z)
if all_world_z:
sc_obj.location.z = min(all_world_z)
print(f"[cinematic_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
else:
print(f"[cinematic_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")
print(f"[cinematic_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")
else:
# ── MODE A: Factory settings ─────────────────────────────────────────
bpy.ops.wm.read_factory_settings(use_empty=True)
if usd_path and _import_usd_file:
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
_apply_mesh_attributes(parts, _mesh_attrs)
for i, part in enumerate(parts):
_apply_smooth(part, SMOOTH_ANGLE)
# Apply material override if set
if _material_override:
print(f"[cinematic_render] material_override active (Mode A): all parts -> {_material_override}", flush=True)
if _usd_mat_lookup:
_usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
if material_map:
material_map = {k: _material_override for k in material_map}
# Material assignment: USD primvar path first, then name-matching fallback
if material_library_path and _usd_mat_lookup:
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
if material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "HARTOMAT_059999_FailedMaterial")]
if _unassigned:
_apply_material_library_shared(
_unassigned, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
elif material_library_path and material_map:
_apply_material_library_shared(
parts, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
else:
for i, part in enumerate(parts):
step_name = _resolve_part_name(i, part, part_names_ordered)
color_hex = part_colors.get(step_name)
if color_hex:
mat = bpy.data.materials.new(name=f"mat_{part.name}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
color = _hex_to_linear(color_hex)
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = 0.35
bsdf.inputs["Roughness"].default_value = 0.40
try:
bsdf.inputs["Specular IOR Level"].default_value = 0.5
except KeyError:
pass
part.data.materials.clear()
part.data.materials.append(mat)
else:
_assign_palette_material(part, i)
# Palette fallback for any parts still without materials
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
# ── Combined bounding box / bounding sphere ──────────────────────────────
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
bbox_min = Vector((
min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners),
))
bbox_max = Vector((
max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners),
))
bbox_center = (bbox_min + bbox_max) * 0.5
bbox_dims = bbox_max - bbox_min
bsphere_radius = max(bbox_dims.length * 0.5, 0.001)
print(f"[cinematic_render] bbox_dims={tuple(round(d, 4) for d in bbox_dims)}, "
f"bsphere_radius={bsphere_radius:.4f}")
# ── Lighting -- only in Mode A (factory settings) ────────────────────────
if not use_template:
light_dist = bsphere_radius * 6.0
bpy.ops.object.light_add(type='SUN', location=(
bbox_center.x + light_dist * 0.5,
bbox_center.y - light_dist * 0.35,
bbox_center.z + light_dist,
))
sun = bpy.context.active_object
sun.data.energy = 4.0
sun.rotation_euler = (math.radians(45), 0, math.radians(30))
bpy.ops.object.light_add(type='AREA', location=(
bbox_center.x - light_dist * 0.4,
bbox_center.y + light_dist * 0.4,
bbox_center.z + light_dist * 0.7,
))
fill = bpy.context.active_object
fill.data.energy = max(800.0, bsphere_radius ** 2 * 2000.0)
fill.data.size = max(4.0, bsphere_radius * 4.0)
# World background
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
bg.inputs["Strength"].default_value = 0.15
# ── Cinematic camera animation ───────────────────────────────────────────
# Remove any existing template camera — cinematic always creates its own
if bpy.context.scene.camera:
old_cam = bpy.context.scene.camera
bpy.data.objects.remove(old_cam, do_unlink=True)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
camera = _setup_cinematic_camera(parts, bbox_center, bsphere_radius, frame_count, width, height)
# ── Colour management ────────────────────────────────────────────────────
if not use_template:
scene.view_settings.view_transform = 'Standard'
scene.view_settings.exposure = 0.0
scene.view_settings.gamma = 1.0
try:
scene.view_settings.look = 'None'
except Exception:
pass
# ── Render engine ────────────────────────────────────────────────────────
if engine == "eevee":
eevee_ok = False
for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
try:
scene.render.engine = eevee_id
eevee_ok = True
print(f"[cinematic_render] EEVEE engine id: {eevee_id}")
break
except TypeError:
continue
if eevee_ok:
for attr in ('taa_render_samples', 'samples'):
try:
setattr(scene.eevee, attr, samples)
break
except AttributeError:
continue
else:
print("[cinematic_render] WARNING: EEVEE not available, falling back to Cycles")
engine = "cycles"
if engine != "eevee":
scene.render.engine = 'CYCLES'
scene.cycles.samples = samples
scene.cycles.use_denoising = True
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
print(f"[cinematic_render] cycles_device={cycles_device}")
gpu_found = False
if cycles_device != "cpu":
try:
cycles_prefs = bpy.context.preferences.addons['cycles'].preferences
for device_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
try:
cycles_prefs.compute_device_type = device_type
cycles_prefs.get_devices()
gpu_devs = [d for d in cycles_prefs.devices if d.type != 'CPU']
if gpu_devs:
for d in gpu_devs:
d.use = True
scene.cycles.device = 'GPU'
gpu_found = True
print(f"[cinematic_render] Cycles GPU ({device_type})")
break
except Exception:
continue
except Exception:
pass
if gpu_found:
print(f"RENDER_DEVICE_USED: engine=CYCLES device=GPU compute_type={device_type}", flush=True)
else:
scene.cycles.device = 'CPU'
print("[cinematic_render] WARNING: GPU not found -- falling back to CPU")
print("RENDER_DEVICE_USED: engine=CYCLES device=CPU compute_type=NONE (fallback)", flush=True)
import os as _os
if _os.environ.get("CYCLES_DEVICE", "auto").lower() == "gpu":
print("GPU_REQUIRED_BUT_CPU_USED: strict mode active (CYCLES_DEVICE=gpu)", flush=True)
sys.exit(2)
# ── Render settings ──────────────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
# ── White background (default for cinematic) ────────────────────────────
# Set world to white unless template provides its own
if not template_path:
world = bpy.data.worlds.new("CinematicWorld")
scene.world = world
world.use_nodes = True
bg_node = world.node_tree.nodes.get("Background")
if bg_node:
bg_node.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0)
bg_node.inputs["Strength"].default_value = 1.0
print("[cinematic_render] white background set", flush=True)
# ── Transparent background (override if requested) ────────────────────
if bg_color or transparent_bg:
scene.render.film_transparent = True
if bg_color:
print(f"[cinematic_render] film_transparent=True for FFmpeg bg_color compositing ({bg_color})", flush=True)
else:
print("[cinematic_render] transparent_bg enabled (alpha PNG frames)", flush=True)
# ── Persistent data (Cycles BVH caching between frames) ──────────────────
scene.render.use_persistent_data = True
print("[cinematic_render] persistent_data enabled -- BVH cached between frames", flush=True)
# ── Render all frames ────────────────────────────────────────────────────
import time as _time
_render_start = _time.time()
for frame in range(1, frame_count + 1):
scene.frame_set(frame)
scene.render.filepath = os.path.join(frames_dir, f"frame_{frame:04d}")
bpy.ops.render.render(write_still=True)
elapsed = _time.time() - _render_start
fps_so_far = frame / elapsed
print(f"[cinematic_render] Frame {frame}/{frame_count} -- {elapsed:.1f}s elapsed ({fps_so_far:.2f} fps)", flush=True)
total = _time.time() - _render_start
print(f"[cinematic_render] Cinematic render complete: {frame_count} frames in {total:.1f}s ({frame_count/total:.2f} fps avg)", flush=True)
if __name__ == "__main__":
main()