# Preset blender-product — photoreal studio turntable of a hero object (Cycles, Metal GPU).
# Usage: blender -b --factory-startup -P scene.py -- --out
/frames/f_ --seconds 10 --fps 30 \
# --samples 64 --res 1920x1080 [--accent 0071e3] [--glyph "/"] [--preview]
# Renders a PNG sequence (never MP4 directly); encode/overlay afterwards with ffmpeg or HyperFrames.
import bpy, sys, math, argparse
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--seconds", type=float, default=10)
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--samples", type=int, default=64)
ap.add_argument("--res", default="1920x1080")
ap.add_argument("--accent", default="0071e3")
ap.add_argument("--glyph", default="/")
ap.add_argument("--preview", action="store_true", help="render frames 1, mid, last at 25%%")
a = ap.parse_args(argv)
W, H = map(int, a.res.split("x"))
FR = int(a.seconds * a.fps)
hexrgb = lambda h: tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
srgb2lin = lambda c: c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
ACC = tuple(srgb2lin(c) for c in hexrgb(a.accent)) + (1,)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc = bpy.context.scene
sc.render.engine = "CYCLES"
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "METAL"
prefs.get_devices()
for d in prefs.devices:
d.use = True
sc.cycles.device = "GPU"
sc.cycles.samples = a.samples
sc.cycles.use_denoising = True
sc.cycles.use_adaptive_sampling = True
sc.cycles.adaptive_threshold = 0.03
sc.render.resolution_x, sc.render.resolution_y = W, H
sc.render.resolution_percentage = 25 if a.preview else 100
sc.render.fps = a.fps
sc.frame_start, sc.frame_end = 1, FR
sc.view_settings.view_transform = "AgX"
sc.view_settings.look = "AgX - Medium High Contrast"
sc.render.image_settings.file_format = "PNG"
sc.render.filepath = a.out
sc.render.use_overwrite = False # re-running resumes: finished frames are skipped
sc.render.use_placeholder = False # a crash must not leave an empty frame that resume would skip
def mat(name, base, rough, metal=0.0, coat=0.0, trans=0.0, emit=None):
m = bpy.data.materials.new(name)
m.use_nodes = True
p = m.node_tree.nodes["Principled BSDF"]
p.inputs["Base Color"].default_value = base
p.inputs["Roughness"].default_value = rough
p.inputs["Metallic"].default_value = metal
p.inputs["Coat Weight"].default_value = coat
p.inputs["Transmission Weight"].default_value = trans
if emit:
p.inputs["Emission Color"].default_value = emit[0]
p.inputs["Emission Strength"].default_value = emit[1]
return m
# Cyclorama: floor curving into the back wall (seamless studio sweep)
bpy.ops.mesh.primitive_plane_add(size=1)
cyc = bpy.context.object
me = cyc.data
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.delete(type="VERT")
bpy.ops.object.mode_set(mode="OBJECT")
verts, faces, prof = [], [], []
for i in range(10): # profile in YZ: flat floor (no duplicate vertex → no degenerate black faces)
prof.append((-12 + i * 1.4, 0.0))
for j in range(1, 17): # quarter circle up into the back wall
ang = j / 16 * (math.pi / 2)
prof.append((0.6 + 6 * math.sin(ang), 6 - 6 * math.cos(ang)))
for x in (-30, 30):
for (y, z) in prof:
verts.append((x, y, z))
n = len(prof)
for i in range(n - 1):
faces.append((i, i + 1, n + i + 1, n + i))
me.from_pydata(verts, [], faces)
me.update()
for f in me.polygons:
f.use_smooth = True
cyc.data.materials.append(mat("Cyc", (0.92, 0.925, 0.94, 1), 0.55))
# Hero: rounded tile (app-icon proportion) in frosted glass on a satin base, accent glyph inside
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 1.15))
tile = bpy.context.object
tile.scale = (1.7, 0.42, 1.7)
bev = tile.modifiers.new("bev", "BEVEL"); bev.width = 0.12; bev.segments = 10
tile.modifiers.new("sub", "SUBSURF").levels = 1
bpy.ops.object.shade_smooth()
tile.data.materials.append(mat("Glass", (0.96, 0.97, 1.0, 1), 0.08, trans=1.0, coat=1.0))
bpy.ops.object.text_add(location=(0, -0.02, 1.15), rotation=(math.pi / 2, 0, 0))
glyph = bpy.context.object
glyph.data.body = a.glyph
glyph.data.align_x = "CENTER"; glyph.data.align_y = "CENTER"
glyph.data.size = 1.25; glyph.data.extrude = 0.09; glyph.data.bevel_depth = 0.015
glyph.data.materials.append(mat("Accent", ACC, 0.25, coat=0.6, emit=(ACC, 0.6)))
bpy.ops.mesh.primitive_cylinder_add(radius=1.25, depth=0.16, location=(0, 0, 0.08), vertices=128)
base = bpy.context.object
base.modifiers.new("bev", "BEVEL").width = 0.04
bpy.ops.object.shade_smooth()
base.data.materials.append(mat("Satin", (0.2, 0.2, 0.22, 1), 0.32, metal=0.9))
hero = bpy.data.objects.new("Hero", None)
sc.collection.objects.link(hero)
for o in (tile, glyph):
o.parent = hero
hero.rotation_euler = (0, 0, 0)
hero.keyframe_insert("rotation_euler", frame=1)
hero.rotation_euler = (0, 0, math.radians(-360)) # full turn → seamless loop, glyph ends front-facing
hero.keyframe_insert("rotation_euler", frame=FR)
# linear spin so the loop point is invisible
try:
for fc in hero.animation_data.action.fcurves:
for k in fc.keyframe_points:
k.interpolation = "LINEAR"
except AttributeError: # Blender 5 layered actions
for layer in hero.animation_data.action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for fc in bag.fcurves:
for k in fc.keyframe_points:
k.interpolation = "LINEAR"
# Lights: big soft key, rim, and a cool fill
def area(name, loc, rot, size, power, color=(1, 1, 1)):
l = bpy.data.lights.new(name, "AREA"); l.size = size; l.energy = power; l.color = color
o = bpy.data.objects.new(name, l); o.location = loc; o.rotation_euler = rot
sc.collection.objects.link(o)
area("Key", (-4, -4, 5), (math.radians(50), 0, math.radians(-45)), 5, 900)
area("Rim", (3.5, 3, 3.5), (math.radians(-60), 0, math.radians(140)), 3, 600, (0.85, 0.9, 1))
area("Fill", (5, -3, 1.5), (math.radians(80), 0, math.radians(60)), 4, 200, (0.9, 0.95, 1))
world = bpy.data.worlds.new("W"); sc.world = world
world.use_nodes = True
world.node_tree.nodes["Background"].inputs[0].default_value = (0.9, 0.92, 0.95, 1)
world.node_tree.nodes["Background"].inputs[1].default_value = 0.35
# Camera: slow push-in with shallow depth of field
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
sc.collection.objects.link(cam); sc.camera = cam
cam.data.lens = 65
cam.data.dof.use_dof = True; cam.data.dof.aperture_fstop = 2.8; cam.data.dof.focus_object = tile
track = cam.constraints.new("TRACK_TO"); track.target = tile
cam.location = (2.2, -10.5, 2.4); cam.keyframe_insert("location", frame=1)
cam.location = (1.0, -8.6, 1.9); cam.keyframe_insert("location", frame=FR)
if a.preview:
for f in (1, FR // 2, FR):
sc.frame_set(f); sc.render.filepath = f"{a.out}preview_{f:04d}"
bpy.ops.render.render(write_still=True)
else:
bpy.ops.render.render(animation=True)