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