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) <noreply@anthropic.com>
This commit is contained in:
Joseph HENRY 2026-03-24 12:41:50 +01:00
parent f79c6a276d
commit 05d97cdeda
6 changed files with 441 additions and 81 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.venv
*.pyc *.pyc
*.blend[1-9] *.blend[1-9]
__pycache__ __pycache__

View File

@ -58,18 +58,42 @@ if _HAS_BPY:
classes = ( classes = (
preferences.BATPreferences, preferences.BATPreferences,
operators.BAT_SequenceFileEntry,
operators.ExportBatPack, operators.ExportBatPack,
operators.BAT_OT_export_zip, operators.BAT_OT_export_zip,
operators.BAT_OT_scan_sequence,
operators.BAT_OT_sequence_pack,
) )
def register(): def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_file_external_data.append(operators.menu_func) 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(): def unregister():
bpy.types.TOPBAR_MT_file_external_data.remove(operators.menu_func) 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) bpy.utils.unregister_class(cls)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -19,6 +19,7 @@
# (c) 2018, Blender Foundation - Sybren A. Stüvel # (c) 2018, Blender Foundation - Sybren A. Stüvel
"""Create a BAT-pack for the given blend file.""" """Create a BAT-pack for the given blend file."""
import logging import logging
import os
import pathlib import pathlib
import sys import sys
import typing import typing
@ -34,7 +35,8 @@ def add_parser(subparsers):
parser = subparsers.add_parser("pack", help=__doc__) parser = subparsers.add_parser("pack", help=__doc__)
parser.set_defaults(func=cli_pack) 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( parser.add_argument(
"target", "target",
type=str, type=str,
@ -97,12 +99,20 @@ def add_parser(subparsers):
"path structure under the target directory. Paths in blend files are " "path structure under the target directory. Paths in blend files are "
"rewritten to relative paths within this structure.", "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): 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() packer.strategise()
try: try:
packer.execute() packer.execute()
@ -116,9 +126,12 @@ def cli_pack(args):
def create_packer( 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: ) -> pack.Packer:
if target.startswith("s3:/"): if target.startswith("s3:/"):
if len(bpaths) > 1:
raise ValueError("S3 uploader does not support --sequence")
if args.noop: if args.noop:
raise ValueError("S3 uploader does not support no-op.") raise ValueError("S3 uploader does not support no-op.")
@ -131,13 +144,16 @@ def create_packer(
if args.keep_hierarchy: if args.keep_hierarchy:
raise ValueError("S3 uploader does not support the --keep-hierarchy option") 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 ( elif (
target.startswith("shaman+http:/") target.startswith("shaman+http:/")
or target.startswith("shaman+https:/") or target.startswith("shaman+https:/")
or target.startswith("shaman:/") or target.startswith("shaman:/")
): ):
if len(bpaths) > 1:
raise ValueError("Shaman uploader does not support --sequence")
if args.noop: if args.noop:
raise ValueError("Shaman uploader does not support no-op.") 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" "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"): elif target.lower().endswith(".zip"):
from blender_asset_tracer.pack import zipped 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") raise ValueError("ZIP packer does not support on-the-fly compression")
packer = zipped.ZipPacker( 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, keep_hierarchy=args.keep_hierarchy,
) )
else: else:
packer = pack.Packer( packer = pack.Packer(
bpath, bpaths,
ppath, ppath,
target, target,
noop=args.noop, noop=args.noop,
@ -222,24 +238,42 @@ def create_shamanpacker(
) )
def paths_from_cli(args) -> typing.Tuple[pathlib.Path, pathlib.Path, str]: def paths_from_cli(args) -> typing.Tuple[typing.List[pathlib.Path], pathlib.Path, str]:
"""Return paths to blendfile, project, and pack target. """Return paths to blendfile(s), project, and pack target.
Calls sys.exit() if anything is wrong. Calls sys.exit() if anything is wrong.
""" """
bpath = args.blendfile # Collect blend files from positional and --sequence arguments.
if not bpath.exists(): bpaths = [] # type: typing.List[pathlib.Path]
log.critical("File %s does not exist", bpath) 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) sys.exit(3)
if bpath.is_dir():
log.critical("%s is a directory, should be a blend file") # Deduplicate preserving order, in case the same file appears both as
sys.exit(3) # positional arg and in --sequence.
bpath = bpathlib.make_absolute(bpath) 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 tpath = args.target
if args.project is None: 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) log.warning("No project path given, using %s", ppath)
else: else:
ppath = bpathlib.make_absolute(args.project) 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 ppath = ppath.parent
try: for bpath in bpaths:
bpath.relative_to(ppath) try:
except ValueError: bpath.relative_to(ppath)
log.critical( except ValueError:
"Project directory %s does not contain blend file %s", log.critical(
args.project, "Project directory %s does not contain blend file %s",
bpath.absolute(), ppath,
) bpath,
sys.exit(5) )
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("Project path: %s", ppath)
log.info("Pack will be created in: %s", tpath) log.info("Pack will be created in: %s", tpath)
return bpath, ppath, tpath return bpaths, ppath, tpath

View File

@ -1,4 +1,5 @@
import os import os
import re
import sys import sys
import subprocess import subprocess
import tempfile import tempfile
@ -6,10 +7,64 @@ import zipfile
from pathlib import Path from pathlib import Path
import bpy import bpy
from bpy.types import Operator from bpy.types import Operator, PropertyGroup
from bpy_extras.io_utils import ExportHelper 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): class ExportBatPack(Operator, ExportHelper):
@ -25,17 +80,16 @@ class ExportBatPack(Operator, ExportHelper):
outfname = bpy.path.ensure_ext(self.filepath, ".zip") outfname = bpy.path.ensure_ext(self.filepath, ".zip")
self.report({"INFO"}, "Executing ZipPacker ...") self.report({"INFO"}, "Executing ZipPacker ...")
progress_cb = BlenderProgressCallback(context.window_manager)
with zipped.ZipPacker( with zipped.ZipPacker(
Path(bpy.data.filepath), Path(bpy.data.filepath),
Path(bpy.data.filepath).parent, Path(bpy.data.filepath).parent,
str(self.filepath), str(self.filepath),
) as packer: ) as packer:
print("[BAT] Strategising (tracing dependencies)...") packer.progress_cb = progress_cb
packer.strategise() packer.strategise()
print(f"[BAT] Found {len(packer._actions)} assets to process")
print("[BAT] Executing (rewriting paths and copying files)...")
packer.execute() packer.execute()
print("[BAT] Packing complete!")
self.report({"INFO"}, "Packing successful!") self.report({"INFO"}, "Packing successful!")
with zipfile.ZipFile(str(self.filepath)) as inzip: 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...") self.report({"INFO"}, "Packing with hierarchy...")
progress_cb = BlenderProgressCallback(context.window_manager)
with packer_cls(bfile, project, target, keep_hierarchy=True) as packer: with packer_cls(bfile, project, target, keep_hierarchy=True) as packer:
print("[BAT] Strategising (tracing dependencies)...") packer.progress_cb = progress_cb
packer.strategise() packer.strategise()
print(f"[BAT] Found {len(packer._actions)} assets to process")
print("[BAT] Executing (rewriting paths and copying files)...")
packer.execute() packer.execute()
print("[BAT] Packing complete!")
if self.use_zip: if self.use_zip:
with zipfile.ZipFile(target) as inzip: with zipfile.ZipFile(target) as inzip:
@ -149,11 +202,236 @@ class BAT_OT_export_zip(Operator, ExportHelper):
return {"FINISHED"} 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): def menu_func(self, context):
layout = self.layout layout = self.layout
layout.separator() layout.separator()
layout.operator(ExportBatPack.bl_idname) layout.operator(ExportBatPack.bl_idname)
filepath = layout.operator(BAT_OT_export_zip.bl_idname) filepath = layout.operator(BAT_OT_export_zip.bl_idname)
layout.operator(BAT_OT_sequence_pack.bl_idname)
try: try:
prefs = bpy.context.preferences.addons["blender_asset_tracer"].preferences prefs = bpy.context.preferences.addons["blender_asset_tracer"].preferences

