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 // 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 /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 ""