Files
Pr3tz/knot_animation/bake_export.py
T
2026-06-02 16:54:36 -04:00

476 lines
18 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.
"""
bake_export.py
--------------
Operator to bake the AnimKnots procedural animation into a fully
self-contained .blend file suitable for distributed render farms
(SheepIt, etc.).
The resulting file contains:
- One mesh object per unique frame, with hide_render / hide_viewport
keyframes so only the correct mesh is visible on each frame.
- All materials (preset, blend, project) referenced by the objects.
- Camera with baked delta_location keyframes (camera shake).
- Light, world, and render settings copied from the source scene.
- No addon dependencies — the file is fully standalone.
"""
from __future__ import annotations
import hashlib
import random
import struct
import bpy
from bpy_extras.io_utils import ExportHelper
from .constants import KNOT_OBJ_NAME
# ---------------------------------------------------------------------------
# Blender 5.0 compat — Action.fcurves was replaced with layered actions
# ---------------------------------------------------------------------------
def _get_action_fcurves(obj):
"""Return an iterable of FCurves for the given object's animation.
Blender 5.0 replaced ``action.fcurves`` with a layered animation
system (slots → layers → strips → channelbags → fcurves). This
helper abstracts both APIs so the rest of the module doesn't care.
"""
anim = obj.animation_data
if not anim or not anim.action:
return []
action = anim.action
# ── Blender 5.0+ (layered actions) ────────────────────────────────────
# Try the convenience helper first
try:
from bpy_extras.anim_utils import action_get_channelbag_for_slot # noqa: F401
slot = anim.action_slot
if slot is not None:
for layer in action.layers:
for strip in layer.strips:
cb = strip.channelbag(slot)
if cb is not None:
return cb.fcurves
return []
except (ImportError, AttributeError):
pass
# ── Blender 4.x (legacy) ──────────────────────────────────────────────
try:
return action.fcurves
except AttributeError:
return []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mesh_fingerprint(mesh: bpy.types.Mesh) -> str:
"""Compute a fast hash of a mesh's vertex positions + material index.
Used to detect consecutive identical frames so we can extend the
previous object's visibility window instead of creating a duplicate.
"""
h = hashlib.md5(usedforsecurity=False)
# Hash vertex positions
for v in mesh.vertices:
h.update(struct.pack('fff', *v.co))
# Hash which material slots are assigned
for slot_idx, slot in enumerate(mesh.materials):
name = slot.name if slot else ""
h.update(f"{slot_idx}:{name}".encode())
return h.hexdigest()
def _obj_transform_fingerprint(obj: bpy.types.Object) -> str:
"""Hash an object's location, rotation_euler, and scale."""
h = hashlib.md5(usedforsecurity=False)
h.update(struct.pack('fff', *obj.location))
h.update(struct.pack('fff', *obj.rotation_euler))
h.update(struct.pack('fff', *obj.scale))
return h.hexdigest()
def _keyframe_visibility(obj, frame: int, visible: bool) -> None:
"""Insert hide_render and hide_viewport keyframes for a single frame."""
obj.hide_render = not visible
obj.hide_viewport = not visible
obj.keyframe_insert(data_path="hide_render", frame=frame)
obj.keyframe_insert(data_path="hide_viewport", frame=frame)
def _set_constant_interpolation(obj) -> None:
"""Set all visibility F-curves to CONSTANT interpolation.
This prevents Blender from smoothly fading between True/False,
which would make objects partially visible on intermediate frames.
"""
for fc in _get_action_fcurves(obj):
if fc.data_path in ("hide_render", "hide_viewport"):
for kp in fc.keyframe_points:
kp.interpolation = 'CONSTANT'
# ---------------------------------------------------------------------------
# Main bake operator
# ---------------------------------------------------------------------------
class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
"""Bake the procedural animation into a standalone .blend file."""
bl_idname = "knot.bake_export"
bl_label = "Bake & Export for Render Farm"
bl_description = (
"Iterate every frame, convert the procedural knot to mesh, "
"and save a self-contained .blend with per-frame visibility keyframes. "
"The output file requires no addons and is render-farm ready"
)
bl_options = {'REGISTER'}
# ExportHelper provides the file browser
filename_ext = ".blend"
filter_glob: bpy.props.StringProperty(default="*.blend", options={'HIDDEN'})
# User-facing options shown in the file browser sidebar
use_render_resolution: bpy.props.BoolProperty(
name="Use Render Resolution",
description="Bake at the Render Curve / Bevel resolution (recommended for final output)",
default=True,
)
pack_textures: bpy.props.BoolProperty(
name="Pack Textures",
description="Pack all external textures into the .blend file",
default=True,
)
chunk_frames: bpy.props.IntProperty(
name="Frames per File",
description=(
"Split the export into multiple .blend files, each covering this many frames. "
"Set to 0 for a single file (no splitting)"
),
default=0,
min=0,
max=10000,
)
# -------------------------------------------------------------------
# Chunk bake helper
# -------------------------------------------------------------------
def _bake_chunk(self, context, chunk_start, chunk_end, filepath):
"""Bake a single frame range and save to *filepath*.
Creates mesh objects, sets up visibility keyframes, bakes camera
shake, removes the AnimKnot, packs data, saves a copy, then
cleans up all baked objects from the session so the next chunk
starts fresh.
Returns the list of baked_objects so the caller can report stats.
"""
scene = context.scene
glob = scene.knot_globals
# -----------------------------------------------------------
# Phase 1: Bake per-frame geometry
# -----------------------------------------------------------
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
prev_fingerprint = None
prev_transform_fp = None
bake_coll = bpy.data.collections.new("BakedAnimation")
scene.collection.children.link(bake_coll)
for f in range(chunk_start, chunk_end + 1):
scene.frame_set(f)
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not knot_obj:
continue
depsgraph = context.evaluated_depsgraph_get()
eval_obj = knot_obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
mesh.name = f"BakedKnot_Mesh_F{f}"
mesh_fp = _mesh_fingerprint(mesh)
transform_fp = _obj_transform_fingerprint(knot_obj)
if (mesh_fp == prev_fingerprint
and transform_fp == prev_transform_fp
and baked_objects):
bpy.data.meshes.remove(mesh)
_, first_f, _ = baked_objects[-1]
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
continue
prev_fingerprint = mesh_fp
prev_transform_fp = transform_fp
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
obj.location = knot_obj.location.copy()
obj.rotation_euler = knot_obj.rotation_euler.copy()
obj.scale = knot_obj.scale.copy()
for src_slot in knot_obj.material_slots:
obj.data.materials.append(src_slot.material)
obj.display_type = knot_obj.display_type
bake_coll.objects.link(obj)
baked_objects.append((obj, f, f))
if not baked_objects:
bake_coll_check = bpy.data.collections.get("BakedAnimation")
if bake_coll_check:
bpy.data.collections.remove(bake_coll_check)
return []
# -----------------------------------------------------------
# Phase 2: Visibility keyframes
# -----------------------------------------------------------
for obj, first_f, last_f in baked_objects:
if first_f > chunk_start:
_keyframe_visibility(obj, chunk_start, False)
if first_f > chunk_start + 1:
_keyframe_visibility(obj, first_f - 1, False)
_keyframe_visibility(obj, first_f, True)
if last_f != first_f:
_keyframe_visibility(obj, last_f, True)
if last_f < chunk_end:
_keyframe_visibility(obj, last_f + 1, False)
if last_f < chunk_end - 1:
_keyframe_visibility(obj, chunk_end, False)
_set_constant_interpolation(obj)
# -----------------------------------------------------------
# Phase 3: Camera shake
# -----------------------------------------------------------
cam = scene.camera
if cam and glob.camera_shake_amplitude > 0.0:
shake = glob.camera_shake_amplitude
for f in range(chunk_start, chunk_end + 1):
random.seed(int(f * 1000))
cam.delta_location = (
random.uniform(-shake, shake),
random.uniform(-shake, shake),
random.uniform(-shake, shake),
)
cam.keyframe_insert(data_path="delta_location", frame=f)
for fc in _get_action_fcurves(cam):
if fc.data_path == "delta_location":
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
elif cam:
cam.delta_location = (0.0, 0.0, 0.0)
# -----------------------------------------------------------
# Phase 4: Remove AnimKnot + handler for clean save
# -----------------------------------------------------------
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if knot_obj:
curve_data = knot_obj.data
bpy.data.objects.remove(knot_obj, do_unlink=True)
if curve_data and curve_data.users == 0:
bpy.data.curves.remove(curve_data)
from .handler import knot_frame_handler
handlers = bpy.app.handlers.frame_change_post
for h in list(handlers):
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
handlers.remove(h)
# -----------------------------------------------------------
# Phase 5: Set chunk frame range, pack, clean orphans
# -----------------------------------------------------------
orig_frame_start = scene.frame_start
orig_frame_end = scene.frame_end
orig_file_format = scene.render.image_settings.file_format
orig_color_mode = scene.render.image_settings.color_mode
scene.frame_start = chunk_start
scene.frame_end = chunk_end
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
try:
bpy.ops.file.make_paths_relative()
except RuntimeError:
pass
if self.pack_textures:
try:
bpy.ops.file.pack_all()
except RuntimeError:
self.report({'WARNING'}, "Some external files could not be packed.")
for mat in list(bpy.data.materials):
if mat.users == 0:
bpy.data.materials.remove(mat)
for m in list(bpy.data.meshes):
if m.users == 0:
bpy.data.meshes.remove(m)
for c in list(bpy.data.curves):
if c.users == 0:
bpy.data.curves.remove(c)
# -----------------------------------------------------------
# Phase 6: Save as copy
# -----------------------------------------------------------
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True)
# -----------------------------------------------------------
# Phase 7: Clean up — restore session for next chunk
# -----------------------------------------------------------
scene.frame_start = orig_frame_start
scene.frame_end = orig_frame_end
scene.render.image_settings.file_format = orig_file_format
scene.render.image_settings.color_mode = orig_color_mode
# Remove baked objects
for obj, _, _ in baked_objects:
if obj and obj.name in bpy.data.objects:
mesh_data = obj.data
bpy.data.objects.remove(obj, do_unlink=True)
if mesh_data and mesh_data.users == 0:
bpy.data.meshes.remove(mesh_data)
bake_coll_check = bpy.data.collections.get("BakedAnimation")
if bake_coll_check:
bpy.data.collections.remove(bake_coll_check)
# Remove camera shake keyframes
if cam:
cam_fcurves = _get_action_fcurves(cam)
fcurves_to_remove = [
fc for fc in cam_fcurves
if fc.data_path == "delta_location"
]
for fc in fcurves_to_remove:
try:
cam_fcurves.remove(fc)
except (TypeError, RuntimeError):
pass
cam.delta_location = (0.0, 0.0, 0.0)
# Re-register handler and rebuild the AnimKnot
handlers = bpy.app.handlers.frame_change_post
already_registered = any(
getattr(h, "__name__", "") == knot_frame_handler.__name__
for h in handlers
)
if not already_registered:
handlers.append(knot_frame_handler)
knot_frame_handler(scene)
return baked_objects
# -------------------------------------------------------------------
# execute
# -------------------------------------------------------------------
def execute(self, context):
scene = context.scene
glob = scene.knot_globals
# Validate
if len(scene.knot_list) == 0:
self.report({'ERROR'}, "Playlist is empty — nothing to bake.")
return {'CANCELLED'}
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not knot_obj:
self.report({'ERROR'}, f"AnimKnot object '{KNOT_OBJ_NAME}' not found.")
return {'CANCELLED'}
filepath = self.filepath
if not filepath.lower().endswith(".blend"):
filepath += ".blend"
frame_start = scene.frame_start
frame_end = scene.frame_end
total_frames = frame_end - frame_start + 1
original_frame = scene.frame_current
# Temporarily override preview resolution to render quality
orig_preview_res = glob.preview_resolution
orig_preview_bevel = glob.preview_bevel_resolution
if self.use_render_resolution:
glob.preview_resolution = glob.render_resolution
glob.preview_bevel_resolution = glob.render_bevel_resolution
# Build the list of (chunk_start, chunk_end, output_path) tuples
if self.chunk_frames > 0:
base = filepath[:-6] # strip ".blend"
chunks = []
idx = 1
cs = frame_start
while cs <= frame_end:
ce = min(cs + self.chunk_frames - 1, frame_end)
chunks.append((cs, ce, f"{base}_{idx:03d}.blend"))
cs = ce + 1
idx += 1
else:
chunks = [(frame_start, frame_end, filepath)]
self.report({'INFO'},
f"Baking {total_frames} frames into {len(chunks)} file(s)...")
total_unique = 0
for ci, (cs, ce, out_path) in enumerate(chunks):
self.report({'INFO'},
f"Chunk {ci+1}/{len(chunks)}: frames {cs}{ce}{out_path}")
baked = self._bake_chunk(context, cs, ce, out_path)
total_unique += len(baked)
# Restore resolution
if self.use_render_resolution:
glob.preview_resolution = orig_preview_res
glob.preview_bevel_resolution = orig_preview_bevel
# Restore frame
scene.frame_set(original_frame)
if len(chunks) == 1:
self.report({'INFO'},
f"Baked .blend saved to: {chunks[0][2]} "
f"({total_unique} unique meshes)")
else:
self.report({'INFO'},
f"Exported {len(chunks)} files "
f"({total_unique} unique meshes total)")
return {'FINISHED'}
def draw(self, context):
"""Draw options in the file browser sidebar."""
layout = self.layout
layout.label(text="Bake Options:", icon='SETTINGS')
layout.prop(self, "use_render_resolution")
layout.prop(self, "pack_textures")
layout.separator()
layout.prop(self, "chunk_frames")
scene = context.scene
total = scene.frame_end - scene.frame_start + 1
layout.separator()
layout.label(text=f"Frames: {scene.frame_start} {scene.frame_end} ({total} total)")
layout.label(text=f"Playlist: {len(scene.knot_list)} knots")
if self.chunk_frames > 0:
n_chunks = -(-total // self.chunk_frames) # ceil division
layout.label(text=f"Will produce {n_chunks} file(s)",
icon='FILE_BLEND')
else:
layout.label(text="Single file (no splitting)", icon='FILE_BLEND')