From 61e8889354b84a82c2baddc3b278ab0a448b80a2 Mon Sep 17 00:00:00 2001 From: Stefan Cepko Date: Tue, 2 Jun 2026 16:54:36 -0400 Subject: [PATCH] Export to blend file for cloud rendering --- knot_animation/__init__.py | 9 +- knot_animation/bake_export.py | 475 ++++++++++++++++++++++++++++++++++ knot_animation/ui.py | 9 + 3 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 knot_animation/bake_export.py diff --git a/knot_animation/__init__.py b/knot_animation/__init__.py index 5337e62..8205fc8 100644 --- a/knot_animation/__init__.py +++ b/knot_animation/__init__.py @@ -64,9 +64,14 @@ if "bpy" in locals(): importlib.reload(operators) importlib.reload(ui) importlib.reload(scene_setup) + # bake_export may not exist from a prior session — import if needed + if "bake_export" in locals(): + importlib.reload(bake_export) + else: + from . import bake_export else: import bpy - from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup + from . import compat, constants, types, materials, geometry, handler, properties, operators, ui, scene_setup, bake_export from .compat import apply_compat_patches from .properties import ( @@ -86,6 +91,7 @@ from .operators import ( KNOT_OT_FitPlaylist, KNOT_OT_GenerateRandom, ) +from .bake_export import KNOT_OT_BakeExport from .ui import ( KNOT_UL_List, KNOT_UL_AllowedMaterialsList, @@ -130,6 +136,7 @@ classes = ( KNOT_OT_FitTimeline, KNOT_OT_FitPlaylist, KNOT_OT_GenerateRandom, + KNOT_OT_BakeExport, KNOT_PT_Panel, ) diff --git a/knot_animation/bake_export.py b/knot_animation/bake_export.py new file mode 100644 index 0000000..22ff3fa --- /dev/null +++ b/knot_animation/bake_export.py @@ -0,0 +1,475 @@ +""" +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') + diff --git a/knot_animation/ui.py b/knot_animation/ui.py index 3082abc..eebc884 100644 --- a/knot_animation/ui.py +++ b/knot_animation/ui.py @@ -408,3 +408,12 @@ class KNOT_PT_Panel(bpy.types.Panel): icon='INFO') gen_box.operator("knot.generate_random", icon='PLAY') + + # ── Export ──────────────────────────────────────────────────────────── + ex_box = layout.box() + ex_box.label(text="Export", icon='EXPORT') + ex_box.label(text="Bake all frames to a standalone .blend file", + icon='INFO') + row = ex_box.row() + row.scale_y = 1.4 + row.operator("knot.bake_export", icon='RENDER_ANIMATION')