loopcut 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ """Freeze a checkpoint into portable files and a content-addressed contract manifest.
2
+
3
+ collect() always uses a factory-startup child Blender with script autoexecution disabled and
4
+ private data/config folders, so enabled core add-ons cannot touch the user's stored data.
5
+ Opening a checkpoint in the UI would replace the live scene and its unsaved edits; even a
6
+ save-copy there would collect today's scene rather than the checkpoint. Only the child edits
7
+ paths. Recovery snapshots are opened through recover_auto_save, which also resolves linked
8
+ libraries against the recorded original filename before they are loaded. Stock copy-saves
9
+ are opened normally: Blender already rebased their relative paths when writing the copy.
10
+
11
+ Missing inputs and unsupported dependencies are returned separately from scene metadata.
12
+ Callers must inspect those lists before describing a bundle as complete. Nested libraries or
13
+ libraries with unpacked external dependencies are unsupported; plain libraries are copied.
14
+ Installation inputs stay at their original paths and are listed under system: the matching
15
+ Blender version supplies them, including the factory startup's essentials brush libraries.
16
+ Implicit point caches and unknown external path types are reported rather than guessed.
17
+ Paths Blender lists but never reads are not inputs: where an appended ID came from (its weak
18
+ reference, as BlenderKit leaves), a sound strip's own copy of a path, and empty fields.
19
+ The manifest/hash/upload side imports no bpy and uses only the standard library.
20
+ Image paths use filepath_raw: filepath would reload and discard recovered painted pixels.
21
+ Dirty still/tiled images are saved only into staging, keeping those checkpoint buffers.
22
+ """
23
+
24
+ import hashlib
25
+ import json
26
+ import math
27
+ import os
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import threading
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path, PureWindowsPath
36
+
37
+ SCHEMA = "loopcut.bundle/0.1"
38
+ ENTRY = "scene.blend"
39
+
40
+
41
+ class BundleError(RuntimeError):
42
+ pass
43
+
44
+
45
+ def _safe_path(value):
46
+ if (not isinstance(value, str) or not value or value.startswith("/") or "\\" in value
47
+ or PureWindowsPath(value).drive or any(ord(c) < 32 for c in value)
48
+ or any(part in {"", ".", ".."} for part in value.split("/"))):
49
+ raise BundleError(f"Unsafe bundle path: {value!r}")
50
+ return value
51
+
52
+
53
+ def _root(staging_dir):
54
+ root = Path(staging_dir)
55
+ if root.is_symlink():
56
+ raise BundleError("Symlink in bundle staging directory")
57
+ return root.resolve()
58
+
59
+
60
+ def _file(root, relative):
61
+ relative = _safe_path(relative)
62
+ path = root / relative
63
+ # Reject symlinks even when they point inside; the bundle must contain actual bytes.
64
+ if path.is_symlink() or any(p.is_symlink() for p in path.parents if p.is_relative_to(root)):
65
+ raise BundleError(f"Symlink in bundle: {relative}")
66
+ if not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
67
+ raise BundleError(f"Missing or escaped bundle file: {relative}")
68
+ return path
69
+
70
+
71
+ def _staged_files(root):
72
+ for path in sorted(root.rglob("*")):
73
+ if path.is_symlink():
74
+ raise BundleError(f"Symlink in bundle: {path.name}")
75
+ if path.is_file():
76
+ relative = _safe_path(path.relative_to(root).as_posix())
77
+ yield relative, _file(root, relative)
78
+ elif not path.is_dir():
79
+ raise BundleError(f"Not a regular bundle file: {path.name}")
80
+
81
+
82
+ def _timestamp(seconds):
83
+ return datetime.fromtimestamp(seconds, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+
85
+
86
+ def _validate_manifest(value):
87
+ from . import blobs
88
+ if (not isinstance(value, dict) or set(value) != {"schema", "blender", "entry", "files", "scene", "source", "created_at"}
89
+ or value.get("schema") != SCHEMA or value.get("entry") != ENTRY):
90
+ raise BundleError("Not a loopcut.bundle/0.1 manifest")
91
+ if not isinstance(value.get("blender"), str) or not value["blender"]:
92
+ raise BundleError("Missing Blender version")
93
+ files = value.get("files")
94
+ if not isinstance(files, list):
95
+ raise BundleError("Missing bundle files")
96
+ paths = []
97
+ for item in files:
98
+ if not isinstance(item, dict) or set(item) != {"path", "sha256", "size"}:
99
+ raise BundleError("Invalid bundle file")
100
+ paths.append(_safe_path(item.get("path")))
101
+ try:
102
+ blobs._hash(item.get("sha256"))
103
+ except ValueError as ex:
104
+ raise BundleError("Invalid bundle digest") from ex
105
+ if type(item.get("size")) is not int or item["size"] < 0:
106
+ raise BundleError("Invalid bundle file size")
107
+ if len(paths) != len(set(paths)) or paths != sorted(paths) or ENTRY not in paths:
108
+ raise BundleError("Bundle files must be unique, sorted and include scene.blend")
109
+ scene, source = value.get("scene"), value.get("source")
110
+ if (not isinstance(scene, dict) or not isinstance(scene.get("name"), str)
111
+ or set(scene) != {"name", "camera", "frame_start", "frame_end", "fps"}
112
+ or not scene["name"]
113
+ or not (scene["camera"] is None or isinstance(scene["camera"], str) and scene["camera"])
114
+ or type(scene.get("frame_start")) is not int or type(scene.get("frame_end")) is not int
115
+ or scene["frame_end"] < scene["frame_start"]
116
+ or type(scene.get("fps")) not in {int, float} or not math.isfinite(scene["fps"]) or scene["fps"] < 1):
117
+ raise BundleError("Invalid bundle scene metadata")
118
+ if (not isinstance(source, dict) or source.get("kind") != "checkpoint"
119
+ or set(source) != {"kind", "session", "checkpoint"}
120
+ or any(not isinstance(source.get(k), str) or not source[k] for k in ("session", "checkpoint"))):
121
+ raise BundleError("Invalid checkpoint source")
122
+ try:
123
+ datetime.strptime(value["created_at"], "%Y-%m-%dT%H:%M:%SZ")
124
+ except (KeyError, TypeError, ValueError) as ex:
125
+ raise BundleError("Invalid bundle creation time") from ex
126
+
127
+
128
+ def manifest(staging_dir, scene_info, source):
129
+ """Build the contract manifest. Repeated calls on unchanged staging bytes are stable.
130
+
131
+ scene_info is collect's result {blender, scene, created_at?, missing?, unsupported?, system?}.
132
+ Prefer the checkpoint's creation time; otherwise use scene.blend's mtime, never the
133
+ wall clock at serialization time. Diagnostics stay local, outside the contract JSON.
134
+ """
135
+ from . import blobs
136
+ root = _root(staging_dir)
137
+ scene_file = _file(root, ENTRY)
138
+ files = [{"path": name, "sha256": blobs.sha256(path), "size": path.stat().st_size}
139
+ for name, path in _staged_files(root)]
140
+ value = {"schema": SCHEMA, "blender": scene_info["blender"], "entry": ENTRY,
141
+ "files": sorted(files, key=lambda item: item["path"]),
142
+ "scene": {key: scene_info["scene"][key]
143
+ for key in ("name", "camera", "frame_start", "frame_end", "fps")},
144
+ "source": {key: source[key] for key in ("kind", "session", "checkpoint")},
145
+ "created_at": scene_info.get("created_at") or _timestamp(scene_file.stat().st_mtime)}
146
+ _validate_manifest(value)
147
+ return value
148
+
149
+
150
+ def _js_numbers(value):
151
+ """The service re-serializes the manifest in JavaScript and checks its hash, and JavaScript
152
+ writes a whole float as an integer: Python's "fps":24.0 must go out as "fps":24."""
153
+ if isinstance(value, float) and value.is_integer():
154
+ return int(value)
155
+ if isinstance(value, dict):
156
+ return {key: _js_numbers(item) for key, item in value.items()}
157
+ if isinstance(value, list):
158
+ return [_js_numbers(item) for item in value]
159
+ return value
160
+
161
+
162
+ def canonical_json(value):
163
+ _validate_manifest(value)
164
+ return json.dumps(_js_numbers(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False,
165
+ allow_nan=False).encode("utf-8")
166
+
167
+
168
+ def upload(staging_dir, manifest, *, client=None, cancel=None, progress=None, multipart=None):
169
+ """Upload absent file blobs, then the canonical manifest blob; return its SHA-256.
170
+
171
+ multipart: the manifest's capabilities.blob_multipart, so a large scene.blend goes in parts.
172
+
173
+ The manifest temporary file lives outside staging, so another manifest() call cannot
174
+ accidentally include it (or the bundle's own hash) in its files list.
175
+ """
176
+ from . import blobs, cloud_http
177
+ body = canonical_json(manifest)
178
+ root = _root(staging_dir)
179
+ files = [{**item, "path": _file(root, item["path"])} for item in manifest["files"]]
180
+ actual = {name for name, _ in _staged_files(root)}
181
+ if actual != {item["path"] for item in manifest["files"]}:
182
+ raise BundleError("Staging files differ from the manifest")
183
+ client = client or cloud_http.Client()
184
+ blobs.upload_missing(files, client=client, cancel=cancel, progress=progress, multipart=multipart)
185
+ digest = hashlib.sha256(body).hexdigest()
186
+ with tempfile.TemporaryDirectory(prefix="loopcut-manifest-") as temporary:
187
+ path = Path(temporary) / "manifest.json"
188
+ path.write_bytes(body)
189
+ blobs.upload_missing([{"path": path, "sha256": digest, "size": len(body), "mime": "application/json"}],
190
+ client=client, cancel=cancel, progress=progress)
191
+ return digest
192
+
193
+
194
+ # ------------------------------------------------------------------ Blender child
195
+
196
+ def _resolve_path(raw, base):
197
+ if raw.startswith("//"):
198
+ return (Path(base).parent / raw[2:]).resolve()
199
+ path = Path(raw)
200
+ return (path if path.is_absolute() else Path(base).parent / path).resolve()
201
+
202
+
203
+ def _is_system_path(path, system_roots):
204
+ """Classify normalized absolute paths without bpy or filesystem access.
205
+
206
+ Use path components, not string prefixes: a sibling such as 5.2-custom is external.
207
+ Empty resource roots mean unavailable, never the current working directory.
208
+ """
209
+ path = Path(os.path.normcase(os.path.abspath(path)))
210
+ return any(path.is_relative_to(Path(os.path.normcase(os.path.abspath(root))))
211
+ for root in system_roots if root)
212
+
213
+
214
+ def _system_roots(bpy):
215
+ roots = [bpy.utils.resource_path(kind) for kind in ("SYSTEM", "LOCAL")]
216
+ roots.append(bpy.utils.system_resource("DATAFILES"))
217
+ return tuple(Path(root).resolve() for root in roots if root)
218
+
219
+
220
+ def _members(path, sequence):
221
+ """Preserve numbering and tile names; regex input is escaped before compilation."""
222
+ if not sequence:
223
+ return [path] if path.is_file() else []
224
+ pattern = re.escape(path.name)
225
+ if "<UDIM>" in path.name:
226
+ pattern = pattern.replace(re.escape("<UDIM>"), r"[0-9]{4}")
227
+ elif "<UVTILE>" in path.name:
228
+ pattern = pattern.replace(re.escape("<UVTILE>"), r"u[0-9]+_v[0-9]+")
229
+ else:
230
+ match = re.fullmatch(r"(.*?)([0-9]+)(\.[^.]*)", path.name)
231
+ if not match:
232
+ return [path] if path.is_file() else []
233
+ pattern = re.escape(match[1]) + r"[0-9]{" + str(len(match[2])) + "}" + re.escape(match[3])
234
+ if not path.parent.is_dir():
235
+ return []
236
+ return sorted(p for p in path.parent.iterdir() if p.is_file() and re.fullmatch(pattern, p.name))
237
+
238
+
239
+ def _references(bpy, unsupported):
240
+ """Known input paths, including explicit simulation caches and sequencer media."""
241
+ refs, seen = [], set()
242
+
243
+ def add(owner, attribute, folder, *, sequence=False, directory=False, members=None, image=False):
244
+ key = (owner.as_pointer(), attribute)
245
+ raw = getattr(owner, attribute, "")
246
+ if not raw or key in seen or raw == "<builtin>":
247
+ return
248
+ seen.add(key)
249
+ idb = getattr(owner, "id_data", owner)
250
+ refs.append({"owner": owner, "attribute": attribute, "folder": folder, "raw": raw,
251
+ "sequence": sequence, "directory": directory, "members": members,
252
+ "image": image,
253
+ "library": getattr(idb, "library", None), "name": getattr(idb, "name", folder)})
254
+
255
+ for collection, folder in (("images", "textures"), ("sounds", "sounds"), ("movieclips", "clips"),
256
+ ("fonts", "fonts"), ("volumes", "volumes"), ("cache_files", "caches")):
257
+ for item in getattr(bpy.data, collection, ()):
258
+ if getattr(item, "packed_file", None) or getattr(item, "packed_files", ()):
259
+ continue
260
+ if collection == "images" and item.source not in {"FILE", "MOVIE", "SEQUENCE", "TILED"}:
261
+ continue
262
+ sequence = (getattr(item, "is_sequence", False)
263
+ or getattr(item, "source", "") in {"SEQUENCE", "TILED"})
264
+ add(item, "filepath_raw" if collection == "images" else "filepath", folder,
265
+ sequence=sequence, image=collection == "images")
266
+ for layer in getattr(item, "layers", ()) if collection == "cache_files" else ():
267
+ add(layer, "filepath", folder)
268
+
269
+ def point_cache(cache, label):
270
+ if cache is None:
271
+ return
272
+ if cache.use_external:
273
+ add(cache, "filepath", "caches", directory=True)
274
+ elif cache.use_disk_cache:
275
+ unsupported.append({"type": "cache", "name": label, "reason": "Implicit disk point cache"})
276
+
277
+ for obj in bpy.data.objects:
278
+ for mod in obj.modifiers:
279
+ point_cache(getattr(mod, "point_cache", None), obj.name)
280
+ domain = getattr(mod, "domain_settings", None)
281
+ if domain:
282
+ add(domain, "cache_directory", "caches", directory=True)
283
+ if mod.type in {"MESH_CACHE", "MULTIRES"}:
284
+ add(mod, "filepath", "caches")
285
+ if mod.type == "OCEAN" and mod.is_cached:
286
+ add(mod, "filepath", "caches", directory=True)
287
+ if mod.type == "NODES":
288
+ if mod.bake_target == "DISK":
289
+ add(mod, "bake_directory", "caches", directory=True)
290
+ for bake in mod.bakes:
291
+ target = bake.bake_target if bake.bake_target != "INHERIT" else mod.bake_target
292
+ if bake.use_custom_path and target == "DISK":
293
+ add(bake, "directory", "caches", directory=True)
294
+ for particles in obj.particle_systems:
295
+ point_cache(particles.point_cache, obj.name)
296
+ for scene in bpy.data.scenes:
297
+ if scene.rigidbody_world:
298
+ point_cache(scene.rigidbody_world.point_cache, scene.name)
299
+ if scene.sequence_editor:
300
+ for strip in scene.sequence_editor.strips_all:
301
+ if strip.type == "IMAGE":
302
+ add(strip, "directory", "textures", directory=True,
303
+ members=[element.filename for element in strip.elements])
304
+ elif strip.type == "MOVIE":
305
+ add(strip, "filepath", "clips")
306
+ return refs
307
+
308
+
309
+ def _not_inputs(bpy, base, library_paths):
310
+ """Paths blend_paths lists that Blender never reads, so the render machine never needs them.
311
+
312
+ ID.library_weak_reference is the file an ID was appended from with Append (Reuse Data), as
313
+ BlenderKit does; it is listed even for packed IDs (Report Missing Files skips it). A sound
314
+ strip keeps its own copy of the path it was made from (scene.cc lists it even when the sound
315
+ is packed); it plays the Sound, which is packed or copied. Python cannot read that copy, and
316
+ it goes stale once the sound is unpacked or relinked (a generated sound's temporary download),
317
+ so it is found per scene: the paths file_path_map lists for a scene that none of its movie or
318
+ image strips claim.
319
+ """
320
+ paths = set()
321
+ collection_type = getattr(getattr(bpy, "types", None), "bpy_prop_collection", list)
322
+ for name in dir(bpy.data):
323
+ collection = getattr(bpy.data, name, None)
324
+ if name.startswith("_") or not isinstance(collection, collection_type):
325
+ continue
326
+ for item in collection:
327
+ weak = getattr(item, "library_weak_reference", None)
328
+ if weak is not None and weak.filepath:
329
+ library = getattr(item, "library", None)
330
+ paths.add(_resolve_path(weak.filepath,
331
+ library_paths[library.as_pointer()] if library else base))
332
+ scenes = [scene for scene in bpy.data.scenes if scene.sequence_editor]
333
+ for scene, listed in (bpy.data.file_path_map(subset=scenes) if scenes else {}).items():
334
+ scene_base = library_paths[scene.library.as_pointer()] if scene.library else base
335
+ claimed = set()
336
+ for strip in scene.sequence_editor.strips_all:
337
+ if strip.type == "MOVIE":
338
+ claimed.add(_resolve_path(strip.filepath, scene_base))
339
+ elif strip.type == "IMAGE":
340
+ claimed.update(_resolve_path(os.path.join(strip.directory, element.filename), scene_base)
341
+ for element in strip.elements)
342
+ paths.update(path for raw in listed if raw and (path := _resolve_path(raw, scene_base)) not in claimed)
343
+ return paths
344
+
345
+
346
+ def _collect_opened(blend_path, staging_dir, original_path=None):
347
+ """Child only. The only save target is staging/scene.blend, with remapping disabled."""
348
+ import bpy
349
+ _root(staging_dir)
350
+ root = Path(staging_dir)
351
+ # Recheck in the child: the parent checked before launching another process.
352
+ if any(root.iterdir()):
353
+ raise BundleError("Bundle staging directory must be empty")
354
+ base = original_path or str(blend_path)
355
+ missing, unsupported = [], []
356
+ refs = _references(bpy, unsupported)
357
+ libraries = list(bpy.data.libraries)
358
+ library_paths = {lib.as_pointer(): _resolve_path(lib.filepath, base) for lib in libraries}
359
+ system_roots, system = _system_roots(bpy), set()
360
+
361
+ def system_path(path):
362
+ if _is_system_path(path, system_roots):
363
+ system.add(path)
364
+ return True
365
+ return False
366
+
367
+ refs = [ref for ref in refs if not system_path(_resolve_path(
368
+ ref["raw"], library_paths[ref["library"].as_pointer()] if ref["library"] else base))]
369
+ system_libraries = {key for key, path in library_paths.items() if system_path(path)}
370
+ complex_libraries = {ref["library"].as_pointer() for ref in refs if ref["library"]}
371
+ # A nested dependency also makes its ancestors complex, not just the leaf library.
372
+ for lib in libraries:
373
+ if lib.as_pointer() in system_libraries:
374
+ continue
375
+ parent = lib.parent
376
+ while parent:
377
+ complex_libraries.add(parent.as_pointer())
378
+ parent = parent.parent
379
+ handled, copied = set(), {}
380
+
381
+ def copy_reference(ref):
382
+ raw, owner, attribute = ref["raw"], ref["owner"], ref["attribute"]
383
+ path = _resolve_path(raw, base)
384
+ handled.add(str(path))
385
+ group_base = path if ref["directory"] else path.parent
386
+ group_key = str(group_base)
387
+ if ref.get("image") and owner.is_dirty:
388
+ # Two images can use the same disk file but have different recovered buffers.
389
+ group_key += "\0" + owner.name
390
+ group = hashlib.sha256(os.fsencode(group_key)).hexdigest()[:16]
391
+ relative = Path(ref["folder"]) / group
392
+ if not ref["directory"]:
393
+ relative /= path.name
394
+ _safe_path(relative.as_posix())
395
+ destination = root / relative
396
+ if ref["directory"]:
397
+ if ref["members"] is not None:
398
+ sources = [path / _safe_path(name) for name in ref["members"]]
399
+ else:
400
+ sources = sorted(p for p in path.rglob("*") if p.is_file()) if path.is_dir() else []
401
+ for child in path.rglob("*") if path.is_dir() else ():
402
+ if child.is_symlink() and child.is_dir():
403
+ unsupported.append({"type": "cache", "path": str(child),
404
+ "reason": "Symlinked cache directory"})
405
+ else:
406
+ sources = _members(path, ref["sequence"])
407
+ if not sources or (not ref["directory"] and not path.is_file()
408
+ and "<UDIM>" not in raw and "<UVTILE>" not in raw):
409
+ missing.append({"type": ref["folder"], "name": ref["name"], "path": raw})
410
+ for source in sources:
411
+ handled.add(str(source.resolve()))
412
+ if not source.is_file():
413
+ missing.append({"type": ref["folder"], "name": ref["name"], "path": str(source)})
414
+ continue
415
+ target = destination / source.relative_to(path) if ref["directory"] else destination.parent / source.name
416
+ _safe_path(target.relative_to(root).as_posix())
417
+ previous = copied.get(target)
418
+ if previous is not None and previous != source.resolve():
419
+ raise BundleError("Two external inputs map to the same bundle path")
420
+ copied[target] = source.resolve()
421
+ target.parent.mkdir(parents=True, exist_ok=True)
422
+ try:
423
+ shutil.copyfile(source, target)
424
+ except FileNotFoundError:
425
+ if source.is_file():
426
+ raise
427
+ missing.append({"type": ref["folder"], "name": ref["name"], "path": str(source)})
428
+ if ref.get("image") and owner.is_dirty:
429
+ if owner.source in {"FILE", "TILED"}:
430
+ destination.parent.mkdir(parents=True, exist_ok=True)
431
+ if owner.source == "FILE":
432
+ destination.unlink(missing_ok=True)
433
+ owner.save(filepath=str(destination), save_copy=True)
434
+ if owner.source == "FILE" and not destination.is_file():
435
+ raise BundleError("Blender could not save a recovered image buffer")
436
+ else:
437
+ unsupported.append({"type": "image", "name": ref["name"], "path": raw,
438
+ "reason": "Modified movie or sequence image buffer"})
439
+ setattr(owner, attribute, "//" + relative.as_posix() + ("/" if ref["directory"] else ""))
440
+
441
+ # Inspect unhandled inputs before rewriting anything. blend_paths also sees linked inputs
442
+ # which are not represented by the supported datablock types (for example external meshes).
443
+ not_inputs = _not_inputs(bpy, base, library_paths)
444
+
445
+ # An empty path names no file; resolved, it would be the scene's whole folder.
446
+ def input_paths(local):
447
+ return {path for raw in bpy.utils.blend_paths(absolute=True, local=local)
448
+ if raw and (path := _resolve_path(raw, base)) not in not_inputs and not system_path(path)}
449
+
450
+ local_paths, all_paths = input_paths(True), input_paths(False)
451
+ linked_paths = set()
452
+ for ref in refs:
453
+ if ref["library"]:
454
+ path = _resolve_path(ref["raw"], library_paths[ref["library"].as_pointer()])
455
+ linked_paths.add(path)
456
+ linked_paths.update(_members(path, ref["sequence"]))
457
+ if ref["directory"] and path.is_dir():
458
+ linked_paths.update(path.rglob("*"))
459
+ if all_paths - local_paths - linked_paths:
460
+ complex_libraries.update(lib.as_pointer() for lib in libraries)
461
+ for ref in refs:
462
+ if ref["library"]:
463
+ continue
464
+ copy_reference(ref)
465
+ for lib in libraries:
466
+ path = library_paths[lib.as_pointer()]
467
+ handled.add(str(path))
468
+ if lib.as_pointer() in system_libraries:
469
+ continue
470
+ if getattr(lib, "packed_file", None) and lib.as_pointer() not in complex_libraries:
471
+ continue
472
+ if lib.parent or lib.as_pointer() in complex_libraries or getattr(lib, "is_missing", False):
473
+ if not path.is_file() and not getattr(lib, "packed_file", None):
474
+ missing.append({"type": "libraries", "name": lib.name, "path": lib.filepath})
475
+ unsupported.append({"type": "library", "name": lib.name, "path": lib.filepath,
476
+ "reason": "Nested, missing or externally dependent linked library"})
477
+ continue
478
+ copy_reference({"owner": lib, "attribute": "filepath", "folder": "libraries", "raw": str(path),
479
+ "sequence": False, "directory": False, "members": None, "name": lib.name})
480
+ for path in sorted(local_paths):
481
+ if str(path) in handled:
482
+ continue
483
+ if path.exists():
484
+ unsupported.append({"type": "external", "path": str(path), "reason": "Unhandled input path"})
485
+ else:
486
+ missing.append({"type": "external", "path": str(path)})
487
+ scene = bpy.context.scene
488
+ result = {"blender": ".".join(map(str, bpy.app.version)),
489
+ "scene": {"name": scene.name, "camera": scene.camera.name if scene.camera else None,
490
+ "frame_start": scene.frame_start, "frame_end": scene.frame_end,
491
+ "fps": scene.render.fps / scene.render.fps_base},
492
+ "created_at": _timestamp(Path(blend_path).stat().st_mtime),
493
+ "missing": missing, "unsupported": unsupported, "system": [str(path) for path in sorted(system)]}
494
+ bpy.context.preferences.filepaths.save_version = 0
495
+ bpy.context.preferences.filepaths.save_modified_images = "NEVER_SAVE"
496
+ # Paths are already relative to staging; the default remap would rebase them a second time.
497
+ status = bpy.ops.wm.save_as_mainfile(filepath=str(root / ENTRY), compress=True, relative_remap=False)
498
+ if status != {"FINISHED"}:
499
+ raise BundleError("Blender could not save the bundled scene")
500
+ return result
501
+
502
+
503
+ def collect(blend_path, staging_dir, original_path=None):
504
+ """Collect in an isolated child. staging_dir must be empty; original_path identifies a
505
+ recovery snapshot's source filename (including '' for an unsaved scene). For stock
506
+ copy-saves pass None, because their relative paths were rebased to the snapshot folder.
507
+ Returns {blender, scene, created_at, missing, unsupported, system}; never uploads anything.
508
+ """
509
+ import bpy
510
+ from . import worker_ipc
511
+ blend = Path(blend_path).resolve()
512
+ root = _root(staging_dir)
513
+ if not blend.is_file():
514
+ raise BundleError("Checkpoint file is missing")
515
+ root.mkdir(parents=True, exist_ok=True)
516
+ if any(root.iterdir()):
517
+ raise BundleError("Bundle staging directory must be empty")
518
+ with tempfile.TemporaryDirectory(prefix="loopcut-bundle-") as temporary:
519
+ control = Path(temporary)
520
+ (control / "tmp").mkdir()
521
+ (control / "blender-config").mkdir()
522
+ worker_ipc.write_env_file(control, 0)
523
+ environment = worker_ipc.child_env(control, control)
524
+ environment.update(LOOPCUT_DATA_DIR=str(control / "data"), LOOPCUT_CONFIG_DIR=str(control / "config"),
525
+ BLENDER_USER_CONFIG=str(control / "blender-config"))
526
+ request, reply, log = control / "request.json", control / "reply.json", control / "blender.log"
527
+ request.write_text(json.dumps({"blend": str(blend), "staging": str(root), "original": original_path}),
528
+ encoding="utf-8")
529
+ if threading.current_thread() is threading.main_thread():
530
+ binary = bpy.app.binary_path
531
+ else:
532
+ from . import mainthread
533
+ binary = mainthread.run_on_main(lambda: bpy.app.binary_path).result(timeout=30)
534
+ command = [binary, "-b", "--factory-startup", "--disable-autoexec",
535
+ "--python-exit-code", "1", "--python", str(Path(__file__).resolve()),
536
+ "--", str(request), str(reply)]
537
+ try:
538
+ with log.open("wb") as output:
539
+ completed = subprocess.run(command, stdout=output, stderr=subprocess.STDOUT,
540
+ env=environment, timeout=600)
541
+ except (OSError, subprocess.TimeoutExpired) as ex:
542
+ raise BundleError("Could not run Blender bundle collection") from ex
543
+ if completed.returncode or not reply.is_file():
544
+ with log.open("rb") as output:
545
+ output.seek(max(0, output.seek(0, os.SEEK_END) - 4000))
546
+ tail = output.read(4000).decode("utf-8", errors="replace")
547
+ raise BundleError("Blender bundle collection failed: " + tail)
548
+ result = json.loads(reply.read_text(encoding="utf-8"))
549
+ if not isinstance(result, dict) or not (root / ENTRY).is_file():
550
+ raise BundleError("Blender returned an invalid bundle result")
551
+ return result
552
+
553
+
554
+ def _worker_main():
555
+ import bpy
556
+ request, reply = sys.argv[sys.argv.index("--") + 1:]
557
+ spec = json.loads(Path(request).read_text(encoding="utf-8"))
558
+ if spec["original"] is not None:
559
+ status = bpy.ops.wm.recover_auto_save(filepath=spec["blend"], use_scripts=False)
560
+ else:
561
+ status = bpy.ops.wm.open_mainfile(filepath=spec["blend"], load_ui=False, use_scripts=False)
562
+ if status != {"FINISHED"}:
563
+ raise BundleError("Blender could not open the checkpoint")
564
+ result = _collect_opened(spec["blend"], spec["staging"], spec["original"])
565
+ Path(reply).write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
566
+
567
+
568
+ if __name__ == "__main__":
569
+ _worker_main()