"""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)
