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:
2026-03-24 12:41:50 +01:00
co-authored by Claude Opus 4.6
parent f79c6a276d
commit 05d97cdeda
6 changed files with 441 additions and 81 deletions
+60 -41
View File
@@ -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)
+2
View File
@@ -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)