View File

@ -96,7 +96,7 @@ class Packer:
def __init__( def __init__(
self, self,
bfile: pathlib.Path, bfile: typing.Union[pathlib.Path, typing.List[pathlib.Path]],
project: pathlib.Path, project: pathlib.Path,
target: str, target: str,
*, *,
@ -105,7 +105,11 @@ class Packer:
relative_only=False, relative_only=False,
keep_hierarchy=False, keep_hierarchy=False,
) -> None: ) -> 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.project = project
self.target = target self.target = target
self._target_path = self._make_target_path(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.missing_files = set() # type: typing.Set[pathlib.Path]
self._new_location_paths = 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_path = None # type: typing.Optional[pathlib.PurePath]
self._output_paths = [] # type: typing.List[pathlib.PurePath]
# Filled by execute() # Filled by execute()
self._file_transferer = None # type: typing.Optional[transfer.FileTransferer] self._file_transferer = None # type: typing.Optional[transfer.FileTransferer]
@ -172,6 +177,11 @@ class Packer:
assert self._output_path is not None assert self._output_path is not None
return self._output_path 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 @property
def progress_cb(self) -> progress.Callback: def progress_cb(self) -> progress.Callback:
return self._progress_cb return self._progress_cb
@ -235,46 +245,47 @@ class Packer:
in the execute() function. 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() 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() self._new_location_paths = set()
for usage in trace.deps(self.blendfile, self._progress_cb): self._output_paths = []
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"//"): for bf in self.blendfiles:
log.info("Skipping absolute path: %s", usage.asset_path) bfile_path = bpathlib.make_absolute(bf)
continue
if usage.is_sequence: # Both paths have to be resolved first, because this also translates
self._visit_sequence(asset_path, usage) # 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: 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._find_new_paths()
self._group_rewrites() self._group_rewrites()
@ -644,11 +655,19 @@ class Packer:
log.debug("Writing info to %s", infopath) log.debug("Writing info to %s", infopath)
with infopath.open("wt", encoding="utf8") as infofile: with infopath.open("wt", encoding="utf8") as infofile:
print("This is a Blender Asset Tracer pack.", file=infofile) print("This is a Blender Asset Tracer pack.", file=infofile)
print("Start by opening the following blend file:", file=infofile) if len(self._output_paths) > 1:
print( print("This pack contains the following blend files:", file=infofile)
" %s" % self._output_path.relative_to(self._target_path).as_posix(), for op in self._output_paths:
file=infofile, 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) self._file_transferer.queue_move(infopath, self._target_path / infoname)

View File

@ -58,6 +58,7 @@ class ZipTransferrer(transfer.FileTransferer):
zippath = self.zippath.absolute() zippath = self.zippath.absolute()
log.info("Writing ZIP file to %s", zippath)
with zipfile.ZipFile(str(zippath), "w") as outzip: with zipfile.ZipFile(str(zippath), "w") as outzip:
for src, dst, act in self.iter_queue(): for src, dst, act in self.iter_queue():
assert src.is_absolute(), "expecting only absolute paths, not %r" % src assert src.is_absolute(), "expecting only absolute paths, not %r" % src
@ -89,3 +90,4 @@ class ZipTransferrer(transfer.FileTransferer):
# be reported there. # be reported there.
self.queue.put((src, dst, act)) self.queue.put((src, dst, act))
return return
log.info("Finished writing ZIP file: %s", zippath)