- Add --sequence CLI flag to pack multiple blend files together with shared dependency deduplication - Add BAT_OT_sequence_pack Blender operator: scan a sequence folder for latest published blend files, review with checkboxes, pack to ZIP - Add studio template system (Autour de Minuit, La Cabane Productions) with configurable folder conventions and task type selection - Add BlenderProgressCallback for cursor progress indicator during pack - Add ZIP write start/finish logging - Deduplicate blend file paths in CLI when positional and --sequence overlap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
450 lines
15 KiB
Python
450 lines
15 KiB
Python
import os
|
|
import re
|
|
import sys
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import bpy
|
|
from bpy.types import Operator, PropertyGroup
|
|
from bpy_extras.io_utils import ExportHelper
|
|
|
|
from blender_asset_tracer.pack import zipped, progress
|
|
|
|
VERSION_RE = re.compile(r'_v(\d+)\.blend$', re.IGNORECASE)
|
|
|
|
|
|
class BlenderProgressCallback(progress.Callback):
|
|
"""Progress callback that updates Blender's wm.progress cursor indicator."""
|
|
|
|
def __init__(self, wm):
|
|
self._wm = wm
|
|
self._assets_traced = 0
|
|
self._total_bytes = 0
|
|
self._transferred_bytes = 0
|
|
self._phase = "init"
|
|
|
|
def pack_start(self):
|
|
self._phase = "trace"
|
|
self._assets_traced = 0
|
|
self._wm.progress_begin(0, 1000)
|
|
self._wm.progress_update(0)
|
|
print("[BAT] Tracing dependencies...")
|
|
|
|
def trace_asset(self, filename):
|
|
self._assets_traced += 1
|
|
# During tracing we don't know the total, so just increment slowly
|
|
# Cap at 400 (40% of 1000) to leave room for the transfer phase
|
|
val = min(self._assets_traced, 400)
|
|
self._wm.progress_update(val)
|
|
if self._assets_traced % 20 == 0:
|
|
print("[BAT] Traced %d assets..." % self._assets_traced)
|
|
|
|
def transfer_file(self, src, dst):
|
|
if self._phase != "transfer":
|
|
self._phase = "transfer"
|
|
print("[BAT] Transferring files...")
|
|
|
|
def transfer_progress(self, total_bytes, transferred_bytes):
|
|
self._total_bytes = total_bytes
|
|
self._transferred_bytes = transferred_bytes
|
|
if total_bytes > 0:
|
|
# Transfer phase: map to 400-1000 range
|
|
pct = transferred_bytes / total_bytes
|
|
val = 400 + int(pct * 600)
|
|
self._wm.progress_update(val)
|
|
|
|
def pack_done(self, output_blendfile, missing_files):
|
|
self._wm.progress_update(1000)
|
|
self._wm.progress_end()
|
|
print("[BAT] Pack complete! (%d assets traced)" % self._assets_traced)
|
|
if missing_files:
|
|
print("[BAT] Warning: %d missing files" % len(missing_files))
|
|
|
|
def pack_aborted(self, reason):
|
|
self._wm.progress_end()
|
|
print("[BAT] Pack aborted: %s" % reason)
|
|
|
|
|
|
class ExportBatPack(Operator, ExportHelper):
|
|
bl_idname = "export_bat.pack"
|
|
bl_label = "BAT - Zip pack (flat)"
|
|
filename_ext = ".zip"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return bpy.data.is_saved
|
|
|
|
def execute(self, context):
|
|
outfname = bpy.path.ensure_ext(self.filepath, ".zip")
|
|
self.report({"INFO"}, "Executing ZipPacker ...")
|
|
|
|
progress_cb = BlenderProgressCallback(context.window_manager)
|
|
|
|
with zipped.ZipPacker(
|
|
Path(bpy.data.filepath),
|
|
Path(bpy.data.filepath).parent,
|
|
str(self.filepath),
|
|
) as packer:
|
|
packer.progress_cb = progress_cb
|
|
packer.strategise()
|
|
packer.execute()
|
|
self.report({"INFO"}, "Packing successful!")
|
|
|
|
with zipfile.ZipFile(str(self.filepath)) as inzip:
|
|
inzip.testzip()
|
|
|
|
self.report({"INFO"}, "Written to %s" % outfname)
|
|
return {"FINISHED"}
|
|
|
|
|
|
def open_folder(folderpath):
|
|
"""Open the folder at the path given with cmd relative to user's OS."""
|
|
from shutil import which
|
|
|
|
my_os = sys.platform
|
|
if my_os.startswith(("linux", "freebsd")):
|
|
cmd = "xdg-open"
|
|
elif my_os.startswith("win"):
|
|
cmd = "explorer"
|
|
if not folderpath:
|
|
return
|
|
else:
|
|
cmd = "open"
|
|
|
|
if not folderpath:
|
|
return
|
|
|
|
folderpath = str(folderpath)
|
|
if os.path.isfile(folderpath):
|
|
select = False
|
|
if my_os.startswith("win"):
|
|
cmd = "explorer /select,"
|
|
select = True
|
|
elif my_os.startswith(("linux", "freebsd")):
|
|
if which("nemo"):
|
|
cmd = "nemo --no-desktop"
|
|
select = True
|
|
elif which("nautilus"):
|
|
cmd = "nautilus --no-desktop"
|
|
select = True
|
|
if not select:
|
|
folderpath = os.path.dirname(folderpath)
|
|
|
|
folderpath = os.path.normpath(folderpath)
|
|
fullcmd = cmd.split() + [folderpath]
|
|
subprocess.Popen(fullcmd)
|
|
|
|
|
|
class BAT_OT_export_zip(Operator, ExportHelper):
|
|
"""Export current blendfile with hierarchy preservation"""
|
|
|
|
bl_label = "BAT - Zip pack (keep hierarchy)"
|
|
bl_idname = "bat.export_zip"
|
|
|
|
filename_ext = ".zip"
|
|
|
|
root_dir: bpy.props.StringProperty(
|
|
name="Root",
|
|
description="Top Level Folder of your project."
|
|
"\nFor now Copy/Paste correct folder by hand if default is incorrect."
|
|
"\n!!! Everything outside won't be zipped !!!",
|
|
)
|
|
|
|
use_zip: bpy.props.BoolProperty(
|
|
name="Output as ZIP",
|
|
description="If enabled, pack into a ZIP archive. If disabled, copy to a directory.",
|
|
default=True,
|
|
)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return bpy.data.is_saved
|
|
|
|
def execute(self, context):
|
|
from blender_asset_tracer.pack import Packer
|
|
from blender_asset_tracer.pack.zipped import ZipPacker
|
|
|
|
bfile = Path(bpy.data.filepath)
|
|
project = Path(self.root_dir) if self.root_dir else bfile.parent
|
|
target = str(self.filepath)
|
|
|
|
if self.use_zip:
|
|
target = bpy.path.ensure_ext(target, ".zip")
|
|
packer_cls = ZipPacker
|
|
else:
|
|
if target.lower().endswith(".zip"):
|
|
target = target[:-4]
|
|
packer_cls = Packer
|
|
|
|
self.report({"INFO"}, "Packing with hierarchy...")
|
|
|
|
progress_cb = BlenderProgressCallback(context.window_manager)
|
|
|
|
with packer_cls(bfile, project, target, keep_hierarchy=True) as packer:
|
|
packer.progress_cb = progress_cb
|
|
packer.strategise()
|
|
packer.execute()
|
|
|
|
if self.use_zip:
|
|
with zipfile.ZipFile(target) as inzip:
|
|
inzip.testzip()
|
|
log_output = Path(tempfile.gettempdir(), "README.txt")
|
|
with open(log_output, "w") as log:
|
|
log.write("Packed with BAT (keep-hierarchy mode)")
|
|
log.write(f"\nBlend file: {bpy.data.filepath}")
|
|
with zipfile.ZipFile(target, "a") as zipObj:
|
|
zipObj.write(log_output, log_output.name)
|
|
|
|
self.report({"INFO"}, "Written to %s" % target)
|
|
open_folder(Path(target).parent)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class BAT_SequenceFileEntry(PropertyGroup):
|
|
filepath: bpy.props.StringProperty(name="File Path")
|
|
enabled: bpy.props.BoolProperty(name="Enabled", default=True)
|
|
shot_name: bpy.props.StringProperty(name="Shot")
|
|
|
|
|
|
# Studio templates define where to find published blend files.
|
|
# Each template is a list of path segments (case-insensitive) to walk from each
|
|
# shot directory to the folder containing versioned .blend files.
|
|
STUDIO_TEMPLATES = {
|
|
'ADM': {
|
|
'label': "Autour de Minuit",
|
|
'description': "Scans <shot>/<task>/ for the latest versioned .blend (e.g. lighting3d, anim3d)",
|
|
'path_segments': ["{task}"],
|
|
'task_choices': [
|
|
('lighting3d', "Lighting 3D", ""),
|
|
('anim3d', "Animation 3D", ""),
|
|
('layout3d', "Layout 3D", ""),
|
|
('compositing', "Compositing", ""),
|
|
],
|
|
},
|
|
'LCPROD': {
|
|
'label': "La Cabane Productions - Lamb Stew",
|
|
'description': "Scans <shot>/03_ANIMATION/Publish/ for the latest versioned .blend",
|
|
'path_segments': ["03_ANIMATION", "Publish"],
|
|
},
|
|
}
|
|
|
|
STUDIO_TEMPLATE_ITEMS = [
|
|
(key, tpl['label'], tpl['description'])
|
|
for key, tpl in STUDIO_TEMPLATES.items()
|
|
]
|
|
|
|
# Collect all task choices across templates that have them.
|
|
TASK_CHOICE_ITEMS = []
|
|
_seen = set()
|
|
for _tpl in STUDIO_TEMPLATES.values():
|
|
for item in _tpl.get('task_choices', []):
|
|
if item[0] not in _seen:
|
|
TASK_CHOICE_ITEMS.append(item)
|
|
_seen.add(item[0])
|
|
|
|
|
|
def _find_subdir_ci(parent, name):
|
|
"""Find a child directory matching `name` case-insensitively (prefix match)."""
|
|
name_upper = name.upper()
|
|
for child in parent.iterdir():
|
|
if child.is_dir() and child.name.upper().startswith(name_upper):
|
|
return child
|
|
return None
|
|
|
|
|
|
def find_latest_publishes(root_dir, template_key, task=''):
|
|
"""Scan a sequence folder for the latest .blend in each shot using the given template."""
|
|
template = STUDIO_TEMPLATES.get(template_key)
|
|
if not template:
|
|
return []
|
|
|
|
results = []
|
|
root = Path(root_dir)
|
|
if not root.is_dir():
|
|
return results
|
|
|
|
path_segments = [seg.replace("{task}", task) if "{task}" in seg else seg
|
|
for seg in template['path_segments']]
|
|
|
|
for shot_dir in sorted(root.iterdir()):
|
|
if not shot_dir.is_dir():
|
|
continue
|
|
|
|
# Walk the template path segments from the shot directory
|
|
current = shot_dir
|
|
for segment in path_segments:
|
|
current = _find_subdir_ci(current, segment)
|
|
if current is None:
|
|
break
|
|
if current is None:
|
|
continue
|
|
|
|
# Find the .blend with the highest _vNNN version number
|
|
best_version = -1
|
|
best_file = None
|
|
for f in current.iterdir():
|
|
if f.suffix.lower() != '.blend':
|
|
continue
|
|
m = VERSION_RE.search(f.name)
|
|
if m:
|
|
ver = int(m.group(1))
|
|
if ver > best_version:
|
|
best_version = ver
|
|
best_file = f
|
|
if best_file:
|
|
results.append((shot_dir.name, best_file.absolute()))
|
|
|
|
return results
|
|
|
|
|
|
class BAT_OT_scan_sequence(Operator):
|
|
"""Scan the current file browser directory for latest published blend files"""
|
|
|
|
bl_idname = "bat.scan_sequence"
|
|
bl_label = "Scan Current Folder"
|
|
|
|
def execute(self, context):
|
|
wm = context.window_manager
|
|
|
|
# Read the directory the user is currently browsing in the file selector
|
|
try:
|
|
seq_dir = context.space_data.params.directory.decode('utf-8')
|
|
except (AttributeError, UnicodeDecodeError):
|
|
seq_dir = None
|
|
|
|
if not seq_dir:
|
|
self.report({"ERROR"}, "Could not read current file browser directory")
|
|
return {"CANCELLED"}
|
|
|
|
wm.bat_sequence_dir = seq_dir
|
|
wm.bat_sequence_files.clear()
|
|
publishes = find_latest_publishes(seq_dir, wm.bat_sequence_template, wm.bat_sequence_task)
|
|
|
|
if not publishes:
|
|
self.report({"WARNING"}, "No published blend files found in %s" % seq_dir)
|
|
return {"CANCELLED"}
|
|
|
|
for shot_name, filepath in publishes:
|
|
entry = wm.bat_sequence_files.add()
|
|
entry.shot_name = shot_name
|
|
entry.filepath = str(filepath)
|
|
entry.enabled = True
|
|
|
|
# Update the file browser filename based on the scanned folder name
|
|
folder_name = Path(seq_dir.rstrip("/\\")).name
|
|
try:
|
|
context.space_data.params.filename = folder_name + "_bat_pack.zip"
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
self.report({"INFO"}, "Found %d published blend files" % len(publishes))
|
|
return {"FINISHED"}
|
|
|
|
|
|
class BAT_OT_sequence_pack(Operator, ExportHelper):
|
|
"""Pack a sequence of shots: scan a folder for latest published blend files and pack them into a ZIP"""
|
|
|
|
bl_idname = "bat.sequence_pack"
|
|
bl_label = "BAT - Pack Sequence"
|
|
filename_ext = ".zip"
|
|
|
|
filter_glob: bpy.props.StringProperty(default="*.zip", options={'HIDDEN'})
|
|
|
|
def invoke(self, context, event):
|
|
wm = context.window_manager
|
|
# Default sequence_dir to blend file's parent if not already set
|
|
if not wm.bat_sequence_dir and bpy.data.is_saved:
|
|
wm.bat_sequence_dir = str(Path(bpy.data.filepath).parent)
|
|
# Pre-fill filename from the last scanned directory
|
|
if wm.bat_sequence_dir:
|
|
folder_name = Path(wm.bat_sequence_dir.rstrip("/\\")).name
|
|
self.filepath = folder_name + "_bat_pack.zip"
|
|
return super().invoke(context, event)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
wm = context.window_manager
|
|
|
|
layout.label(text="Studio Template:")
|
|
layout.prop(wm, "bat_sequence_template", text="")
|
|
|
|
# Show task selector if the current template has task choices
|
|
template = STUDIO_TEMPLATES.get(wm.bat_sequence_template)
|
|
if template and template.get('task_choices'):
|
|
layout.prop(wm, "bat_sequence_task", text="Task")
|
|
|
|
layout.separator()
|
|
layout.label(text="Navigate to the sequence folder, then:")
|
|
layout.operator(BAT_OT_scan_sequence.bl_idname, icon='FILE_REFRESH')
|
|
|
|
if wm.bat_sequence_dir:
|
|
layout.label(text="Scanned: %s" % Path(wm.bat_sequence_dir).name)
|
|
|
|
if len(wm.bat_sequence_files) > 0:
|
|
layout.separator()
|
|
layout.label(text="Published blend files:")
|
|
box = layout.box()
|
|
for entry in wm.bat_sequence_files:
|
|
row = box.row()
|
|
row.prop(entry, "enabled", text="")
|
|
row.label(text=entry.shot_name)
|
|
row.label(text=Path(entry.filepath).name)
|
|
|
|
def execute(self, context):
|
|
from blender_asset_tracer.pack.zipped import ZipPacker
|
|
|
|
wm = context.window_manager
|
|
bpaths = [Path(entry.filepath) for entry in wm.bat_sequence_files if entry.enabled]
|
|
|
|
if not bpaths:
|
|
self.report({"ERROR"}, "No blend files selected. Scan a folder first.")
|
|
return {"CANCELLED"}
|
|
|
|
target = bpy.path.ensure_ext(self.filepath, ".zip")
|
|
project = Path(os.path.commonpath([p.parent for p in bpaths]))
|
|
|
|
self.report({"INFO"}, "Packing %d blend files..." % len(bpaths))
|
|
|
|
progress_cb = BlenderProgressCallback(wm)
|
|
|
|
try:
|
|
with ZipPacker(bpaths, project, target, keep_hierarchy=True) as packer:
|
|
packer.progress_cb = progress_cb
|
|
packer.strategise()
|
|
packer.execute()
|
|
except Exception as ex:
|
|
self.report({"ERROR"}, "Packing failed: %s" % str(ex))
|
|
return {"CANCELLED"}
|
|
|
|
with zipfile.ZipFile(target) as inzip:
|
|
inzip.testzip()
|
|
|
|
self.report({"INFO"}, "Written to %s" % target)
|
|
open_folder(Path(target).parent)
|
|
return {"FINISHED"}
|
|
|
|
|
|
def menu_func(self, context):
|
|
layout = self.layout
|
|
layout.separator()
|
|
layout.operator(ExportBatPack.bl_idname)
|
|
filepath = layout.operator(BAT_OT_export_zip.bl_idname)
|
|
layout.operator(BAT_OT_sequence_pack.bl_idname)
|
|
|
|
try:
|
|
prefs = bpy.context.preferences.addons["blender_asset_tracer"].preferences
|
|
root_dir_env = None
|
|
if prefs.use_env_root:
|
|
root_dir_env = os.getenv("ZIP_ROOT")
|
|
if not root_dir_env:
|
|
root_dir_env = os.getenv("PROJECT_ROOT")
|
|
if not root_dir_env:
|
|
root_dir_env = prefs.root_default
|
|
filepath.root_dir = "" if root_dir_env is None else root_dir_env
|
|
except Exception:
|
|
filepath.root_dir = os.getenv("ZIP_ROOT") or os.getenv("PROJECT_ROOT") or ""
|
|
|
|
|