""" frozen_camera.py — the Frozen Time renderer, parameterised. One reusable Blender tool for the "clay camera-move" step of the Frozen Time pipeline (see FROZEN_TIME.md). Everything that was hardcoded per-reel in An earlier hardcoded script is now driven by a JSON job file: mesh, reference image + bbox, output, resolution/fps, and a list of camera beats. Run under Blender's own Python, headless: /Applications/Blender.app/Contents/MacOS/Blender -b -noaudio \ --python bin/frozen_camera.py -- render --job job.json Subcommands (all take --job): render the full pipeline: solve, ground, animate, render, encode contact-sheet sweep azimuth (and optionally elevation) around the mesh, render N thumbnails, montage them for a human to pick from solve-azimuth sweep azimuth, match the reference bbox's aspect ratio, report the best azimuth Preserved exactly from the original hardcoded script (do not "simplify" these, they are the hard-won parts — see FROZEN_TIME.md §6.2-6.6): - the frame-1 alpha-bbox solve, iterated to convergence - the grounded world (3000-unit noise plane + sun + cast shadow, never a grey void, never camera-projected cards) - 8% decimate - the Newton-solved cubic-bezier easing with split/lagged channels - the frustum-shift fit for near-top-down beats Environment gotchas baked in here (verified on this machine, Blender 5.2): - Action has no .fcurves; set keyframe_new_interpolation_type before inserting keys. - The EEVEE enum is BLENDER_EEVEE (BLENDER_EEVEE_NEXT does not exist). - FFMPEG is not a valid image_settings.file_format; render PNG, encode with ffmpeg afterward. - ImageMagick 7: use `magick`, not `convert`. - The ground material needs EEVEE; Workbench will not show it. The silhouette solves use Workbench + FLAT deliberately, for a clean alpha. """ import argparse import json import math import os import shutil import subprocess import sys import tempfile import bpy from mathutils import Vector, Matrix # -------------------------------------------------------------------------- # CLI plumbing — Blender swallows everything before "--" # -------------------------------------------------------------------------- def blender_argv(): argv = sys.argv if "--" in argv: return argv[argv.index("--") + 1:] return [] def build_parser(): p = argparse.ArgumentParser(prog="frozen_camera.py", add_help=True) sub = p.add_subparsers(dest="cmd", required=True) r = sub.add_parser("render", help="full pipeline: solve, ground, animate, render, encode") r.add_argument("--job", required=True) r.add_argument("--keep-frames", action="store_true", help="don't delete the PNG sequence work dir") c = sub.add_parser("contact-sheet", help="azimuth/elevation sweep montage for picking beat angles by eye") c.add_argument("--job", required=True) c.add_argument("--azimuth-steps", type=int, default=12, help="number of azimuth samples over 360°") c.add_argument("--elevations", default="0", help="comma-separated elevation angles, e.g. 0,30,60") c.add_argument("--out", default=None, help="output montage PNG (default: /contact_sheet.png)") c.add_argument("--tile", default="768x768", help="magick montage per-tile geometry") a = sub.add_parser("solve-azimuth", help="sweep azimuth, match the reference bbox aspect ratio") a.add_argument("--job", required=True) a.add_argument("--step", type=float, default=10.0, help="coarse sweep step in degrees") a.add_argument("--range", default="-180,180", help="az_min,az_max in degrees. A negative az_min needs the " "--range=-180,180 (with '=') form — argparse otherwise reads " "'-180,180' as an option, not this flag's value.") a.add_argument("--hint", type=float, default=None, help="a rough human eyeball guess for the azimuth (degrees). A silhouette's " "aspect ratio is not injective over 360° — a bowling/throwing pose gives " "several azimuths the same width/height ratio (front vs. back hemisphere, " "near-mirror arm positions). The hint picks which crossing is the real one; " "without it the solver falls back to the sharpest bracket and warns.") a.add_argument("--refine-iters", type=int, default=4, help="bisection refine passes near the best bracket") return p # -------------------------------------------------------------------------- # job file # -------------------------------------------------------------------------- DEFAULT_TIMING = { "elevation_lead_frames": 2, # elevation channel leads the swing by this many frames "dolly_lag_frames": 4, # dolly (radius) channel trails the swing by this many frames "radius_drift_pct": 1.2, # total radius creep over the whole timeline, so nothing sits dead "swing_bezier": [0.40, 0.00, 0.10, 1.00], # azimuth/aim easing "dolly_bezier": [0.30, 0.00, 0.18, 1.00], # radius easing "rise_bezier": [0.22, 0.00, 0.30, 1.00], # elevation easing } DEFAULT_WORLD = { "ground_size": 3000.0, "ground_noise_scale_1": 220.0, "ground_noise_scale_2": 1400.0, "ground_color_low": [0.075, 0.115, 0.045, 1.0], "ground_color_high": [0.30, 0.36, 0.16, 1.0], "sun_energy": 3.0, "sun_elevation_deg": 50.0, "sun_rotation_z_deg": 200.0, "world_color": [0.55, 0.60, 0.66, 1.0], "clay_color": [0.62, 0.62, 0.62, 1.0], "clay_roughness": 0.75, } LABEL_FONT = "/System/Library/Fonts/Helvetica.ttc" # ImageMagick 7 on this machine has no default # font registered — -annotate fails with # "unable to read font ''" unless one is given DEFAULT_CLIP_GUARD = { "enabled": True, "margin_frac": 0.01, # fraction of frame dimension counted as "touching the edge" "scale": 0.35, # render the clip-check pass at this fraction of final resolution, for speed } def load_job(path): with open(path) as f: job = json.load(f) job.setdefault("lens_mm", 50.0) job.setdefault("decimate_ratio", 0.08) job.setdefault("fps", 24) job.setdefault("resolution", [1080, 1920]) job.setdefault("timing", {}) for k, v in DEFAULT_TIMING.items(): job["timing"].setdefault(k, v) job.setdefault("world", {}) for k, v in DEFAULT_WORLD.items(): job["world"].setdefault(k, v) job.setdefault("clip_guard", {}) for k, v in DEFAULT_CLIP_GUARD.items(): job["clip_guard"].setdefault(k, v) ref = job["reference"] x0, y0, x1, y1 = ref["bbox"] ref["_cx"] = (x0 + x1) / 2.0 ref["_cy"] = (y0 + y1) / 2.0 ref["_h"] = y1 - y0 ref["_w"] = x1 - x0 return job # -------------------------------------------------------------------------- # scene setup — shared by all three subcommands # -------------------------------------------------------------------------- def clear_scene(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) def import_and_decimate(mesh_path, ratio): bpy.ops.import_scene.gltf(filepath=mesh_path) figs = [o for o in bpy.context.scene.objects if o.type == 'MESH'] if not figs: raise RuntimeError(f"no mesh objects came in from {mesh_path}") for o in figs: if o.name.startswith("annot_"): print(f"ANNOT {o.name}: kept at full res ({len(o.data.polygons)} polys), own material") continue before = len(o.data.polygons) m = o.modifiers.new("d", "DECIMATE") m.ratio = ratio bpy.context.view_layer.objects.active = o bpy.ops.object.modifier_apply(modifier="d") print(f"DECIMATE {o.name}: {before} -> {len(o.data.polygons)} polys") return figs def mesh_bounds(figs): mn = Vector((1e9,) * 3) mx = Vector((-1e9,) * 3) for o in figs: for c in o.bound_box: w = o.matrix_world @ Vector(c) for i in range(3): mn[i] = min(mn[i], w[i]) mx[i] = max(mx[i], w[i]) ctr = (mn + mx) / 2 size = mx - mn floor = mn.z return ctr, size, floor def make_camera(lens_mm): cd = bpy.data.cameras.new("c") cam = bpy.data.objects.new("c", cd) cd.lens = lens_mm bpy.context.scene.collection.objects.link(cam) bpy.context.scene.camera = cam return cam, cd def place_polar(cam, cd, aim, az_deg, el_deg, radius, shift_x=0.0, shift_y=0.0): """Point cam at aim from (az, el, radius) in the standard rig convention used throughout the Frozen Time scripts: az=0 looks along +Y from the south, el is elevation above the aim's horizontal plane.""" A = math.radians(az_deg) E = math.radians(el_deg) cam.location = ( aim.x + radius * math.cos(E) * math.sin(A), aim.y - radius * math.cos(E) * math.cos(A), aim.z + radius * math.sin(E), ) cam.rotation_euler = (aim - Vector(cam.location)).to_track_quat('-Z', 'Y').to_euler() cd.shift_x = shift_x cd.shift_y = shift_y def silhouette_mode(sc, w, h): sc.render.engine = 'BLENDER_WORKBENCH' sc.render.film_transparent = True sc.render.resolution_x = w sc.render.resolution_y = h sc.render.image_settings.file_format = 'PNG' sc.render.image_settings.color_mode = 'RGBA' sh = sc.display.shading sh.light = 'FLAT' sh.color_type = 'SINGLE' sh.single_color = (1, 0.15, 0.05) sh.show_cavity = False def render_alpha_bbox(sc, tmp_png): sc.render.filepath = tmp_png bpy.ops.render.render(write_still=True) out = subprocess.run( ["magick", tmp_png, "-trim", "-format", "%w %h %X %Y", "info:"], capture_output=True, text=True, ).stdout.split() if len(out) != 4: # a fully transparent frame (nothing in view) trims to nothing return 0, 0, 0, 0 bw, bh, bx, by = [int(v.replace('+', '')) for v in out] return bw, bh, bx, by # -------------------------------------------------------------------------- # 3.2 — the frame-1 match solve (preserved exactly, parameterised) # -------------------------------------------------------------------------- def solve_match_frame(sc, cam, cd, ctr, size, ref, az_deg, tmp_dir, aim_height_frac=0.10, max_iters=8, tol_px=3): """Iterate distance + aim so the silhouette's alpha bbox lands on the reference photo's subject bbox. Converges in ~4 passes. Returns (R, aim, bbox) where bbox=(bw,bh,cx,cy) of the final pass.""" rw, rh = ref["width"], ref["height"] TCX, TCY, TH = ref["_cx"], ref["_cy"], ref["_h"] silhouette_mode(sc, rw, rh) # initial guess: a distance that roughly fills frame height with the # mesh's own vertical extent, at the requested azimuth, aimed near # the beat's aim height. R = (size.z * 1.15) * (cd.lens / 36.0) * (rh / TH) aim = Vector((ctr.x, ctr.y, ctr.z + size.z * aim_height_frac)) a = math.radians(az_deg) right = Vector((math.cos(a), math.sin(a), 0)) tmp_png = os.path.join(tmp_dir, "match_solve.png") bw = bh = cx = cy = 0 for i in range(max_iters): place_polar(cam, cd, aim, az_deg, 0.0, R) bw, bh, bx, by = render_alpha_bbox(sc, tmp_png) if bw == 0: raise RuntimeError("match solve: silhouette render came back empty — check mesh/camera placement") cx, cy = bx + bw / 2, by + bh / 2 print(f"MATCH-SOLVE {i} bbox {bw}x{bh} c({cx:.1f},{cy:.1f}) target {ref['_w']:.0f}x{TH:.0f} c({TCX:.1f},{TCY:.1f})") if abs(bh - TH) <= tol_px and abs(cx - TCX) <= tol_px and abs(cy - TCY) <= tol_px: break R *= bh / TH upp = (36.0 * R / cd.lens) / rh aim = aim - right * ((TCX - cx) * upp) + Vector((0, 0, (TCY - cy) * upp)) print(f"MATCH R={R:.4f} aim={[round(v,4) for v in aim]} bbox {bw}x{bh} c({cx:.1f},{cy:.1f})") return R, aim, (bw, bh, cx, cy) # -------------------------------------------------------------------------- # 3.5b — frustum-shift fit for a near-top-down (or any hard-to-aim) beat # -------------------------------------------------------------------------- def solve_frustum_fit(sc, cam, cd, ref, az_deg, el_deg, aim, radius0, target_h_frac, tmp_dir, max_iters=6, tol_px=8): """For a beat where nudging the aim point to compose is unreliable (extreme elevation), fit radius + frustum shift instead — exact, no axis-sign guessing. Target: subject centred in frame, height = target_h_frac * ref height.""" rw, rh = ref["width"], ref["height"] tgt_h = rh * target_h_frac tgt_cx, tgt_cy = rw / 2.0, rh / 2.0 silhouette_mode(sc, rw, rh) cd.shift_x = 0.0 cd.shift_y = 0.0 R = radius0 tmp_png = os.path.join(tmp_dir, "frustum_fit.png") bw = bh = cx = cy = 0 for i in range(max_iters): place_polar(cam, cd, aim, az_deg, el_deg, R, cd.shift_x, cd.shift_y) for _o in _ANNOTS: # annotations that are on screen in this beat face the fit camera, so the fit sees their true footprint if not _o.hide_render: _o.scale = (1, 1, 1); _face_camera(_o, cam.matrix_world.translation, Vector(tuple(_o["annot_dir"])).normalized()) bw, bh, bx, by = render_alpha_bbox(sc, tmp_png) if bw == 0: raise RuntimeError("frustum fit: silhouette render came back empty") cx, cy = bx + bw / 2, by + bh / 2 print(f"FRUSTUM-FIT {i} bbox {bw}x{bh} c({cx:.1f},{cy:.1f}) target_h {tgt_h:.0f} c({tgt_cx:.0f},{tgt_cy:.0f}) shift ({cd.shift_x:.4f},{cd.shift_y:.4f})") tgt_w = rw * 0.90 # 05 Sep 2026: width guard — a wide pose (or annotations) must also stay inside the frame if (abs(bh - tgt_h) <= tol_px or bw >= tgt_w - tol_px) and bw <= tgt_w + tol_px and abs(cx - tgt_cx) <= tol_px and abs(cy - tgt_cy) <= tol_px: break R *= max(bh / tgt_h, bw / tgt_w) cd.shift_x += (cx - tgt_cx) / rh cd.shift_y -= (cy - tgt_cy) / rh print(f"FRUSTUM-FIT final R={R:.4f} shift=({cd.shift_x:.4f},{cd.shift_y:.4f})") return R, cd.shift_x, cd.shift_y # -------------------------------------------------------------------------- # 3.3 — the grounded world # -------------------------------------------------------------------------- def make_ground_material(world_cfg): m = bpy.data.materials.new("gnd") m.use_nodes = True nt = m.node_tree nt.nodes.clear() coord = nt.nodes.new("ShaderNodeTexCoord") n1 = nt.nodes.new("ShaderNodeTexNoise") n1.inputs['Scale'].default_value = world_cfg["ground_noise_scale_1"] n1.inputs['Detail'].default_value = 8.0 n1.inputs['Roughness'].default_value = 0.7 n2 = nt.nodes.new("ShaderNodeTexNoise") n2.inputs['Scale'].default_value = world_cfg["ground_noise_scale_2"] n2.inputs['Detail'].default_value = 6.0 mixn = nt.nodes.new("ShaderNodeMix") mixn.data_type = 'RGBA' mixn.inputs['Factor'].default_value = 0.45 ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].color = tuple(world_cfg["ground_color_low"]) ramp.color_ramp.elements[1].color = tuple(world_cfg["ground_color_high"]) bsdf = nt.nodes.new("ShaderNodeBsdfDiffuse") out = nt.nodes.new("ShaderNodeOutputMaterial") nt.links.new(coord.outputs['Object'], n1.inputs['Vector']) nt.links.new(coord.outputs['Object'], n2.inputs['Vector']) nt.links.new(n1.outputs['Fac'], mixn.inputs[6]) nt.links.new(n2.outputs['Fac'], mixn.inputs[7]) nt.links.new(mixn.outputs[2], ramp.inputs['Fac']) nt.links.new(ramp.outputs['Color'], bsdf.inputs['Color']) nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface']) return m def build_grounded_world(sc, ctr, floor, figs, world_cfg): bpy.ops.mesh.primitive_plane_add(size=world_cfg["ground_size"], location=(ctr.x, ctr.y, floor)) gnd = bpy.context.active_object gnd.name = "ground" gnd.data.materials.append(make_ground_material(world_cfg)) clay = bpy.data.materials.new("clay") clay.use_nodes = True bs = clay.node_tree.nodes.get("Principled BSDF") bs.inputs['Base Color'].default_value = tuple(world_cfg["clay_color"]) bs.inputs['Roughness'].default_value = world_cfg["clay_roughness"] for o in figs: if o.name.startswith("annot_"): continue o.data.materials.clear() o.data.materials.append(clay) sun = bpy.data.objects.new("sun", bpy.data.lights.new("s", type='SUN')) sc.collection.objects.link(sun) sun.data.energy = world_cfg["sun_energy"] sun.rotation_euler = ( math.radians(world_cfg["sun_elevation_deg"]), 0, math.radians(world_cfg["sun_rotation_z_deg"]), ) sc.world.use_nodes = True sc.world.node_tree.nodes["Background"].inputs[0].default_value = tuple(world_cfg["world_color"]) sc.world.node_tree.nodes["Background"].inputs[1].default_value = 1.0 return gnd def beauty_mode(sc, w, h, fps, taa_samples=16): sc.render.engine = 'BLENDER_EEVEE' sc.render.film_transparent = False sc.render.image_settings.color_mode = 'RGB' sc.render.image_settings.file_format = 'PNG' sc.render.resolution_x = w sc.render.resolution_y = h sc.render.fps = fps sc.eevee.taa_render_samples = taa_samples # -------------------------------------------------------------------------- # 3.4 — cubic-bezier easing, Newton-solved (preserved exactly) # -------------------------------------------------------------------------- def bez(p1x, p1y, p2x, p2y): def bx(t): return 3 * p1x * t * (1 - t) ** 2 + 3 * p2x * t * t * (1 - t) + t ** 3 def by(t): return 3 * p1y * t * (1 - t) ** 2 + 3 * p2y * t * t * (1 - t) + t ** 3 def dbx(t): return 3 * p1x * (1 - t) * (1 - 3 * t) + 3 * p2x * t * (2 - 3 * t) + 3 * t * t def f(x): if x <= 0: return 0.0 if x >= 1: return 1.0 t = x for _ in range(8): d = dbx(t) if abs(d) < 1e-7: break t = min(1, max(0, t - (bx(t) - x) / d)) return by(t) return f def leg(f, s, e, ease, lag=0): if e <= s: return 1.0 x = (f - s - lag) / (e - s) return ease(min(1.0, max(0.0, x))) # -------------------------------------------------------------------------- # beat timeline — generalised N-beat version of the original two-leg logic # -------------------------------------------------------------------------- _ANNOTS = [] # set by cmd_render; annotations are shown during frustum fits only def _face_camera(o, cpos, d): """orient a flat annot_* glyph so it faces cpos with local +X along the screen projection of d""" v = (o.location - cpos).normalized(); x = d - v * d.dot(v) if x.length < 1e-4: x = Vector((0, 0, 1)) - v * v.z x.normalize(); z = -v; y = z.cross(x).normalized(); x = y.cross(z).normalized() q = Matrix((x, y, z)).transposed().to_4x4().to_quaternion() if any(m.name.startswith("frost") for m in o.data.materials): from mathutils import Quaternion as _Q q = q @ _Q((1, 0, 0), math.radians(float(o.get("annot_tilt", 16)))) # frosted slab: show a sliver of its edge o.rotation_mode = 'QUATERNION'; o.rotation_quaternion = q def resolve_beats(sc, cam, cd, ctr, size, ref, beats, tmp_dir): """Solve each beat's (radius, aim, shift) up front. beat[0] must be fit="match" (the frame-1 solve). Any beat with fit="frustum" gets the frustum-shift fit. Everything else is placed straight from its radius_mult * R0 with no fit. Mutates each beat dict in place with _radius, _aim, _shift_x, _shift_y, _az, _el.""" if beats[0].get("fit") != "match": raise ValueError("beats[0] must have fit: 'match' — it is the frame-1 solve") b0 = beats[0] aim0 = Vector((ctr.x, ctr.y, ctr.z + size.z * b0.get("aim_height_frac", 0.10))) R0, aim0, bbox0 = solve_match_frame(sc, cam, cd, ctr, size, ref, b0["azimuth"], tmp_dir) b0["_radius"], b0["_aim"] = R0, aim0 b0["_shift_x"], b0["_shift_y"] = 0.0, 0.0 b0["_az"], b0["_el"] = b0["azimuth"], b0.get("elevation", 0.0) b0["_bbox"] = bbox0 for b in beats[1:]: aim = Vector((ctr.x, ctr.y, ctr.z + size.z * b.get("aim_height_frac", 0.0))) b["_az"], b["_el"] = b["azimuth"], b.get("elevation", 0.0) if b.get("fit") == "frustum": R_guess = R0 * b.get("radius_mult", 1.0) th_frac = b.get("fit_target_height_frac", 0.72) for _o in _ANNOTS: _o.hide_render = not (int(list(_o.get("annot_show", [1, 9999]))[1]) > 40) # late-showing set only (the pull arrows) R, sx, sy = solve_frustum_fit(sc, cam, cd, ref, b["_az"], b["_el"], aim, R_guess, th_frac, tmp_dir) for _o in _ANNOTS: _o.hide_render = True b["_radius"], b["_aim"], b["_shift_x"], b["_shift_y"] = R, aim, sx, sy else: b["_radius"], b["_aim"] = R0 * b.get("radius_mult", 1.0), aim b["_shift_x"], b["_shift_y"] = 0.0, 0.0 return R0, aim0 def build_easing(timing): swing = bez(*timing["swing_bezier"]) dolly = bez(*timing["dolly_bezier"]) rise = bez(*timing["rise_bezier"]) lead = timing["elevation_lead_frames"] lag = timing["dolly_lag_frames"] return swing, dolly, rise, lead, lag def frame_schedule(beats): """beats[0].hold_frames is the opening hold. Every beat after that contributes whip_in_frames (transition into it) then hold_frames (dwell once there). Returns (total_frames, legs) where legs is a list of (frame_start_of_whip, frame_end_of_whip, beat_from, beat_to).""" f = beats[0].get("hold_frames", 10) legs = [] for i in range(1, len(beats)): whip = beats[i].get("whip_in_frames", 24) hold = beats[i].get("hold_frames", 20) legs.append((f, f + whip, beats[i - 1], beats[i])) f += whip + hold return f, legs def animate_camera(cam, cd, beats, timing): swing, dolly, rise, lead, lag = build_easing(timing) total, legs = frame_schedule(beats) drift_pct = timing["radius_drift_pct"] / 100.0 bpy.context.preferences.edit.keyframe_new_interpolation_type = 'LINEAR' for f in range(1, total + 1): # find which leg we're in/after (a frame past the last whip end stays on the last leg, t clamped to 1) s = e = None b_from = b_to = None for (ls, le, lf, lt) in legs: if f <= le: s, e, b_from, b_to = ls, le, lf, lt break if s is None: s, e, b_from, b_to = legs[-1] ts = leg(f, s, e, swing) td = leg(f, s, e, dolly, lag) tr = leg(f, s, e, rise, -lead) az = b_from["_az"] + (b_to["_az"] - b_from["_az"]) * ts el = b_from["_el"] + (b_to["_el"] - b_from["_el"]) * tr rr = b_from["_radius"] + (b_to["_radius"] - b_from["_radius"]) * td am = b_from["_aim"].lerp(b_to["_aim"], ts) sx = b_from["_shift_x"] + (b_to["_shift_x"] - b_from["_shift_x"]) * ts sy = b_from["_shift_y"] + (b_to["_shift_y"] - b_from["_shift_y"]) * ts rr *= 1.0 - drift_pct * (f / total) place_polar(cam, cd, am, az, el, rr, sx, sy) cd.keyframe_insert("shift_x", frame=f) cd.keyframe_insert("shift_y", frame=f) cam.keyframe_insert("location", frame=f) cam.keyframe_insert("rotation_euler", frame=f) return total # -------------------------------------------------------------------------- # render + encode # -------------------------------------------------------------------------- def animate_annotations(sc, figs, total_frames, cam=None): """05 Sep 2026 — 3D annotations (objects named annot_*). Each is a flat arrow glyph built along its local +X. Per frame: BILLBOARD it to the (animated) camera with +X along the screen-projection of annot_dir, so it always reads as a clean 2D arrow like his drawings; nudge it along annot_dir (quick out, slow back); show only inside annot_show [first,last] frames (fades in/out over 6 frames by scale). GLB extras → custom props. No annot_ objects → no-op.""" ann = [o for o in figs if o.name.startswith("annot_") and "annot_dir" in o] if not ann: return 0 cam = cam or sc.camera for o in ann: if o.animation_data: o.animation_data_clear() o.rotation_mode = 'QUATERNION' for f in range(1, total_frames + 1): sc.frame_set(f) cpos = cam.matrix_world.translation.copy() for o in ann: d = Vector(tuple(o["annot_dir"])).normalized() amp = float(o.get("annot_amp", 0.035)); per = max(4, int(o.get("annot_period", 30))); ph = float(o.get("annot_phase", 0.0)) show = list(o.get("annot_show", [1, total_frames])); f0, f1 = int(show[0]), int(show[1]) base = Vector(tuple(o.get("annot_base", o.location))) if "annot_base" not in o: o["annot_base"] = list(o.location) t = ((f - 1) / per + ph) % 1.0 style = str(o.get("annot_motion", "glide")) if style == "pop": # old: quick out, slow back k = (1 - (1 - t / 0.3) ** 3) if t < 0.3 else (1 - ((t - 0.3) / 0.7) ** 2 * (3 - 2 * (t - 0.3) / 0.7)) else: # 06 Sep 2026 — his note: "smooth and elite, not bouncy": a slow sine breath, ±amp k = 0.5 - 0.5 * math.cos(2 * math.pi * t) pos = base + d * (amp * k) v = (pos - cpos).normalized() # view ray to the arrow x = d - v * d.dot(v) # screen-plane component of the arrow direction if x.length < 1e-4: x = Vector((0, 0, 1)) - v * v.z x.normalize(); z = -v; y = z.cross(x).normalized(); x = y.cross(z).normalized() m = Matrix((x, y, z)).transposed().to_4x4(); q = m.to_quaternion() if any(mm.name.startswith("frost") for mm in o.data.materials): from mathutils import Quaternion as _Q q = q @ _Q((1, 0, 0), math.radians(float(o.get("annot_tilt", 16)))) o.location = pos; o.rotation_quaternion = q fade = float(o.get("annot_fade", 14)) def _ss(u): u = min(1.0, max(0.0, u)); return u * u * (3 - 2 * u) # smoothstep, no pop sc_ = _ss((f - f0 + 1) / fade) * _ss((f1 - f + 1) / fade) if f0 <= f <= f1 else 0.0 sc_ = 0.7 + 0.3 * sc_ if sc_ > 0 else 0.0 # settle from 70% → 100%, never from zero o.scale = (sc_, sc_, sc_) if sc_ > 0 else (0.001, 0.001, 0.001) o.keyframe_insert("location", frame=f); o.keyframe_insert("rotation_quaternion", frame=f); o.keyframe_insert("scale", frame=f) for o in ann: print(f"ANNOT {o.name}: billboard + nudge, shown frames {list(o.get('annot_show',[1,total_frames]))}") return len(ann) def render_sequence(sc, total_frames, frame_dir): os.makedirs(frame_dir, exist_ok=True) sc.frame_start = 1 sc.frame_end = total_frames sc.render.filepath = os.path.join(frame_dir, "f_") bpy.ops.render.render(animation=True) def encode_prores4444(frame_dir, out_path, fps): os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", os.path.join(frame_dir, "f_%04d.png"), "-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-vendor", "apl0", out_path] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: print(r.stdout); print(r.stderr); raise RuntimeError("ffmpeg prores encode failed") def encode_mp4(frame_dir, out_path, fps): os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) cmd = [ "ffmpeg", "-y", "-framerate", str(fps), "-i", os.path.join(frame_dir, "f_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16", out_path, ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: print(r.stdout) print(r.stderr) raise RuntimeError("ffmpeg encode failed") print(f"ENCODED {out_path}") # -------------------------------------------------------------------------- # frame-clipping guard # -------------------------------------------------------------------------- def clip_guard(sc, cam, cd, gnd, total_frames, w, h, cfg, tmp_dir): if not cfg.get("enabled", True): return [] scale = cfg.get("scale", 0.35) cw, ch = max(64, int(w * scale)), max(64, int(h * scale)) margin = cfg.get("margin_frac", 0.01) mx_px, my_px = margin * cw, margin * ch gnd_was_hidden = gnd.hide_render gnd.hide_render = True silhouette_mode(sc, cw, ch) sc.frame_start = 1 sc.frame_end = total_frames offenders = [] for f in range(1, total_frames + 1): sc.frame_set(f) tmp_png = os.path.join(tmp_dir, "clip_check.png") bw, bh, bx, by = render_alpha_bbox(sc, tmp_png) if bw == 0: continue touches = [] if bx <= mx_px: touches.append("left") if by <= my_px: touches.append("top") if (bx + bw) >= (cw - mx_px): touches.append("right") if (by + bh) >= (ch - my_px): touches.append("bottom") if touches: offenders.append((f, touches)) gnd.hide_render = gnd_was_hidden if offenders: print("!" * 60) print(f"CLIP GUARD: subject touches frame edge on {len(offenders)} frame(s):") for f, touches in offenders: print(f" frame {f}: {', '.join(touches)}") print("!" * 60) else: print(f"CLIP GUARD: clean — no edge contact across {total_frames} frames") return offenders # -------------------------------------------------------------------------- # subcommand: render # -------------------------------------------------------------------------- def cmd_render(args): job = load_job(args.job) sc = bpy.context.scene clear_scene() figs = import_and_decimate(job["mesh"], job["decimate_ratio"]) # 05 Sep 2026 — annotations (annot_*) are NOT the subject: they must not steer the frame-1 match, the frustum fits, # the world centre or the clip guard, and they cast no shadow. Body-only figs drive every solve. annots = [o for o in figs if o.name.startswith("annot_")] body = [o for o in figs if not o.name.startswith("annot_")] for o in annots: o.visible_shadow = False _ANNOTS[:] = annots ctr, size, floor = mesh_bounds(body) cam, cd = make_camera(job["lens_mm"]) tmp_dir = tempfile.mkdtemp(prefix="frozen_camera_solve_") try: beats = job["beats"] for o in annots: o.hide_render = True R0, AIM0 = resolve_beats(sc, cam, cd, ctr, size, job["reference"], beats, tmp_dir) for o in annots: o.hide_render = False gnd = build_grounded_world(sc, ctr, floor, figs, job["world"]) w, h = job["resolution"] beauty_mode(sc, w, h, job["fps"]) total = animate_camera(cam, cd, beats, job["timing"]) print(f"TOTAL FRAMES {total} ({total / job['fps']:.2f}s @ {job['fps']}fps)") animate_annotations(sc, figs, total, cam) frame_dir = job.get("frame_work_dir") or tempfile.mkdtemp(prefix="frozen_camera_frames_") if job.get("annot_only"): # 05 Sep 2026 — arrows-only alpha pass: identical camera, body + ground hidden, transparent film. # Output is ProRes 4444 (.mov) so the arrows drop straight over the Seedance shot in Premiere. _frosted = any(m.name.startswith("frost") for o in annots for m in o.data.materials) for o in body: if _frosted: o.is_holdout = True # body stays as a HOLDOUT: it occludes the arrows and renders as alpha 0 else: o.hide_render = True gnd.hide_render = True sc.render.film_transparent = True sc.render.image_settings.color_mode = 'RGBA' # beauty_mode writes RGB — the alpha pass needs the channel sc.render.image_settings.color_depth = '16' sc.view_settings.view_transform = 'Standard' # exact brand colours, no filmic roll-off on the overlay frosted = any(m.name.startswith("frost") for o in annots for m in o.data.materials) if frosted: # 05 Sep 2026 — frosted-glass arrows (his approved look): render this pass in Cycles under a neutral studio # world so the glass gets real soft highlights; materials untouched. import glob as _glob sc.render.engine = 'CYCLES'; sc.cycles.samples = int(job.get("annot_samples", 64)); sc.cycles.use_denoising = True sc.cycles.transparent_max_bounces = 16; sc.cycles.transmission_bounces = 12 try: sc.cycles.device = 'GPU' except Exception: pass aw = bpy.data.worlds.new("annot_world"); aw.use_nodes = True; nt = aw.node_tree; env = nt.nodes.new("ShaderNodeTexEnvironment") hdr = _glob.glob("/Applications/Blender.app/Contents/Resources/*/datafiles/studiolights/world/interior.exr") if hdr: env.image = bpy.data.images.load(hdr[0]); nt.links.new(env.outputs[0], nt.nodes["Background"].inputs[0]) nt.nodes["Background"].inputs[1].default_value = 1.1; sc.world = aw # (was `w` — shadowed the frame width and crashed the clip guard) for o in [x for x in sc.objects if x.type == 'LIGHT']: o.hide_render = True kl = bpy.data.lights.new("annot_key", "AREA"); kl.shape = 'RECTANGLE'; kl.size = 3.0; kl.size_y = 0.4; kl.energy = float(job.get("annot_key_energy", 900)) ko = bpy.data.objects.new("annot_key", kl); sc.collection.objects.link(ko); ko.parent = cam ko.location = (-1.6, 1.4, -0.4); ko.rotation_euler = (math.radians(-35), math.radians(-40), 0) # up-left of the lens, travels with it print("ANNOT-ONLY PASS (frosted): Cycles, studio world, camera-parented key, materials kept, body + ground hidden") else: for o in annots: # flat glyphs: pure emission → exact brand colour for m in o.data.materials: b = m.node_tree.nodes.get("Principled BSDF") if b: b.inputs["Base Color"].default_value = (0, 0, 0, 1); b.inputs["Emission Strength"].default_value = 1.0 print("ANNOT-ONLY PASS: body + ground hidden, film transparent, RGBA 16-bit, Standard view, pure-emission arrows") render_sequence(sc, total, frame_dir) if not job.get("annot_only"): # nothing to guard in an arrows-only pass (body hidden, world swapped) for o in annots: o.hide_render = True clip_guard(sc, cam, cd, gnd, total, w, h, job["clip_guard"], tmp_dir) for o in annots: o.hide_render = False if job.get("annot_only"): encode_prores4444(frame_dir, job["output"], job["fps"]) else: encode_mp4(frame_dir, job["output"], job["fps"]) if not args.keep_frames and not job.get("frame_work_dir"): shutil.rmtree(frame_dir, ignore_errors=True) b0 = beats[0]["_bbox"] print(f"MATCH-FRAME RESULT: bbox {b0[0]}x{b0[1]} centred ({b0[2]:.0f},{b0[3]:.0f})") finally: shutil.rmtree(tmp_dir, ignore_errors=True) # -------------------------------------------------------------------------- # subcommand: contact-sheet # -------------------------------------------------------------------------- def cmd_contact_sheet(args): job = load_job(args.job) sc = bpy.context.scene clear_scene() figs = import_and_decimate(job["mesh"], job["decimate_ratio"]) ctr, size, floor = mesh_bounds(figs) cam, cd = make_camera(job["lens_mm"]) tile_w, tile_h = [int(v) for v in args.tile.lower().split("x")] sc.render.engine = 'BLENDER_WORKBENCH' sc.render.film_transparent = False sc.render.resolution_x = tile_w sc.render.resolution_y = tile_h sc.render.image_settings.file_format = 'PNG' sc.render.image_settings.color_mode = 'RGB' sh = sc.display.shading sh.light = 'STUDIO' sh.color_type = 'SINGLE' sh.single_color = (0.68, 0.68, 0.68) sh.show_cavity = True sh.cavity_type = 'BOTH' sc.world.color = (0.10, 0.10, 0.11) radius = size.length * 2.0 # generous — never clip the figure against a tile edge aim = Vector((ctr.x, ctr.y, ctr.z + size.z * 0.10)) elevations = [float(v) for v in args.elevations.split(",")] n = args.azimuth_steps tmp_dir = tempfile.mkdtemp(prefix="frozen_camera_sheet_") tile_paths = [] try: for el in elevations: for i in range(n): az = 360.0 * i / n place_polar(cam, cd, aim, az, el, radius) tp = os.path.join(tmp_dir, f"tile_{el:+06.1f}_{az:06.1f}.png") sc.render.filepath = tp bpy.ops.render.render(write_still=True) labelled = tp.replace(".png", "_lbl.png") subprocess.run([ "magick", tp, "-gravity", "South", "-background", "black", "-splice", "0x28", "-gravity", "South", "-fill", "white", "-font", LABEL_FONT, "-pointsize", "20", "-annotate", "+0+4", f"az {az:.0f} el {el:.0f}", labelled, ], check=True) tile_paths.append(labelled) out = args.out or os.path.join(os.path.dirname(os.path.abspath(args.job)), "contact_sheet.png") os.makedirs(os.path.dirname(out) or ".", exist_ok=True) montage_cmd = ["magick", "montage"] + tile_paths + [ "-tile", f"{n}x{len(elevations)}", "-geometry", "+2+2", "-background", "black", "-font", LABEL_FONT, out, ] r = subprocess.run(montage_cmd, capture_output=True, text=True) if r.returncode != 0: print(r.stdout, r.stderr) raise RuntimeError("magick montage failed") print(f"CONTACT SHEET: {out} ({n} azimuths x {len(elevations)} elevations)") finally: shutil.rmtree(tmp_dir, ignore_errors=True) # -------------------------------------------------------------------------- # subcommand: solve-azimuth # -------------------------------------------------------------------------- def cmd_solve_azimuth(args): job = load_job(args.job) sc = bpy.context.scene ref = job["reference"] target_aspect = ref["_w"] / ref["_h"] clear_scene() figs = import_and_decimate(job["mesh"], job["decimate_ratio"]) ctr, size, floor = mesh_bounds(figs) cam, cd = make_camera(job["lens_mm"]) az_min, az_max = [float(v) for v in args.range.split(",")] # Must be generous enough that the silhouette never clips the frame at ANY # azimuth (a clipped bbox measures a cropped rectangle, not the figure's # true aspect, and corrupts the whole sweep). 2.0x the mesh's diagonal # clears this pose with margin; tightening it re-introduces exactly the # clipping bug the --job's clip guard exists to catch elsewhere. radius = size.length * 2.0 aim = Vector((ctr.x, ctr.y, ctr.z + size.z * 0.10)) silhouette_mode(sc, ref["width"], ref["height"]) tmp_dir = tempfile.mkdtemp(prefix="frozen_camera_az_") def aspect_at(az): place_polar(cam, cd, aim, az, 0.0, radius) tmp_png = os.path.join(tmp_dir, "az.png") bw, bh, _, _ = render_alpha_bbox(sc, tmp_png) if bw == 0 or bh == 0: return None return bw / bh try: n_steps = int(round((az_max - az_min) / args.step)) + 1 samples = [] for i in range(n_steps): az = az_min + i * args.step asp = aspect_at(az) if asp is None: continue samples.append((az, asp)) print(f"AZ-SWEEP az={az:.1f} aspect={asp:.4f} target={target_aspect:.4f}") # find every bracket where the aspect crosses the target — a silhouette's # aspect ratio is NOT one-to-one over 360°, so several will usually exist brackets = [] for i in range(len(samples) - 1): az0, a0 = samples[i] az1, a1 = samples[i + 1] if (a0 - target_aspect) == 0 or (a0 - target_aspect) * (a1 - target_aspect) <= 0: brackets.append((az0, a0, az1, a1)) if not brackets: best = min(samples, key=lambda s: abs(s[1] - target_aspect)) print(f"AZ-SOLVE: no sign change found in sweep; closest single sample az={best[0]:.1f} aspect={best[1]:.4f}") best_az = best[0] else: if len(brackets) > 1: print(f"AZ-SOLVE: {len(brackets)} candidate crossings found (aspect ratio is ambiguous over 360°):") for (bz0, ba0, bz1, ba1) in brackets: print(f" candidate az~[{bz0:.1f},{bz1:.1f}] aspect [{ba0:.4f},{ba1:.4f}]") if args.hint is not None: def dist_to_hint(b): bz0, _, bz1, _ = b lo, hi = min(bz0, bz1), max(bz0, bz1) if lo <= args.hint <= hi: return 0.0 return min(abs(args.hint - lo), abs(args.hint - hi)) az0, a0, az1, a1 = min(brackets, key=dist_to_hint) print(f"AZ-SOLVE: picked crossing nearest --hint {args.hint:.1f}") else: if len(brackets) > 1: print("AZ-SOLVE: no --hint given; falling back to the sharpest bracket — " "pass --hint to disambiguate correctly.") az0, a0, az1, a1 = min(brackets, key=lambda b: abs(b[3] - b[1])) frac = (a0 - target_aspect) / (a0 - a1) if a0 != a1 else 0.5 best_az = az0 + frac * (az1 - az0) print(f"AZ-SOLVE bracket [{az0:.1f}->{a0:.4f}, {az1:.1f}->{a1:.4f}] linear interp -> az={best_az:.2f}") # bisection refine: shrink the bracket toward the target aspect lo_az, lo_a, hi_az, hi_a = az0, a0, az1, a1 for i in range(args.refine_iters): mid_az = (lo_az + hi_az) / 2.0 mid_a = aspect_at(mid_az) if mid_a is None: break print(f"AZ-REFINE {i} az={mid_az:.2f} aspect={mid_a:.4f}") if (lo_a - target_aspect) * (mid_a - target_aspect) <= 0: hi_az, hi_a = mid_az, mid_a else: lo_az, lo_a = mid_az, mid_a if lo_a != hi_a: frac = (lo_a - target_aspect) / (lo_a - hi_a) best_az = lo_az + frac * (hi_az - lo_az) else: best_az = (lo_az + hi_az) / 2.0 print(f"AZIMUTH SOLVER RESULT: az = {best_az:.1f} deg (target aspect {target_aspect:.4f})") finally: shutil.rmtree(tmp_dir, ignore_errors=True) # -------------------------------------------------------------------------- # main # -------------------------------------------------------------------------- def main(): parser = build_parser() args = parser.parse_args(blender_argv()) if args.cmd == "render": cmd_render(args) elif args.cmd == "contact-sheet": cmd_contact_sheet(args) elif args.cmd == "solve-azimuth": cmd_solve_azimuth(args) if __name__ == "__main__": main()