Files
HartOMat/flamenco/scripts/turntable_setup.py
T
2026-03-05 22:12:38 +01:00

689 lines
29 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: scene setup for turntable animation (Flamenco).
Performs all scene preparation — STL import, materials, camera, pivot animation,
compositor — then SAVES the resulting .blend file to <scene_path>.
The saved .blend is then rendered by a separate Flamenco task:
blender --background <scene_path> --python turntable_gpu_setup.py -a
Using Blender's native -a (--render-anim) keeps the GPU scene (BVH, textures)
loaded for ALL frames in one process, avoiding per-frame GPU re-upload overhead.
Usage (from Blender):
blender --background --python turntable_setup.py -- \\
<stl_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] [scene_path] [camera_orbit]
"""
import bpy
import sys
import os
import json
import math
from mathutils import Vector, Matrix
# ── Colour palette ────────────────────────────────────────────────────────────
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
# ── Helpers (kept in sync with turntable_render.py) ──────────────────────────
def _ensure_collection(name: str):
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):
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):
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):
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"[turntable_setup] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
def _axis_rotation(axis: str, degrees: float) -> tuple:
rad = math.radians(degrees)
if axis == "world_x":
return (rad, 0.0, 0.0)
elif axis == "world_y":
return (0.0, rad, 0.0)
else:
return (0.0, 0.0, rad)
def _set_fcurves_linear(action):
try:
for layer in action.layers:
for strip in layer.strips:
for channelbag in strip.channelbags:
for fc in channelbag.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
except AttributeError:
for fc in action.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
def _scale_mm_to_m(parts):
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[turntable_setup] scaled {len(parts)} parts mm→m (×0.001)")
def _import_stl(stl_file):
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
parts = []
if os.path.isfile(manifest_path):
try:
with open(manifest_path, "r") as f:
manifest = json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[turntable_setup] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[turntable_setup] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[turntable_setup] imported {len(parts)} named parts from per-part STLs")
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[turntable_setup] fallback: separated into {len(parts)} part(s)")
return parts
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
for p in parts:
p.location -= center
return parts
def _resolve_part_name(index, part_obj, part_names_ordered):
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):
if not mat_lib_path or not os.path.isfile(mat_lib_path):
print(f"[turntable_setup] 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"[turntable_setup] appended material: {mat_name}")
else:
print(f"[turntable_setup] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[turntable_setup] 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)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
part_key = step_name.lower().strip()
mat_name = mat_map.get(part_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"[turntable_setup] assigned '{mat_name}' to part '{part.name}'")
print(f"[turntable_setup] material assignment: {assigned_count}/{len(parts)} parts matched")
def main():
argv = sys.argv
args = argv[argv.index("--") + 1:]
stl_path = args[0]
frames_dir = args[1]
frame_count = int(args[2])
degrees = int(args[3])
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_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"
bg_color = args[21] if len(args) > 21 else ""
transparent_bg = args[22] == "1" if len(args) > 22 else False
scene_path = args[23] if len(args) > 23 else os.path.join(os.path.dirname(frames_dir), "scene.blend")
camera_orbit = args[24] != "0" if len(args) > 24 else True
noise_threshold_arg = args[25] if len(args) > 25 else ""
denoiser_arg = args[26] if len(args) > 26 else ""
denoising_input_passes_arg = args[27] if len(args) > 27 else ""
denoising_prefilter_arg = args[28] if len(args) > 28 else ""
denoising_quality_arg = args[29] if len(args) > 29 else ""
denoising_use_gpu_arg = args[30] if len(args) > 30 else ""
os.makedirs(frames_dir, exist_ok=True)
os.makedirs(os.path.dirname(scene_path), 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 = []
if template_path and not os.path.isfile(template_path):
print(f"[turntable_setup] ERROR: template_path not found: {template_path}")
sys.exit(1)
use_template = bool(template_path)
print(f"[turntable_setup] engine={engine}, samples={samples}, size={width}x{height}, "
f"frames={frame_count}, degrees={degrees}")
print(f"[turntable_setup] part_names_ordered: {len(part_names_ordered)} entries")
if use_template:
print(f"[turntable_setup] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
else:
print("[turntable_setup] no template — using factory settings (Mode A)")
if material_library_path:
print(f"[turntable_setup] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
# ── SCENE SETUP ──────────────────────────────────────────────────────────
if use_template:
print(f"[turntable_setup] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
target_col = _ensure_collection(target_collection)
parts = _import_stl(stl_path)
_scale_mm_to_m(parts)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
for part in parts:
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
for part in parts:
_apply_smooth(part, SMOOTH_ANGLE)
if material_library_path and material_map:
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
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 not color_hex:
_assign_palette_material(part, i)
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"[turntable_setup] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
else:
print(f"[turntable_setup] WARNING: shadow catcher object '{sc_obj_name}' not found")
needs_auto_camera = (lighting_only and not shadow_catcher) or not bpy.context.scene.camera
if not needs_auto_camera and bpy.context.scene.camera:
bpy.context.scene.camera.data.clip_start = 0.001
print(f"[turntable_setup] template mode: {len(parts)} parts imported into '{target_collection}'")
else:
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
_scale_mm_to_m(parts)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
for i, part in enumerate(parts):
_apply_smooth(part, SMOOTH_ANGLE)
if material_library_path and material_map:
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
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)
if needs_auto_camera:
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"[turntable_setup] bbox_dims={tuple(round(d, 4) for d in bbox_dims)}, bsphere_radius={bsphere_radius:.4f}")
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)
cam_dist = bsphere_radius * 2.5
cam_location = Vector((bbox_center.x + cam_dist, bbox_center.y, bbox_center.z + bsphere_radius * 0.5))
bpy.ops.object.camera_add(location=cam_location)
camera = bpy.context.active_object
bpy.context.scene.camera = camera
camera.data.clip_start = max(cam_dist * 0.001, 0.0001)
camera.data.clip_end = cam_dist * 10.0
empty = bpy.data.objects.new("target", None)
bpy.context.collection.objects.link(empty)
empty.location = bbox_center
track = camera.constraints.new(type='TRACK_TO')
track.target = empty
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'
if not use_template:
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
pivot = bpy.data.objects.new("pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
camera.parent = pivot
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(pivot.animation_data.action)
else:
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
bbox_center = Vector((
(min(v.x for v in all_corners) + max(v.x for v in all_corners)) * 0.5,
(min(v.y for v in all_corners) + max(v.y for v in all_corners)) * 0.5,
(min(v.z for v in all_corners) + max(v.z for v in all_corners)) * 0.5,
))
if camera_orbit and bpy.context.scene.camera:
# Camera-orbit mode: rotate camera around static product.
# Parts stay stationary → Cycles BVH cached across all frames → ~40% speedup.
camera = bpy.context.scene.camera
cam_world = camera.matrix_world.copy()
cam_pivot = bpy.data.objects.new("cam_pivot", None)
bpy.context.collection.objects.link(cam_pivot)
cam_pivot.location = bbox_center
camera.parent = cam_pivot
# Restore world-space transform after parenting (Blender recomputes local matrix)
camera.matrix_world = cam_world
cam_pivot.rotation_euler = (0, 0, 0)
cam_pivot.keyframe_insert(data_path="rotation_euler", frame=1)
cam_pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
cam_pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(cam_pivot.animation_data.action)
print(f"[turntable_setup] camera-orbit mode: cam_pivot at {tuple(round(c, 4) for c in bbox_center)}")
else:
# Product-rotation mode: parts parent to pivot (default fallback when no camera)
pivot = bpy.data.objects.new("turntable_pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
for part in parts:
part.parent = pivot
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(pivot.animation_data.action)
print(f"[turntable_setup] product-rotation mode: {len(parts)} parts parented to turntable_pivot")
# ── Colour management ────────────────────────────────────────────────────
scene = bpy.context.scene
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"[turntable_setup] 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("[turntable_setup] 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 = denoiser_arg if denoiser_arg else 'OPENIMAGEDENOISE'
if denoising_input_passes_arg:
try: scene.cycles.denoising_input_passes = denoising_input_passes_arg
except Exception: pass
if denoising_prefilter_arg:
try: scene.cycles.denoising_prefilter = denoising_prefilter_arg
except Exception: pass
if denoising_quality_arg:
try: scene.cycles.denoising_quality = denoising_quality_arg
except Exception: pass
if denoising_use_gpu_arg:
try: scene.cycles.denoising_use_gpu = (denoising_use_gpu_arg == "1")
except AttributeError: pass
if noise_threshold_arg:
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = float(noise_threshold_arg)
if denoiser_arg:
scene["_denoiser_override"] = denoiser_arg
# scene.cycles.device is set by turntable_gpu_setup.py at render time
# (GPU preferences are user-level and not stored in .blend)
# We set the intended device here so gpu_setup can read it.
scene["_cycles_device"] = cycles_device
# Keep BVH, textures, and scene data resident on GPU between frames.
# Critical for -a mode: prevents Cycles from re-uploading data each frame.
scene.render.use_persistent_data = True
# No motion blur needed for static mechanical parts — eliminates per-frame
# CPU deformation calculations.
scene.render.use_motion_blur = False
print(f"[turntable_setup] cycles_device preference saved: {cycles_device}")
print("[turntable_setup] use_persistent_data=True, use_motion_blur=False")
# ── Render output settings ───────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
# Blender -a appends 4-digit frame number: "frame_" → "frame_0001.png"
scene.render.filepath = os.path.join(frames_dir, "frame_")
# ── Transparent background ────────────────────────────────────────────────
# bg_color compositing is done by FFmpeg in the compose-video task.
# Blender renders transparent PNG frames (film_transparent=True) when
# bg_color is set; FFmpeg then overlays them over a solid colour background.
if bg_color or transparent_bg:
scene.render.film_transparent = True
if bg_color:
print(f"[turntable_setup] film_transparent=True for FFmpeg bg_color compositing ({bg_color})")
else:
print("[turntable_setup] transparent_bg enabled (alpha PNG frames)")
# ── Save scene ───────────────────────────────────────────────────────────
# save_as_mainfile saves to an explicit new path (like File > Save As).
# save_mainfile would save back to the originally-opened template path.
print(f"[turntable_setup] Saving scene to {scene_path}")
result = bpy.ops.wm.save_as_mainfile(filepath=scene_path)
if 'FINISHED' not in result:
print(f"[turntable_setup] ERROR: save_as_mainfile returned {result} — aborting")
sys.exit(1)
if not os.path.isfile(scene_path):
print(f"[turntable_setup] ERROR: scene file not found after save: {scene_path}")
sys.exit(1)
size_mb = os.path.getsize(scene_path) / 1024 / 1024
print(f"[turntable_setup] Scene saved → {scene_path} ({size_mb:.1f} MB)")
print(f"[turntable_setup] Ready for: blender --background {scene_path} --python turntable_gpu_setup.py -a")
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as _exc:
import traceback
traceback.print_exc()
print(f"[turntable_setup] FATAL: unhandled exception — {_exc}")
sys.exit(1)