From 05d97cdedabe08d4b719e002ad0e64fbb9264780 Mon Sep 17 00:00:00 2001 From: Joseph HENRY Date: Tue, 24 Mar 2026 12:41:50 +0100 Subject: [PATCH] Add sequence pack operator and multi-blend-file CLI support - 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) --- .gitignore | 1 + blender_asset_tracer/__init__.py | 26 ++- blender_asset_tracer/cli/pack.py | 94 +++++--- blender_asset_tracer/operators.py | 298 +++++++++++++++++++++++++- blender_asset_tracer/pack/__init__.py | 101 +++++---- blender_asset_tracer/pack/zipped.py | 2 + 6 files changed, 441 insertions(+), 81 deletions(-) diff --git a/.gitignore b/.gitignore index 7f0097a..ee360fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.venv *.pyc *.blend[1-9] __pycache__ diff --git a/blender_asset_tracer/__init__.py b/blender_asset_tracer/__init__.py index 6d27b73..9b60cef 100644 --- a/blender_asset_tracer/__init__.py +++ b/blender_asset_tracer/__init__.py @@ -58,18 +58,42 @@ if _HAS_BPY: classes = ( preferences.BATPreferences, + operators.BAT_SequenceFileEntry, operators.ExportBatPack, operators.BAT_OT_export_zip, + operators.BAT_OT_scan_sequence, + operators.BAT_OT_sequence_pack, ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_file_external_data.append(operators.menu_func) + bpy.types.WindowManager.bat_sequence_template = bpy.props.EnumProperty( + name="Studio Template", + description="Folder convention used to find published blend files", + items=operators.STUDIO_TEMPLATE_ITEMS, + ) + bpy.types.WindowManager.bat_sequence_task = bpy.props.EnumProperty( + name="Task", + description="Which task folder to scan (for templates that require it)", + items=operators.TASK_CHOICE_ITEMS, + ) + bpy.types.WindowManager.bat_sequence_dir = bpy.props.StringProperty( + name="Sequence Directory", + description="Root folder of the sequence last scanned for published blend files", + ) + bpy.types.WindowManager.bat_sequence_files = bpy.props.CollectionProperty( + type=operators.BAT_SequenceFileEntry, + ) def unregister(): bpy.types.TOPBAR_MT_file_external_data.remove(operators.menu_func) - for cls in classes: + del bpy.types.WindowManager.bat_sequence_files + del bpy.types.WindowManager.bat_sequence_dir + del bpy.types.WindowManager.bat_sequence_task + del bpy.types.WindowManager.bat_sequence_template + for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": diff --git a/blender_asset_tracer/cli/pack.py b/blender_asset_tracer/cli/pack.py index 19e713e..d20d15d 100755 --- a/blender_asset_tracer/cli/pack.py +++ b/blender_asset_tracer/cli/pack.py @@ -19,6 +19,7 @@ # (c) 2018, Blender Foundation - Sybren A. Stüvel """Create a BAT-pack for the given blend file.""" import logging +import os import pathlib import sys import typing @@ -34,7 +35,8 @@ def add_parser(subparsers): parser = subparsers.add_parser("pack", help=__doc__) parser.set_defaults(func=cli_pack) - parser.add_argument("blendfile", type=pathlib.Path, help="The Blend file to pack.") + parser.add_argument("blendfile", nargs='?', type=pathlib.Path, default=None, + help="The Blend file to pack (omit when using --sequence).") parser.add_argument( "target", type=str, @@ -97,12 +99,20 @@ def add_parser(subparsers): "path structure under the target directory. Paths in blend files are " "rewritten to relative paths within this structure.", ) + parser.add_argument( + "--sequence", + nargs="+", + type=pathlib.Path, + metavar="BLENDFILE", + help="Pack multiple blend files together, deduplicating shared dependencies. " + "All listed blend files and their dependencies are packed into the target.", + ) def cli_pack(args): - bpath, ppath, tpath = paths_from_cli(args) + bpaths, ppath, tpath = paths_from_cli(args) - with create_packer(args, bpath, ppath, tpath) as packer: + with create_packer(args, bpaths, ppath, tpath) as packer: packer.strategise() try: packer.execute() @@ -116,9 +126,12 @@ def cli_pack(args): def create_packer( - args, bpath: pathlib.Path, ppath: pathlib.Path, target: str + args, bpaths: typing.List[pathlib.Path], ppath: pathlib.Path, target: str ) -> pack.Packer: if target.startswith("s3:/"): + if len(bpaths) > 1: + raise ValueError("S3 uploader does not support --sequence") + if args.noop: raise ValueError("S3 uploader does not support no-op.") @@ -131,13 +144,16 @@ def create_packer( if args.keep_hierarchy: raise ValueError("S3 uploader does not support the --keep-hierarchy option") - packer = create_s3packer(bpath, ppath, pathlib.PurePosixPath(target)) + packer = create_s3packer(bpaths[0], ppath, pathlib.PurePosixPath(target)) elif ( target.startswith("shaman+http:/") or target.startswith("shaman+https:/") or target.startswith("shaman:/") ): + if len(bpaths) > 1: + raise ValueError("Shaman uploader does not support --sequence") + if args.noop: raise ValueError("Shaman uploader does not support no-op.") @@ -154,7 +170,7 @@ def create_packer( "Shaman uploader does not support the --keep-hierarchy option" ) - packer = create_shamanpacker(bpath, ppath, target) + packer = create_shamanpacker(bpaths[0], ppath, target) elif target.lower().endswith(".zip"): from blender_asset_tracer.pack import zipped @@ -163,12 +179,12 @@ def create_packer( raise ValueError("ZIP packer does not support on-the-fly compression") packer = zipped.ZipPacker( - bpath, ppath, target, noop=args.noop, relative_only=args.relative_only, + bpaths, ppath, target, noop=args.noop, relative_only=args.relative_only, keep_hierarchy=args.keep_hierarchy, ) else: packer = pack.Packer( - bpath, + bpaths, ppath, target, noop=args.noop, @@ -222,24 +238,42 @@ def create_shamanpacker( ) -def paths_from_cli(args) -> typing.Tuple[pathlib.Path, pathlib.Path, str]: - """Return paths to blendfile, project, and pack target. +def paths_from_cli(args) -> typing.Tuple[typing.List[pathlib.Path], pathlib.Path, str]: + """Return paths to blendfile(s), project, and pack target. Calls sys.exit() if anything is wrong. """ - bpath = args.blendfile - if not bpath.exists(): - log.critical("File %s does not exist", bpath) + # Collect blend files from positional and --sequence arguments. + bpaths = [] # type: typing.List[pathlib.Path] + if args.blendfile is not None: + bpaths.append(args.blendfile) + if args.sequence: + bpaths.extend(args.sequence) + if not bpaths: + log.critical("No blend file specified. Provide a positional blendfile or use --sequence.") sys.exit(3) - if bpath.is_dir(): - log.critical("%s is a directory, should be a blend file") - sys.exit(3) - bpath = bpathlib.make_absolute(bpath) + + # Deduplicate preserving order, in case the same file appears both as + # positional arg and in --sequence. + bpaths = list(dict.fromkeys(bpaths)) + + # Validate each blend file and make absolute. + for i, bpath in enumerate(bpaths): + if not bpath.exists(): + log.critical("File %s does not exist", bpath) + sys.exit(3) + if bpath.is_dir(): + log.critical("%s is a directory, should be a blend file", bpath) + sys.exit(3) + bpaths[i] = bpathlib.make_absolute(bpath) tpath = args.target if args.project is None: - ppath = bpathlib.make_absolute(bpath).parent + if len(bpaths) == 1: + ppath = bpaths[0].parent + else: + ppath = pathlib.Path(os.path.commonpath([p.parent for p in bpaths])) log.warning("No project path given, using %s", ppath) else: ppath = bpathlib.make_absolute(args.project) @@ -256,18 +290,20 @@ def paths_from_cli(args) -> typing.Tuple[pathlib.Path, pathlib.Path, str]: ) ppath = ppath.parent - try: - bpath.relative_to(ppath) - except ValueError: - log.critical( - "Project directory %s does not contain blend file %s", - args.project, - bpath.absolute(), - ) - sys.exit(5) + for bpath in bpaths: + try: + bpath.relative_to(ppath) + except ValueError: + log.critical( + "Project directory %s does not contain blend file %s", + ppath, + bpath, + ) + sys.exit(5) - log.info("Blend file to pack: %s", bpath) + for bpath in bpaths: + log.info("Blend file to pack: %s", bpath) log.info("Project path: %s", ppath) log.info("Pack will be created in: %s", tpath) - return bpath, ppath, tpath + return bpaths, ppath, tpath diff --git a/blender_asset_tracer/operators.py b/blender_asset_tracer/operators.py index f797c77..3ac44cc 100644 --- a/blender_asset_tracer/operators.py +++ b/blender_asset_tracer/operators.py @@ -1,4 +1,5 @@ import os +import re import sys import subprocess import tempfile @@ -6,10 +7,64 @@ import zipfile from pathlib import Path import bpy -from bpy.types import Operator +from bpy.types import Operator, PropertyGroup from bpy_extras.io_utils import ExportHelper -from blender_asset_tracer.pack import zipped +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): @@ -25,17 +80,16 @@ class ExportBatPack(Operator, ExportHelper): 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: - print("[BAT] Strategising (tracing dependencies)...") + packer.progress_cb = progress_cb packer.strategise() - print(f"[BAT] Found {len(packer._actions)} assets to process") - print("[BAT] Executing (rewriting paths and copying files)...") packer.execute() - print("[BAT] Packing complete!") self.report({"INFO"}, "Packing successful!") with zipfile.ZipFile(str(self.filepath)) as inzip: @@ -126,13 +180,12 @@ class BAT_OT_export_zip(Operator, ExportHelper): self.report({"INFO"}, "Packing with hierarchy...") + progress_cb = BlenderProgressCallback(context.window_manager) + with packer_cls(bfile, project, target, keep_hierarchy=True) as packer: - print("[BAT] Strategising (tracing dependencies)...") + packer.progress_cb = progress_cb packer.strategise() - print(f"[BAT] Found {len(packer._actions)} assets to process") - print("[BAT] Executing (rewriting paths and copying files)...") packer.execute() - print("[BAT] Packing complete!") if self.use_zip: with zipfile.ZipFile(target) as inzip: @@ -149,11 +202,236 @@ class BAT_OT_export_zip(Operator, ExportHelper): 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 diff --git a/blender_asset_tracer/pack/__init__.py b/blender_asset_tracer/pack/__init__.py index a63d0b4..56f3af9 100755 --- a/blender_asset_tracer/pack/__init__.py +++ b/blender_asset_tracer/pack/__init__.py @@ -96,7 +96,7 @@ class Packer: def __init__( self, - bfile: pathlib.Path, + bfile: typing.Union[pathlib.Path, typing.List[pathlib.Path]], project: pathlib.Path, target: str, *, @@ -105,7 +105,11 @@ class Packer: relative_only=False, keep_hierarchy=False, ) -> None: - self.blendfile = bfile + if isinstance(bfile, list): + self.blendfiles = bfile + else: + self.blendfiles = [bfile] + self.blendfile = self.blendfiles[0] # backward compat self.project = project self.target = target self._target_path = self._make_target_path(target) @@ -136,6 +140,7 @@ class Packer: self.missing_files = set() # type: typing.Set[pathlib.Path] self._new_location_paths = set() # type: typing.Set[pathlib.Path] self._output_path = None # type: typing.Optional[pathlib.PurePath] + self._output_paths = [] # type: typing.List[pathlib.PurePath] # Filled by execute() self._file_transferer = None # type: typing.Optional[transfer.FileTransferer] @@ -172,6 +177,11 @@ class Packer: assert self._output_path is not None return self._output_path + @property + def output_paths(self) -> typing.List[pathlib.PurePath]: + """The paths of all packed blend files in the target directory.""" + return list(self._output_paths) + @property def progress_cb(self) -> progress.Callback: return self._progress_cb @@ -235,46 +245,47 @@ class Packer: in the execute() function. """ - # The blendfile that we pack is generally not its own dependency, so - # we have to explicitly add it to the _packed_paths. - bfile_path = bpathlib.make_absolute(self.blendfile) - - # Both paths have to be resolved first, because this also translates - # network shares mapped to Windows drive letters back to their UNC - # notation. Only resolving one but not the other (which can happen - # with the abosolute() call above) can cause errors. - if self.keep_hierarchy: - bfile_pp = self._target_path / bpathlib.strip_root(bfile_path) - else: - bfile_pp = self._target_path / bfile_path.relative_to( - bpathlib.make_absolute(self.project) - ) - self._output_path = bfile_pp - self._progress_cb.pack_start() - - act = self._actions[bfile_path] - act.path_action = PathAction.KEEP_PATH - act.new_path = bfile_pp - - self._check_aborted() self._new_location_paths = set() - for usage in trace.deps(self.blendfile, self._progress_cb): - self._check_aborted() - asset_path = usage.abspath - if any(asset_path.match(glob) for glob in self._exclude_globs): - log.info("Excluding file: %s", asset_path) - continue + self._output_paths = [] - if self.relative_only and not usage.asset_path.startswith(b"//"): - log.info("Skipping absolute path: %s", usage.asset_path) - continue + for bf in self.blendfiles: + bfile_path = bpathlib.make_absolute(bf) - if usage.is_sequence: - self._visit_sequence(asset_path, usage) + # Both paths have to be resolved first, because this also translates + # network shares mapped to Windows drive letters back to their UNC + # notation. Only resolving one but not the other (which can happen + # with the abosolute() call above) can cause errors. + if self.keep_hierarchy: + bfile_pp = self._target_path / bpathlib.strip_root(bfile_path) else: - self._visit_asset(asset_path, usage) + bfile_pp = self._target_path / bfile_path.relative_to( + bpathlib.make_absolute(self.project) + ) + self._output_paths.append(bfile_pp) + act = self._actions[bfile_path] + act.path_action = PathAction.KEEP_PATH + act.new_path = bfile_pp + + self._check_aborted() + for usage in trace.deps(bf, self._progress_cb): + self._check_aborted() + asset_path = usage.abspath + if any(asset_path.match(glob) for glob in self._exclude_globs): + log.info("Excluding file: %s", asset_path) + continue + + if self.relative_only and not usage.asset_path.startswith(b"//"): + log.info("Skipping absolute path: %s", usage.asset_path) + continue + + if usage.is_sequence: + self._visit_sequence(asset_path, usage) + else: + self._visit_asset(asset_path, usage) + + self._output_path = self._output_paths[0] # backward compat self._find_new_paths() self._group_rewrites() @@ -644,11 +655,19 @@ class Packer: log.debug("Writing info to %s", infopath) with infopath.open("wt", encoding="utf8") as infofile: print("This is a Blender Asset Tracer pack.", file=infofile) - print("Start by opening the following blend file:", file=infofile) - print( - " %s" % self._output_path.relative_to(self._target_path).as_posix(), - file=infofile, - ) + if len(self._output_paths) > 1: + print("This pack contains the following blend files:", file=infofile) + for op in self._output_paths: + print( + " %s" % op.relative_to(self._target_path).as_posix(), + file=infofile, + ) + else: + print("Start by opening the following blend file:", file=infofile) + print( + " %s" % self._output_path.relative_to(self._target_path).as_posix(), + file=infofile, + ) self._file_transferer.queue_move(infopath, self._target_path / infoname) diff --git a/blender_asset_tracer/pack/zipped.py b/blender_asset_tracer/pack/zipped.py index be1bafa..2245496 100644 --- a/blender_asset_tracer/pack/zipped.py +++ b/blender_asset_tracer/pack/zipped.py @@ -58,6 +58,7 @@ class ZipTransferrer(transfer.FileTransferer): zippath = self.zippath.absolute() + log.info("Writing ZIP file to %s", zippath) with zipfile.ZipFile(str(zippath), "w") as outzip: for src, dst, act in self.iter_queue(): assert src.is_absolute(), "expecting only absolute paths, not %r" % src @@ -89,3 +90,4 @@ class ZipTransferrer(transfer.FileTransferer): # be reported there. self.queue.put((src, dst, act)) return + log.info("Finished writing ZIP file: %s", zippath)