Getting ready for deploy

This commit is contained in:
Stefan Cepko
2026-06-04 03:49:56 -04:00
parent 61e8889354
commit c3936955ea
12 changed files with 1085 additions and 420 deletions
+347 -300
View File
@@ -1,7 +1,7 @@
"""
bake_export.py
--------------
Operator to bake the AnimKnots procedural animation into a fully
Operator to bake the Pr3tz procedural animation into a fully
self-contained .blend file suitable for distributed render farms
(SheepIt, etc.).
@@ -26,42 +26,31 @@ from .constants import KNOT_OBJ_NAME
# ---------------------------------------------------------------------------
# Blender 5.0 compat — Action.fcurves was replaced with layered actions
# Blender 5.0 — layered Action API (Action.fcurves removed)
# ---------------------------------------------------------------------------
def _get_action_fcurves(obj):
"""Return an iterable of FCurves for the given object's animation.
"""Return an iterable of FCurves for the given object's active action slot.
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.
Blender 5.0 uses a layered animation system:
action → layers → strips → channelbag (per slot) → fcurves
"""
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
slot = anim.action_slot
if slot is None:
return []
except (ImportError, AttributeError):
pass
# ── Blender 4.x (legacy) ──────────────────────────────────────────────
try:
return action.fcurves
except AttributeError:
return []
for layer in action.layers:
for strip in layer.strips:
cb = strip.channelbag(slot)
if cb is not None:
return cb.fcurves
return []
# ---------------------------------------------------------------------------
@@ -147,240 +136,20 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
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,
split_export: bpy.props.BoolProperty(
name="Split Export",
description="Split the baked animation into multiple .blend files",
default=False,
)
frames_per_file: bpy.props.IntProperty(
name="Frames Per File",
description="Number of frames to include in each split file",
default=500,
min=1,
)
# -------------------------------------------------------------------
# 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:
@@ -396,58 +165,341 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
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
orig_frame_start = scene.frame_start
orig_frame_end = scene.frame_end
total_frames = orig_frame_end - orig_frame_start + 1
# Temporarily override preview resolution to render quality
self.report({'INFO'}, f"Baking {total_frames} frames...")
chunk_size = self.frames_per_file if self.split_export else total_frames
# We will loop over chunks and bake each as a separate file
try:
import os
first_chunk = True
for chunk_start in range(orig_frame_start, orig_frame_end + 1, chunk_size):
chunk_end = min(chunk_start + chunk_size - 1, orig_frame_end)
scene.frame_start = chunk_start
scene.frame_end = chunk_end
chunk_filepath = filepath
if self.split_export:
base, ext = os.path.splitext(filepath)
chunk_filepath = f"{base}_{chunk_start}-{chunk_end}{ext}"
self.report({'INFO'}, f"Baking chunk {chunk_start}-{chunk_end} to {chunk_filepath}...")
# pack_and_clean is True only on the first chunk so that external
# textures are packed exactly once. Subsequent chunks already
# have the packed images in memory from the first run.
result = self._bake_range(
context, chunk_filepath,
pack_and_clean=first_chunk,
)
first_chunk = False
if result != {'FINISHED'}:
self.report({'ERROR'}, f"Failed baking chunk {chunk_start}-{chunk_end}")
return {'CANCELLED'}
finally:
scene.frame_start = orig_frame_start
scene.frame_end = orig_frame_end
self.report({'INFO'}, "Baking complete.")
return {'FINISHED'}
def _bake_range(self, context, filepath, *, pack_and_clean: bool = True):
scene = context.scene
glob = scene.knot_globals
frame_start = scene.frame_start
frame_end = scene.frame_end
total_frames = frame_end - frame_start + 1
# If "Use Render Resolution" is on, temporarily override the preview
# resolution to match render resolution. The handler reads
# preview_resolution for viewport and render_resolution for renders,
# but frame_set() triggers a viewport-mode handler call. By
# equalising them we ensure the baked mesh uses 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)]
# -------------------------------------------------------------------
# Phase 1: Bake per-frame geometry into mesh objects
# -------------------------------------------------------------------
self.report({'INFO'},
f"Baking {total_frames} frames into {len(chunks)} file(s)...")
# Collect all baked objects so we can keyframe them afterwards
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
# Each entry: (object, first_visible_frame, last_visible_frame)
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)
prev_fingerprint = None
prev_transform_fp = None
# Restore resolution
# Create a dedicated collection to hold baked frames
bake_coll = bpy.data.collections.new("BakedAnimation")
scene.collection.children.link(bake_coll)
# Store the original frame so we can restore it later
original_frame = scene.frame_current
for f in range(frame_start, frame_end + 1):
# Set the frame — this triggers knot_frame_handler via
# the registered frame_change_post handler
scene.frame_set(f)
# Get the knot object (may have been recreated by the handler)
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not knot_obj:
continue
# Evaluate the depsgraph to get the final geometry
depsgraph = context.evaluated_depsgraph_get()
eval_obj = knot_obj.evaluated_get(depsgraph)
# Convert to mesh
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
mesh.name = f"BakedKnot_Mesh_F{f}"
# Compute fingerprints for duplicate detection
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):
# Identical to the previous frame — extend the previous
# object's visibility window and discard the duplicate mesh
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
# Create a new object for this frame
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
# Copy transform
obj.location = knot_obj.location.copy()
obj.rotation_euler = knot_obj.rotation_euler.copy()
obj.scale = knot_obj.scale.copy()
# Copy material slots
for src_slot in knot_obj.material_slots:
obj.data.materials.append(src_slot.material)
# Copy display settings
obj.display_type = knot_obj.display_type
bake_coll.objects.link(obj)
baked_objects.append((obj, f, f))
# Restore preview resolution if we overrode it
if self.use_render_resolution:
glob.preview_resolution = orig_preview_res
glob.preview_bevel_resolution = orig_preview_bevel
# Restore frame
# Restore original frame
scene.frame_set(original_frame)
if not baked_objects:
self.report({'ERROR'}, "No frames were baked — the handler may not be producing geometry.")
# Clean up the empty collection
bpy.data.collections.remove(bake_coll)
return {'CANCELLED'}
# -------------------------------------------------------------------
# Phase 2: Set up visibility keyframes
# -------------------------------------------------------------------
for obj, first_f, last_f in baked_objects:
# Default: hidden on all frames
# We keyframe "hidden" before and after the active window,
# and "visible" for the active range.
# Hidden before the active range
if first_f > frame_start:
_keyframe_visibility(obj, frame_start, False)
if first_f > frame_start + 1:
_keyframe_visibility(obj, first_f - 1, False)
# Visible during the active range
_keyframe_visibility(obj, first_f, True)
if last_f != first_f:
_keyframe_visibility(obj, last_f, True)
# Hidden after the active range
if last_f < frame_end:
_keyframe_visibility(obj, last_f + 1, False)
if last_f < frame_end - 1:
_keyframe_visibility(obj, frame_end, False)
_set_constant_interpolation(obj)
# -------------------------------------------------------------------
# Phase 3: Bake camera shake to keyframes
# -------------------------------------------------------------------
cam = scene.camera
if cam and glob.camera_shake_amplitude > 0.0:
shake = glob.camera_shake_amplitude
for f in range(frame_start, frame_end + 1):
import random
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)
# Set interpolation to LINEAR for smooth shake
for fc in _get_action_fcurves(cam):
if fc.data_path == "delta_location":
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
# If no shake, ensure delta_location is zeroed
elif cam:
cam.delta_location = (0.0, 0.0, 0.0)
# -------------------------------------------------------------------
# Phase 4: Clean up the procedural AnimKnot object
# -------------------------------------------------------------------
# Remove the original NURBS curve object — we've replaced it with
# baked mesh objects
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)
# Remove the frame_change_post handler so the baked file is
# completely standalone
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: Pack data and ensure render-farm compatibility
# -------------------------------------------------------------------
# Set output format to PNG image sequence (required by SheepIt)
orig_file_format = scene.render.image_settings.file_format
orig_color_mode = scene.render.image_settings.color_mode
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
if pack_and_clean:
# Make all paths relative — only needed once; subsequent chunks
# operate on the same in-memory data which is already relative.
try:
bpy.ops.file.make_paths_relative()
except RuntimeError:
pass # May fail if file has never been saved
# Pack external data if requested — run once so that all packed
# images are available in memory for every subsequent chunk save.
if self.pack_textures:
try:
bpy.ops.file.pack_all()
except RuntimeError:
self.report({'WARNING'}, "Some external files could not be packed.")
# Remove unused data blocks to reduce file size. Only safe to
# do on the first chunk; later chunks share the same in-memory
# materials/images and we do not want to inadvertently purge data
# that is still referenced by a subsequent chunk's bake.
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)
for ng in list(bpy.data.node_groups):
if ng.users == 0:
bpy.data.node_groups.remove(ng)
for img in list(bpy.data.images):
if img.users == 0 and not img.use_fake_user:
bpy.data.images.remove(img)
# -------------------------------------------------------------------
# Phase 6: Save as a COPY — the original session is untouched
# -------------------------------------------------------------------
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True, compress=True)
# -------------------------------------------------------------------
# Phase 7: Restore the original session state
# -------------------------------------------------------------------
# We just saved a copy, but the in-memory state has been modified
# (baked objects added, AnimKnot removed, handler removed).
# Restore everything so the user can keep working.
# Restore render settings
scene.render.image_settings.file_format = orig_file_format
scene.render.image_settings.color_mode = orig_color_mode
# Remove all baked objects and their meshes from the current session
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)
# Remove the baked collection
bake_coll_check = bpy.data.collections.get("BakedAnimation")
if bake_coll_check:
bpy.data.collections.remove(bake_coll_check)
# Remove baked camera shake keyframes (restore static camera)
if cam:
cam_fcurves = _get_action_fcurves(cam)
# cam_fcurves may be a ChannelBag or ActionFCurves — both support .remove()
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 # Some API versions don't support removal — keyframes will persist
cam.delta_location = (0.0, 0.0, 0.0)
# Re-create the AnimKnot object and re-register the handler
from .geometry import _make_torus_knot
from .materials import prewarm_materials_and_blends
# We need to prewarm materials and blends because removing unused materials might have deleted them
prewarm_materials_and_blends(scene)
# Re-register the handler
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)
# Trigger the handler once to rebuild the AnimKnot object
knot_frame_handler(scene)
# Restore the 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):
@@ -456,9 +508,11 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
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")
layout.prop(self, "split_export")
if self.split_export:
layout.prop(self, "frames_per_file")
scene = context.scene
total = scene.frame_end - scene.frame_start + 1
@@ -466,10 +520,3 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
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')