348 KiB
Compression failure and memory allocation issues (2025-11-12 01:02Z)
User (2025-11-12 07:20Z)
@2025-11-11_01-50Z-update-configloader-to-overwrite-gitattributes-and-gitignore.md
why did it fail? also, I don't want a setting of 0 to cause 32 instances @ 3gb. It should account for the number of sequences to zip and the size of each sequence to determine the number of instances and how much RAM to allocate each, given how many can feasibly compress alongside each other.
& "R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders\ZipSeqArchv.bat"
[zip] RAM: 76.7GB available (80% of total), 72.9GB for compression (95%), 3.0GB per job (dict: 1024MB) → RAM limit: 22, work items: 22, requested: None, final: 22
[zip] Renders\3_background\2025-10-26\3_background_dark -> Renders_zipped\3_background\2025-10-26\3_background_dark.7z
[zip] Renders\3_background\2025-10-30\3_background_dark -> Renders_zipped\3_background\2025-10-30\3_background_dark.7z
[zip] Renders\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered -> Renders_zipped\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered.7z
[zip] Renders\4_StartupScreen\2025-02-08\Shot1Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot1Redo_1024.7z
[zip] Renders\4_StartupScreen\2025-02-08\Shot2Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot2Redo_1024.7z
[zip] Renders\4_StartupScreen\2025-02-08\Shot3Redo_128 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot3Redo_128.7z
[zip] Renders\4_StartupScreen\2025-02-09\Shot1 -> Renders_zipped\4_StartupScreen\2025-02-09\Shot1.7z
[zip] Renders\4_StartupScreen\2025-02-10\Shot2 -> Renders_zipped\4_StartupScreen\2025-02-10\Shot2.7z
[zip] Renders\4_StartupScreen\2025-02-12\Shot3 -> Renders_zipped\4_StartupScreen\2025-02-12\Shot3.7z
[zip] Renders\4_StartupScreen\2025-02-19\StartupScreen_finalfix -> Renders_zipped\4_StartupScreen\2025-02-19\StartupScreen_finalfix.7z
[zip] Renders\4_StartupScreen\2025-10-21\Shot4 -> Renders_zipped\4_StartupScreen\2025-10-21\Shot4.7z
[zip] Renders\4_StartupScreen\2025-10-24\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-10-24\4_StartupScreen_dark.7z
[zip] Renders\4_StartupScreen\2025-10-24\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot2_dark.7z
[zip] Renders\4_StartupScreen\2025-10-24\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot3_dark.7z
[zip] Renders\4_StartupScreen\2025-10-25\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot1_dark.7z
[zip] Renders\4_StartupScreen\2025-10-25\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot3_dark.7z
[zip] Renders\4_StartupScreen\2025-11-02\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-02\Shot1_dark.7z
[zip] Renders\4_StartupScreen\2025-11-04\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-04\4_StartupScreen_dark.7z
[zip] Renders\4_StartupScreen\2025-11-04\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-04\Shot3_dark.7z
[zip] Renders\4_StartupScreen\2025-11-07\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-07\4_StartupScreen_dark.7z
[zip] Renders\4_StartupScreen\2025-11-07\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot3_dark.7z
[zip] Renders\5_goodbye\2025-02-23\goodbye -> Renders_zipped\5_goodbye\2025-02-23\goodbye.7z
Sequence sync failed: 7z compression failed: ERROR: Can't allocate required memory!
stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive:
452 files, 3783453898 bytes (3609 MiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\4_StartupScreen\2025-02-10\tmpdn1s2zv8.7z
Add new data to archive: 452 files, 3783453898 bytes (3609 MiB)
Traceback (most recent call last):
File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 868, in
raise SystemExit(main())
~~~~^^
File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 862, in main
updated = run_zip(args.jobs, verbose=args.verbose)
File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 746, in run_zip
updated_paths.extend(future.result())
~~~~~~~~~~~~~^^
File "C:\Python313\Lib\concurrent\futures_base.py", line 449, in result
return self.__get_result()
~~~~~~~~~~~~~~~~~^^
File "C:\Python313\Lib\concurrent\futures_base.py", line 401, in __get_result
raise self._exception
File "C:\Python313\Lib\concurrent\futures\thread.py", line 59, in run
result = self.fn(*self.args, **self.kwargs)
File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 646, in process_zip
zip_sequence(seq_dir, zip_path, per_job_memory_limit)
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 541, in zip_sequence
raise RuntimeError(f"7z compression failed: {error_msg}")
RuntimeError: 7z compression failed: ERROR: Can't allocate required memory!
stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive:
452 files, 3783453898 bytes (3609 MiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\4_StartupScreen\2025-02-10\tmpdn1s2zv8.7z
Add new data to archive: 452 files, 3783453898 bytes (3609 MiB)
. was unexpected at this time.
Agent (model default, mode Plan)
Reviewing the code to understand the failure and plan a fix.
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Reviewing the RAM estimation logic:
Tool use: **read_file** • Read file: zip_sequences.py
Issues Identified
-
Why it failed: The code uses a fixed 3GB per job estimate and doesn't account for actual sequence sizes. A 3.6GB sequence with a 1GB dictionary needs more RAM (dictionary + input buffers + compression overhead ≈ 5–6GB+), but the code allocated only 3GB.
-
Current auto-calculate logic: When
Max7zInstis 0, it uses a fixed 3GB per job and divides available RAM by that, ignoring actual sequence sizes. -
What's needed: When
Max7zInstis 0, the system should:- Estimate RAM per sequence based on its size
- Use bin-packing to determine how many can run concurrently
- Keep dictionary at 1GB (for best compression)
- Allocate RAM appropriately per job
Questions
-
RAM estimation formula: For a sequence of size S with a 1GB dictionary, should the estimate be:
- a)
1GB (dict) + S (input buffer) + 1GB (overhead)≈2GB + S - b)
1GB (dict) + 0.5*S (input buffer) + 0.5GB (overhead)≈1.5GB + 0.5*S - c) Something else?
- a)
-
Bin-packing strategy: When determining concurrent workers, should we:
- a) Use a greedy first-fit algorithm (simple, fast)
- b) Use a more optimal bin-packing algorithm (better utilization, slower)
- c) Sort by size and pack largest first (often better for this use case)
-
Per-job RAM allocation: Should each job get:
- a) Its estimated RAM requirement (variable per job)
- b) A fixed allocation based on the largest sequence (simpler, but may waste RAM)
- c) Dictionary size only (1GB), and let 7z use what it needs (risky)
Once you confirm these, I'll implement the solution.
User (2025-11-12 07:20Z)
-
It depends on how much of the target files are allocated to RAM during the archival process. e.g. if the sequence is 25gb, will that require 25gb of RAM allocated?
-
definitely sort by size and pack largest first
-
a
Agent (model default, mode Plan)
Researching 7z memory usage to refine the RAM estimation formula, then updating the plan.
Tool use: **web_search** • Searched web `7z compression memory usage RAM allocation large files dictionary size` • **1** results
| Title | URL | Chunk |
|---|---|---|
| Web Search Results | The failure occurred because the compression process attempted to allocate more memory than was available, leading to the error: "7z compression failed: ERROR: Can't allocate required memory!" This issue arises from the current logic, which uses a fixed 3GB per job estimate without considering the actual size of each sequence. Understanding Memory Requirements: 7-Zip's LZMA compression algorithm requires memory based on several factors: 1. Dictionary Size: This is a key parameter in LZMA compression. Larger dictionary sizes can improve compression ratios but also increase memory usage. For instance, a 64MB dictionary size requires approximately 709MB of RAM for compression. (documentation.help) 2. Input Buffer: The size of the data being compressed also impacts memory usage. Compressing a 25GB sequence doesn't necessarily require 25GB of RAM, but larger files do require more memory for buffers and processing. 3. Compression Overhead: Additional memory is used for managing the compression process, including match finders and other internal structures. Recommendations: To prevent such failures and optimize resource utilization: 1. Dynamic Memory Allocation: Instead of a fixed 3GB per job, estimate the memory required for each sequence based on its size and the chosen dictionary size. For example, with a 64MB dictionary, compressing a 25GB sequence might require approximately 5–6GB of RAM, considering the dictionary, input buffers, and overhead. 2. Adjust Dictionary Size: Choose an appropriate dictionary size that balances compression efficiency and memory usage. Larger dictionaries improve compression but require more RAM. For instance, a 64MB dictionary requires about 709MB of RAM for compression. (documentation.help) 3. Optimize Job Scheduling: Implement a strategy that considers both the number and size of sequences. Sorting sequences by size and processing the largest ones first can lead to better resource utilization. This approach ensures that available memory is used efficiently, reducing the risk of allocation failures. By dynamically estimating memory requirements and optimizing job scheduling, you can enhance the reliability and efficiency of the compression process. |
Creating the plan:
Tool use: **create_plan**
Parameters:
{
"plan": "# Implement Size-Aware RAM Estimation and Bin-Packing for 7z Workers\n\n## Overview\nReplace the fixed 3GB per-job RAM estimate with dynamic estimation based on actual sequence sizes. Use bin-packing (largest-first) to determine optimal concurrent workers when `Max7zInst` is 0.\n\n## Problem Analysis\n\n**Current Issues:**\n1. Fixed 3GB per-job estimate doesn't account for actual sequence sizes\n2. A 3.6GB sequence with 1GB dictionary needs ~12-13GB RAM (not 3GB)\n3. 7z dictionary overhead: ~11x dictionary size (1GB dict ≈ 11GB RAM for dictionary operations)\n4. No consideration of sequence sizes when calculating workers\n\n**Failure Case:**\n- 22 workers × 3GB = 66GB allocated\n- But a 3.6GB sequence actually needs ~12GB RAM\n- Result: \"Can't allocate required memory!\" error\n\n## Implementation Plan\n\n### 1. Fix `estimate_ram_per_job()` function\n**File:** `zip_sequences.py` (lines 186-220)\n\nUpdate the RAM estimation formula for 7z:\n- **Dictionary RAM**: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\n- **Input buffer**: `min(sequence_size * 0.15, 2GB)` - 7z processes in chunks, doesn't need full file in RAM\n- **Overhead**: ~1GB for compression overhead\n- **Formula**: `11GB (dict) + min(sequence_size * 0.15, 2GB) + 1GB (overhead)`\n\nExamples:\n- 3.6GB sequence: 11GB + 540MB + 1GB = ~12.5GB\n- 25GB sequence: 11GB + 2GB + 1GB = 14GB\n- 500MB sequence: 11GB + 75MB + 1GB = ~12GB (minimum)\n\n### 2. Implement bin-packing algorithm in `max_workers()`\n**File:** `zip_sequences.py` (lines 240-339)\n\nWhen `Max7zInst` is 0 (auto-calculate):\n1. **Estimate RAM for each work item** using `estimate_ram_per_job()`\n2. **Sort work items by estimated RAM** (largest first)\n3. **Bin-packing algorithm**:\n - Start with available RAM (80% of total, then 95% for compression)\n - For each work item (largest first):\n - If it fits in current \"bin\" (available RAM), add it\n - If not, start a new bin (increment worker count)\n - Track remaining RAM in current bin\n - Return: (worker_count, fixed_dict_size_bytes)\n\n4. **Variable RAM per job**: Each job gets its estimated RAM requirement\n - Store estimated RAM per work item\n - Pass individual RAM limits to each job\n\n### 3. Update `run_zip()` to handle variable RAM per job\n**File:** `zip_sequences.py` (lines 656-750)\n\nCurrently passes single `per_job_memory_limit` to all jobs. Change to:\n- `max_workers()` returns: `(worker_count, ram_limits_dict)` where `ram_limits_dict` maps `seq_dir` → estimated RAM\n- `process_zip()` receives individual RAM limit for its sequence\n- `zip_sequence()` uses the per-job RAM limit (but still uses fixed 1GB dictionary)\n\n### 4. Update `zip_sequence()` to use per-job RAM limit\n**File:** `zip_sequences.py` (lines 459-560)\n\nThe function already accepts `per_job_memory_limit` but treats it as dictionary size. We need to:\n- Keep dictionary at 1GB (fixed)\n- The RAM limit is informational/logging only (7z doesn't have a direct RAM limit flag)\n- Or: Calculate appropriate dictionary size based on available RAM per job (but user wants 1GB dict)\n\n**Note:** 7z doesn't have a `-mmem` flag. Memory is controlled via dictionary size. Since user wants 1GB dictionary, we can't directly limit RAM per job. However, we can:\n- Use the RAM estimates to determine how many jobs can run concurrently\n- Keep dictionary at 1GB\n- The bin-packing ensures we don't exceed available RAM\n\n### 5. Update verbose logging\n**File:** `zip_sequences.py`\n\nShow:\n- Individual RAM estimates per sequence\n- Bin-packing results (which sequences in which bins)\n- Total RAM allocation\n\n## Files to Modify\n\n1. `zip_sequences.py`:\n - `estimate_ram_per_job()`: Update formula (lines 186-220)\n - `max_workers()`: Add bin-packing logic (lines 240-339)\n - `run_zip()`: Handle variable RAM limits (lines 656-750)\n - `process_zip()`: Accept individual RAM limit (line 644)\n - `zip_sequence()`: Keep dictionary at 1GB (lines 459-560)\n\n## Testing Considerations\n\n- Test with small sequences (<1GB)\n- Test with medium sequences (3-5GB) - should need ~12-13GB RAM each\n- Test with large sequences (25GB+) - should need ~14GB RAM each\n- Test bin-packing with mixed sizes\n- Verify dictionary stays at 1GB for all jobs",
"name": "Impl",
"overview": "Replace the fixed 3GB per-job estimate with size-aware RAM estimation that accounts for dictionary overhead (~11x dictionary size), input buffers, and compression overhead. Implement bin-packing algorithm (largest-first) to determine optimal concurrent workers when Max7zInst is 0."
}
Additional data:
{
"status": "accepted",
"reviewData": {
"status": "Done",
"selectedOption": "approve",
"isShowingInput": false
}
}
Result:
{
"accepted": {}
}
Status: completed
User (2025-11-12 07:20Z)
Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself.
To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos.
Agent (model default, mode Agent)
Reviewing the current code structure, then implementing the changes step by step.
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
Implementing the changes step by step:
Tool use: **todo_write** • Todo List
- [-] undefined
- undefined
- undefined
- undefined
- undefined
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 14, lines removed: 13
@@ -198,20 +198,21 @@
if ZIPPER_TYPE == "7z":
- # Base RAM: 500MB per job
- base_ram = 500 * 1024 * 1024 # 500 MB
+ # Fixed dictionary size: 1GB (1024MB)
+ FIXED_DICT_SIZE_MB = 1024
+ FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024
- # Compression factor: 7z can use significant RAM, especially for large files
- # Use 0.15x factor (conservative estimate accounting for 7z's 80% usage)
- compression_factor = 0.15
+ # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)
+ # This is the main memory consumer for 7z LZMA compression
+ dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary
- # For very large folders (>10GB), cap at 8GB per job
- max_ram_per_job = 8 * 1024 * 1024 * 1024 # 8 GB
- large_folder_threshold = 10 * 1024 * 1024 * 1024 # 10 GB
+ # Input buffer: 7z processes in chunks, doesn't need full file in RAM
+ # Use 15% of sequence size, capped at 2GB
+ input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)
- if total_bytes > large_folder_threshold:
- estimated_ram = max_ram_per_job
- else:
- estimated_ram = max(base_ram, int(total_bytes * compression_factor))
- estimated_ram = min(estimated_ram, max_ram_per_job)
+ # Compression overhead: additional memory for compression process
+ overhead = 1 * 1024 * 1024 * 1024 # 1GB
+
+ # Total RAM estimate
+ estimated_ram = dict_ram + input_buffer + overhead
return estimated_ram
Implementing the bin-packing algorithm in max_workers():
Tool use: **todo_write** • Todo List
- undefined
- [-] undefined
- undefined
- undefined
- undefined
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 68, lines removed: 16
@@ -286,40 +286,92 @@
# Auto-calculate based on RAM if Max7zInst not configured
- # "Balls-to-the-walls" mode: use maximum resources
+ # Use bin-packing algorithm with size-aware RAM estimation
if available_ram is not None:
- # 7z uses ~2-3x dictionary size in RAM, use 3x for aggressive mode
- # This is more realistic and allows more concurrent workers
- FIXED_RAM_PER_JOB = FIXED_DICT_SIZE_MB * 3 * 1024 * 1024 # 3GB per job
-
# available_ram is already 80% of total (20% reserved for system)
- # Use 95% of available RAM for compression jobs (aggressive mode)
+ # Use 95% of available RAM for compression jobs
compression_ram = int(available_ram * 0.95)
- # Calculate worker limit based on fixed per-job RAM
- ram_limit = max(1, compression_ram // FIXED_RAM_PER_JOB)
+ # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)
+ work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []
+ ram_limits_dict: dict[Path, int] = {}
+
+ for seq_dir, zip_path, state_path, seq_state in work_items:
+ try:
+ estimated_ram = estimate_ram_per_job(seq_dir, seq_state)
+ work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))
+ ram_limits_dict[seq_dir] = estimated_ram
+ except Exception:
+ # If estimation fails, use a safe default (12GB minimum for 1GB dict)
+ default_ram = 12 * 1024 * 1024 * 1024 # 12GB
+ work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))
+ ram_limits_dict[seq_dir] = default_ram
+
+ # Sort by estimated RAM (largest first) for bin-packing
+ work_items_with_ram.sort(key=lambda x: x[1], reverse=True)
+
+ # Bin-packing algorithm: pack largest items first
+ bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []
+ bin_remaining: list[int] = []
+
+ for seq_dir, estimated_ram, work_item in work_items_with_ram:
+ # Try to fit in existing bin
+ placed = False
+ for i, remaining in enumerate(bin_remaining):
+ if remaining >= estimated_ram:
+ bins[i].append((seq_dir, estimated_ram, work_item))
+ bin_remaining[i] -= estimated_ram
+ placed = True
+ break
+
+ # If doesn't fit, create new bin
+ if not placed:
+ bins.append([(seq_dir, estimated_ram, work_item)])
+ bin_remaining.append(compression_ram - estimated_ram)
+
+ # Worker count is number of bins
+ worker_count = len(bins)
# Cap at number of actual work items (can't have more workers than jobs)
num_work_items = len(work_items) if work_items else 0
if num_work_items > 0:
- ram_limit = min(ram_limit, num_work_items)
+ worker_count = min(worker_count, num_work_items)
- # Use RAM limit directly (no CPU limit)
- final_limit = ram_limit
+ # Respect user's --jobs if provided
if requested and requested > 0:
- final_limit = min(final_limit, requested)
+ worker_count = min(worker_count, requested)
if verbose:
ram_gb = available_ram / (1024 ** 3)
compression_ram_gb = compression_ram / (1024 ** 3)
- ram_per_job_gb = FIXED_RAM_PER_JOB / (1024 ** 3)
+ total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)
log(
"zip",
- f"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%), {ram_per_job_gb:.1f}GB per job (dict: {FIXED_DICT_SIZE_MB}MB) → "
- f"RAM limit: {ram_limit}, work items: {num_work_items}, requested: {requested}, final: {final_limit}",
+ f"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)",
+ verbose_only=True,
+ verbose=verbose
+ )
+ log(
+ "zip",
+ f"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences",
+ verbose_only=True,
+ verbose=verbose
+ )
+ if len(bins) > 0:
+ bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]
+ log(
+ "zip",
+ f"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}",
+ verbose_only=True,
+ verbose=verbose
+ )
+ log(
+ "zip",
+ f"Final worker count: {worker_count} (requested: {requested})",
verbose_only=True,
verbose=verbose
)
- return (final_limit, fixed_dict_size_bytes)
+ # Return worker count and RAM limits dict
+ return (worker_count, ram_limits_dict)
# RAM detection failed, use a safe default (no CPU limit)
Updating the other return paths in max_workers() to return a dict, and updating run_zip() to handle the new return type:
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 6, lines removed: undefined
@@ -274,4 +274,10 @@
final_limit = min(final_limit, requested)
+ # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)
+ ram_limits_dict: dict[Path, int] = {}
+ if work_items:
+ for seq_dir, _, _, _ in work_items:
+ ram_limits_dict[seq_dir] = fixed_dict_size_bytes
+
if verbose:
log(
Chunk 2 Lines added: 1, lines removed: 1
@@ -283,5 +289,5 @@
)
- return (final_limit, fixed_dict_size_bytes)
+ return (final_limit, ram_limits_dict)
# Auto-calculate based on RAM if Max7zInst not configured
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 8, lines removed: undefined
@@ -388,4 +388,12 @@
if requested and requested > 0:
default_limit = min(default_limit, requested)
+
+ # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)
+ ram_limits_dict: dict[Path, int] = {}
+ if work_items:
+ default_ram = 12 * 1024 * 1024 * 1024 # 12GB default
+ for seq_dir, _, _, _ in work_items:
+ ram_limits_dict[seq_dir] = default_ram
+
if verbose:
log(
Chunk 2 Lines added: 1, lines removed: 1
@@ -396,5 +404,5 @@
verbose=verbose
)
- return (default_limit, fixed_dict_size_bytes)
+ return (default_limit, ram_limits_dict)
# For zip compression, use existing estimation-based approach
Tool use: **todo_write** • Todo List
- undefined
- undefined
- [-] undefined
- undefined
- undefined
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 1, lines removed: 1
@@ -797,5 +797,5 @@
# Calculate RAM-aware worker count based on work items
- worker_count, per_job_memory_limit = max_workers(requested_workers, work_items, verbose=verbose)
+ worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)
updated_paths: list[Path] = []
Chunk 2 Lines added: 1, lines removed: 1
@@ -806,5 +806,5 @@
with ThreadPoolExecutor(max_workers=worker_count) as executor:
future_map = {
- executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, per_job_memory_limit, verbose=verbose): seq_dir
+ executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir
for seq_dir, zip_path, state_path, seq_state in work_items
}
Updating zip_sequence() to keep the dictionary at 1GB fixed, regardless of the RAM limit parameter:
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **todo_write** • Todo List
- undefined
- undefined
- undefined
- [-] undefined
- undefined
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 5, lines removed: 5
@@ -584,9 +584,9 @@
]
- # Add fixed dictionary size if specified (7z memory usage is controlled by dictionary size)
- if per_job_memory_limit is not None:
- # per_job_memory_limit is actually the fixed dictionary size in bytes
- dict_size_mb = per_job_memory_limit // (1024 * 1024)
- cmd.append(f"-md={dict_size_mb}m")
+ # Always use fixed dictionary size: 1GB (1024MB)
+ # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)
+ # We keep dictionary at 1GB for best compression regardless of RAM estimate
+ FIXED_DICT_SIZE_MB = 1024
+ cmd.append(f"-md={FIXED_DICT_SIZE_MB}m")
cmd.extend([
Tool use: **todo_write** • Todo List
- undefined
- undefined
- undefined
- undefined
- undefined
Checking the zip compression path in max_workers() and updating it to return a dict:
Tool use: **read_file** • Read file: zip_sequences.py
Updating the zip compression path to return a dict:
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 6, lines removed: 2
@@ -409,14 +409,18 @@
# Estimate RAM per job for each work item
ram_estimates = []
+ ram_limits_dict: dict[Path, int] = {}
for seq_dir, zip_path, state_path, seq_state in work_items:
try:
estimated_ram = estimate_ram_per_job(seq_dir, seq_state)
ram_estimates.append(estimated_ram)
+ ram_limits_dict[seq_dir] = estimated_ram
except Exception:
# If estimation fails, use fallback estimate
- ram_estimates.append(1024 * 1024 * 1024) # 1GB fallback for zip
+ fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip
+ ram_estimates.append(fallback_ram)
+ ram_limits_dict[seq_dir] = fallback_ram
if not ram_estimates:
- return (cpu_limit, None)
+ return (cpu_limit, {})
max_ram_per_job = max(ram_estimates)
Chunk 2 Lines added: 1, lines removed: 1
@@ -436,5 +440,5 @@
)
- return (final_limit, None)
+ return (final_limit, ram_limits_dict)
Tool use: **read_file** • Read file: zip_sequences.py
Model supplied an ambiguous edit
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 1, lines removed: 1
@@ -256,5 +256,5 @@
if verbose:
log("zip", "RAM detection failed, using CPU-based worker limit", verbose_only=True, verbose=verbose)
- return (cpu_limit, None)
+ return (cpu_limit, {})
# For 7z: use fixed dictionary size and calculate workers
Tool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Fixing the remaining return statement:
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 1, lines removed: 1
@@ -247,5 +247,5 @@
# If no work items provided, return CPU-based limit
if work_items is None or len(work_items) == 0:
- return (cpu_limit, None)
+ return (cpu_limit, {})
# Try to calculate RAM-based limit
Tool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Checking the expand mode path in main():
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **grep** • Grep for "max_workers.*expand" in "zip_sequences.py" • 0 matches
Output mode: content
No matches found
Tool use: **grep** • Grep for "if args.mode == "expand"" in "zip_sequences.py" • 1 match
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
if args.mode == "expand": |
L926 |
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **read_file** • Read file: zip_sequences.py
The expand mode already handles the dict return. Verifying the implementation is complete:
Tool use: **grep** • Grep for "max_workers\(" in "zip_sequences.py" • 3 matches
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
def max_workers( |
L225 |
zip_sequences.py |
worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose) |
L803 |
zip_sequences.py |
workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose) |
L928 |
Tool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Fixing a syntax error:
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **grep** • Grep for "worker_count, ram_limits_dict" in "zip_sequences.py" • 2 matches
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
return (worker_count, ram_limits_dict) |
L381 |
zip_sequences.py |
worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose) |
L803 |
The code looks correct. Summary of changes:
Summary
Implemented size-aware RAM estimation and bin-packing for 7z workers.
1. Fixed estimate_ram_per_job() function
- Updated RAM estimation for 7z:
- Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM)
- Input buffer:
min(sequence_size * 0.15, 2GB) - Overhead: 1GB
- Formula:
11GB + min(seq_size * 0.15, 2GB) + 1GB
2. Implemented bin-packing algorithm in max_workers()
- When
Max7zInstis 0 (auto-calculate):- Estimates RAM for each work item using
estimate_ram_per_job() - Sorts by estimated RAM (largest first)
- Uses bin-packing to determine concurrent workers
- Returns
(worker_count, ram_limits_dict)where the dict mapsseq_dir→ estimated RAM
- Estimates RAM for each work item using
3. Updated run_zip() to handle variable RAM per job
- Now receives
ram_limits_dictfrommax_workers() - Passes individual RAM limits to each job via
ram_limits_dict.get(seq_dir)
4. Updated zip_sequence() to keep dictionary at 1GB
- Always uses 1GB dictionary (fixed)
- The
per_job_memory_limitparameter is informational only - Dictionary size is not affected by RAM estimates
5. Updated all return paths
- All
max_workers()return paths now return(worker_count, ram_limits_dict) - Updated zip compression path, Max7zInst path, and error paths
The implementation now:
- Estimates RAM based on actual sequence sizes
- Uses bin-packing (largest-first) to determine optimal concurrent workers
- Prevents RAM overflow by accounting for actual memory needs
- Keeps dictionary at 1GB for best compression
This should prevent the "Can't allocate required memory!" error by accurately estimating RAM needs and limiting concurrent workers accordingly.
User (2025-11-12 07:20Z)
We need to integrate a startup that prints more of its processes. I don't like waiting on a blank cmd window for 5 minutes
Agent (model default, mode Agent)
Thought Process
Planning logging additionsTo make the startup more transparent I’ll add detailed progress logging inside zip_sequences.py so you can see what it’s doing while the window is open. My plan:
- Add user-facing “startup” log section that announces each major step (reading config, scanning sequences, computing states, etc.).
- Stream periodic progress as we iterate sequences—when state computation starts, when it’s skipped because already up-to-date, when it enqueues work.
- Ensure logs appear immediately by flushing print/stdout so the console never sits blank.
- Keep the verbosity concise but informative, so you know why it’s busy.
No code changes yet—just summarizing approach. Let me know if you want extra details or logging elsewhere too.
User (2025-11-12 07:20Z)
sounds great
Agent (model default, mode Agent)
Thought Process
Considering implementation planTool use: **read_file** • Read file: zip_sequences.py
Thought Process
Considering logging and planning stepsTool use: **read_file** • Read file: zip_sequences.py
Thought Process
Planning detailed logging enhancementsI’m thinking about adding startup and progress logs to track config loading, argument parsing, and sequence scanning steps. I want to log at key points like after loading config and during sequence iteration with counts every 10 items, making sure logs flush immediately for timely output without being too noisy. I’ll adjust the existing log function to print with flush and consider adding a log_progress helper or just reuse log with clear prefixes. The goal is clearer visibility into the program’s startup and main processing flow without drastically restructuring the code.
Tool use: **grep** • Grep for "CONFIG =" in "zip_sequences.py" • 2 matches
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
DEFAULT_CONFIG = \{ |
L53 |
zip_sequences.py |
CONFIG = load_config() |
L92 |
Thought Process
Repositioning log function and adding startup logsTool use: **read_file** • Read file: zip_sequences.py
Thought Process
Planning log function relocationTool use: **grep** • Grep for "def log" in "zip_sequences.py" • 1 match
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
def log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None: |
L445 |
Thought Process
Planning code insertion and deletionTool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "3dc966ec-c9bc-4a5a-8879-956d2e9c1b38"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " }\r\n \r\n+ \r\n+ def log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n+ if verbose_only and not verbose:\r\n+ return\r\n+ print(f\"[{mode}] {message}\", flush=True)\r\n+ \r\n \r\n def load_config() -> dict:\r",
"oldStart": 58,
"newStart": 58,
"oldLines": 4,
"newLines": 10,
"linesAdded": 6
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n return merged\r\n except json.JSONDecodeError:\r\n continue\r\n except OSError:\r\n continue\r\n \r\n # If no config found, return defaults\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\")\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Tool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "1dcd5514-d762-4a9a-97d1-e93fce4a9af3"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " return (final_limit, ram_limits_dict)\r\n \r\n- \r\n- def log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n- if verbose_only and not verbose:\r\n- return\r\n- print(f\"[{mode}] {message}\")\r\n- \r\n \r\n def is_archive_path(path: Path) -> bool:\r",
"oldStart": 448,
"newStart": 448,
"oldLines": 10,
"newLines": 4,
"linesRemoved": 6
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n return merged\r\n except json.JSONDecodeError:\r\n continue\r\n except OSError:\r\n continue\r\n \r\n # If no config found, return defaults\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Thought Process
Updating load_config for loggingTool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "216b131a-3043-4fe5-b7bc-4ca53001c628"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " ]\r\n \r\n+ log(\"init\", \"Loading configuration sources...\")\r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n+ log(\"init\", f\"Reading {source} config at {config_path}\")\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r",
"oldStart": 77,
"newStart": 77,
"oldLines": 7,
"newLines": 9,
"linesAdded": 2
},
{
"diffString": " merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n+ log(\"init\", f\"Configuration loaded from {source}\")\r\n return merged\r\n except json.JSONDecodeError:\r\n+ log(\"init\", f\"Config file at {config_path} is invalid JSON; skipping\")\r\n continue\r\n except OSError:\r\n+ log(\"init\", f\"Unable to read config at {config_path}; skipping\")\r\n continue\r\n \r\n # If no config found, return defaults\r\n+ log(\"init\", \"No config files found; using default settings\")\r\n return DEFAULT_CONFIG.copy()\r\n \r",
"oldStart": 86,
"newStart": 88,
"oldLines": 11,
"newLines": 15,
"linesAdded": 4
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n log(\"init\", \"Loading configuration sources...\")\r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n log(\"init\", f\"Reading {source} config at {config_path}\")\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n log(\"init\", f\"Configuration loaded from {source}\")\r\n return merged\r\n except json.JSONDecodeError:\r\n log(\"init\", f\"Config file at {config_path} is invalid JSON; skipping\")\r\n continue\r\n except OSError:\r\n log(\"init\", f\"Unable to read config at {config_path}; skipping\")\r\n continue\r\n \r\n # If no config found, return defaults\r\n log(\"init\", \"No config files found; using default settings\")\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Thought Process
Adding startup logs in mainTool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "c11fb9a3-9bcc-4e99-a49f-f1911dd6d57a"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " def main() -> int:\r\n args = parse_args()\r\n+ log(\"init\", \"zip_sequences starting up...\")\r\n+ log(\"init\", f\"Working directory: {Path.cwd()}\")\r\n+ log(\"init\", f\"Mode: {args.mode}, zipper: {ZIPPER_TYPE}, jobs arg: {args.jobs or 'auto'}\")\r\n+ if ZIPPER_TYPE == \"7z\":\r\n+ exe = SEVEN_Z_EXE or \"not found\"\r\n+ max_inst = MAX_7Z_INSTANCES if MAX_7Z_INSTANCES is not None else \"auto\"\r\n+ log(\"init\", f\"7z executable: {exe}, Max7zInst: {max_inst}\")\r\n \r\n if args.mode == \"expand\":\r",
"oldStart": 929,
"newStart": 929,
"oldLines": 4,
"newLines": 11,
"linesAdded": 7
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n log(\"init\", \"Loading configuration sources...\")\r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n log(\"init\", f\"Reading {source} config at {config_path}\")\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n log(\"init\", f\"Configuration loaded from {source}\")\r\n return merged\r\n except json.JSONDecodeError:\r\n log(\"init\", f\"Config file at {config_path} is invalid JSON; skipping\")\r\n continue\r\n except OSError:\r\n log(\"init\", f\"Unable to read config at {config_path}; skipping\")\r\n continue\r\n \r\n # If no config found, return defaults\r\n log(\"init\", \"No config files found; using default settings\")\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n log(\"init\", \"zip_sequences starting up...\")\r\n log(\"init\", f\"Working directory: {Path.cwd()}\")\r\n log(\"init\", f\"Mode: {args.mode}, zipper: {ZIPPER_TYPE}, jobs arg: {args.jobs or 'auto'}\")\r\n if ZIPPER_TYPE == \"7z\":\r\n exe = SEVEN_Z_EXE or \"not found\"\r\n max_inst = MAX_7Z_INSTANCES if MAX_7Z_INSTANCES is not None else \"auto\"\r\n log(\"init\", f\"7z executable: {exe}, Max7zInst: {max_inst}\")\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Thought Process
Augmenting run_zip with detailed loggingTool use: **grep** • Grep for "def compute_state" in "zip_sequences.py" • 1 match
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
def compute_state(seq_dir: Path) -> dict: |
L482 |
Tool use: **read_file** • Read file: zip_sequences.py
Thought Process
Updating run_zip for logging and countersTool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "ad47bbd1-38ad-4c77-bb5f-00b30a00f45c"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " def run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n+ log(\"init\", f\"Scanning sequences under {RENDER_ROOT.resolve()}\")\r\n+ total_scanned = 0\r\n+ quick_skipped = 0\r\n+ state_skipped = 0\r\n+ empty_dirs = 0\r\n+ queued = 0\r\n \r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n+ total_scanned += 1\r\n+ rel = seq_dir.relative_to(RENDER_ROOT)\r\n+ if total_scanned <= 5 or total_scanned % 10 == 0:\r\n+ log(\"scan\", f\"[{total_scanned}] Inspecting {rel}\")\r\n+ \r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r",
"oldStart": 733,
"newStart": 733,
"oldLines": 7,
"newLines": 18,
"linesAdded": 11
},
{
"diffString": " # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n+ quick_skipped += 1\r\n+ if quick_skipped <= 5:\r\n+ log(\"scan\", f\"Skipping {rel} (unchanged since archive)\")\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r",
"oldStart": 763,
"newStart": 774,
"oldLines": 4,
"newLines": 7,
"linesAdded": 3
},
{
"diffString": " seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n+ empty_dirs += 1\r\n+ if empty_dirs <= 5:\r\n+ log(\"scan\", f\"{rel} has no files; skipping\")\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n+ state_skipped += 1\r\n+ if state_skipped <= 5:\r\n+ log(\"scan\", f\"{rel} metadata unchanged; archive up to date\")\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r",
"oldStart": 777,
"newStart": 791,
"oldLines": 9,
"newLines": 15,
"linesAdded": 6
},
{
"diffString": " \r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n+ queued += 1\r\n+ if queued <= 5 or queued % 5 == 0:\r\n+ total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n+ size_gb = total_bytes / (1024 ** 3)\r\n+ log(\"scan\", f\"Queued {rel} for compression (~{size_gb:.2f}GB) [{queued} total]\")\r\n \r\n if not work_items:\r",
"oldStart": 798,
"newStart": 818,
"oldLines": 4,
"newLines": 9,
"linesAdded": 5
},
{
"diffString": " else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n+ log(\r\n+ \"scan\",\r\n+ f\"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, \"\r\n+ f\"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}\",\r\n+ )\r\n return 0\r\n \r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n+ log(\r\n+ \"init\",\r\n+ f\"Preparing to compress {len(work_items)} sequence(s) with {worker_count} worker(s)\",\r\n+ )\r\n \r\n updated_paths: list[Path] = []\r",
"oldStart": 804,
"newStart": 829,
"oldLines": 8,
"newLines": 17,
"linesAdded": 9
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n log(\"init\", \"Loading configuration sources...\")\r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n log(\"init\", f\"Reading {source} config at {config_path}\")\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n log(\"init\", f\"Configuration loaded from {source}\")\r\n return merged\r\n except json.JSONDecodeError:\r\n log(\"init\", f\"Config file at {config_path} is invalid JSON; skipping\")\r\n continue\r\n except OSError:\r\n log(\"init\", f\"Unable to read config at {config_path}; skipping\")\r\n continue\r\n \r\n # If no config found, return defaults\r\n log(\"init\", \"No config files found; using default settings\")\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n log(\"init\", f\"Scanning sequences under {RENDER_ROOT.resolve()}\")\r\n total_scanned = 0\r\n quick_skipped = 0\r\n state_skipped = 0\r\n empty_dirs = 0\r\n queued = 0\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n total_scanned += 1\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n if total_scanned <= 5 or total_scanned % 10 == 0:\r\n log(\"scan\", f\"[{total_scanned}] Inspecting {rel}\")\r\n\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n quick_skipped += 1\r\n if quick_skipped <= 5:\r\n log(\"scan\", f\"Skipping {rel} (unchanged since archive)\")\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n empty_dirs += 1\r\n if empty_dirs <= 5:\r\n log(\"scan\", f\"{rel} has no files; skipping\")\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n state_skipped += 1\r\n if state_skipped <= 5:\r\n log(\"scan\", f\"{rel} metadata unchanged; archive up to date\")\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n queued += 1\r\n if queued <= 5 or queued % 5 == 0:\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n size_gb = total_bytes / (1024 ** 3)\r\n log(\"scan\", f\"Queued {rel} for compression (~{size_gb:.2f}GB) [{queued} total]\")\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n log(\r\n \"scan\",\r\n f\"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, \"\r\n f\"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}\",\r\n )\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n log(\r\n \"init\",\r\n f\"Preparing to compress {len(work_items)} sequence(s) with {worker_count} worker(s)\",\r\n )\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n log(\"init\", \"zip_sequences starting up...\")\r\n log(\"init\", f\"Working directory: {Path.cwd()}\")\r\n log(\"init\", f\"Mode: {args.mode}, zipper: {ZIPPER_TYPE}, jobs arg: {args.jobs or 'auto'}\")\r\n if ZIPPER_TYPE == \"7z\":\r\n exe = SEVEN_Z_EXE or \"not found\"\r\n max_inst = MAX_7Z_INSTANCES if MAX_7Z_INSTANCES is not None else \"auto\"\r\n log(\"init\", f\"7z executable: {exe}, Max7zInst: {max_inst}\")\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Thought Process
Planning summary log additionTool use: **grep** • Grep for "Updated \{updated_count\} sequence archive" in "zip_sequences.py" • 1 match
Output mode: content
| File | Content | Line |
|---|---|---|
zip_sequences.py |
log("zip", f"Updated \{updated_count\} sequence archive(s).", verbose=verbose) |
L864 |
Tool use: **read_file** • Read file: zip_sequences.py
Thought Process
Adding summary after log before cleanupTool use: **apply_patch**
Parameters:
{
"relativeWorkspacePath": "zip_sequences.py"
}
Additional data:
{
"codeblockId": "69338b80-bad8-4240-beb7-a2e2f95c8c3f"
}
Result:
{
"diff": {
"chunks": [
{
"diffString": " verbose=verbose,\r\n )\r\n+ log(\r\n+ \"scan\",\r\n+ f\"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, \"\r\n+ f\"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}\",\r\n+ )\r\n \r\n removed = cleanup_orphan_archives(verbose=verbose)\r",
"oldStart": 870,
"newStart": 870,
"oldLines": 4,
"newLines": 9,
"linesAdded": 5
}
],
"editor": "EDITOR_AI"
},
"shouldAutoFixLints": true,
"resultForModel": "Success. Updated the following files:\nM zip_sequences.py",
"contentsAfterEdit": "#!/usr/bin/env python3\r\n\"\"\"Maintain zipped render sequences for Git hooks.\r\n\r\nDefault mode scans `Renders/`, produces ZIP archives under `Renders/_zipped/`,\r\nand stages any updated archives so commits only track compact files. Switch to\r\n`--mode expand` to inflate the tracked archives back into the ignored working\r\ndirectories after checkouts or pulls.\r\n\"\"\"\r\n\r\nfrom __future__ import annotations\r\n\r\nimport argparse\r\nimport json\r\nimport os\r\nimport platform\r\nimport shutil\r\nimport subprocess\r\nimport sys\r\nimport tempfile\r\nimport time\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\nfrom pathlib import Path\r\nfrom typing import Iterator, Sequence\r\n\r\n# Try to import psutil for cross-platform RAM detection\r\ntry:\r\n import psutil\r\n HAS_PSUTIL = True\r\nexcept ImportError:\r\n HAS_PSUTIL = False\r\n # For Windows fallback\r\n if platform.system() == \"Windows\":\r\n try:\r\n import ctypes\r\n HAS_CTYPES = True\r\n except ImportError:\r\n HAS_CTYPES = False\r\n else:\r\n HAS_CTYPES = False\r\n\r\n\r\nRENDER_ROOT = Path(\"Renders\")\r\nARCHIVE_ROOT = RENDER_ROOT / \"_zipped\"\r\nSEQUENCE_EXTENSIONS = {\r\n \".png\",\r\n \".jpg\",\r\n \".jpeg\",\r\n \".tif\",\r\n \".tiff\",\r\n \".exr\",\r\n}\r\nSTATE_SUFFIX = \".meta.json\"\r\nDEFAULT_CONFIG = {\r\n \"zipper\": \"7z\",\r\n \"compression\": 9,\r\n \"dailyFormat\": \"daily_YYMMDD\",\r\n \"Max7zInst\": 0, # Maximum concurrent 7z instances (0 = auto-calculate)\r\n}\r\n\r\n\r\ndef log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:\r\n if verbose_only and not verbose:\r\n return\r\n print(f\"[{mode}] {message}\", flush=True)\r\n\r\n\r\ndef load_config() -> dict:\r\n # First try to load from project's .config folder (current working directory)\r\n # Then fall back to ProjectStructure repo config (next to zip_sequences.py)\r\n cwd = Path.cwd()\r\n project_config = cwd / \".config\" / \"config.json\"\r\n repo_config = Path(__file__).resolve().with_name(\"config.json\")\r\n \r\n config_paths = [\r\n (\"project\", project_config),\r\n (\"repo\", repo_config),\r\n ]\r\n \r\n log(\"init\", \"Loading configuration sources...\")\r\n for source, config_path in config_paths:\r\n try:\r\n if config_path.exists():\r\n log(\"init\", f\"Reading {source} config at {config_path}\")\r\n text = config_path.read_text(encoding=\"utf-8\")\r\n try:\r\n data = json.loads(text)\r\n if isinstance(data, dict):\r\n merged = DEFAULT_CONFIG.copy()\r\n merged.update(data)\r\n log(\"init\", f\"Configuration loaded from {source}\")\r\n return merged\r\n except json.JSONDecodeError:\r\n log(\"init\", f\"Config file at {config_path} is invalid JSON; skipping\")\r\n continue\r\n except OSError:\r\n log(\"init\", f\"Unable to read config at {config_path}; skipping\")\r\n continue\r\n \r\n # If no config found, return defaults\r\n log(\"init\", \"No config files found; using default settings\")\r\n return DEFAULT_CONFIG.copy()\r\n\r\n\r\nCONFIG = load_config()\r\nzipper_val = CONFIG.get(\"zipper\", \"7z\")\r\n# Handle both old boolean format and new string format\r\nif isinstance(zipper_val, bool):\r\n ZIPPER_TYPE = \"7z\" if zipper_val else \"zip\"\r\nelse:\r\n ZIPPER_TYPE = str(zipper_val).lower()\r\n\r\nCOMPRESSION_LEVEL = CONFIG.get(\"compression\", 9)\r\nif isinstance(COMPRESSION_LEVEL, str):\r\n try:\r\n COMPRESSION_LEVEL = int(COMPRESSION_LEVEL)\r\n except ValueError:\r\n COMPRESSION_LEVEL = 9\r\nif not isinstance(COMPRESSION_LEVEL, int):\r\n COMPRESSION_LEVEL = 9\r\nCOMPRESSION_LEVEL = max(0, min(9, COMPRESSION_LEVEL))\r\n\r\nMAX_7Z_INSTANCES = CONFIG.get(\"Max7zInst\", 0)\r\nif MAX_7Z_INSTANCES is not None:\r\n if isinstance(MAX_7Z_INSTANCES, str):\r\n try:\r\n MAX_7Z_INSTANCES = int(MAX_7Z_INSTANCES)\r\n except ValueError:\r\n MAX_7Z_INSTANCES = 0\r\n if not isinstance(MAX_7Z_INSTANCES, int) or MAX_7Z_INSTANCES < 1:\r\n MAX_7Z_INSTANCES = 0\r\n# Treat 0 as None (auto-calculate)\r\nif MAX_7Z_INSTANCES == 0:\r\n MAX_7Z_INSTANCES = None\r\n\r\nSEVEN_Z_EXE: str | None = None\r\nif ZIPPER_TYPE == \"7z\":\r\n SEVEN_Z_EXE = shutil.which(\"7z\") or shutil.which(\"7za\")\r\n\r\n\r\ndef parse_args() -> argparse.Namespace:\r\n parser = argparse.ArgumentParser(description=\"Sync render sequences with zipped archives.\")\r\n parser.add_argument(\r\n \"--mode\",\r\n choices=(\"zip\", \"expand\"),\r\n default=\"zip\",\r\n help=\"zip sequences for commit (default) or expand tracked archives\",\r\n )\r\n parser.add_argument(\"--jobs\", type=int, help=\"max parallel workers\")\r\n parser.add_argument(\"--verbose\", action=\"store_true\", help=\"print extra progress details\")\r\n return parser.parse_args()\r\n\r\n\r\ndef get_available_ram() -> int | None:\r\n \"\"\"Get available RAM in bytes, reserving 20% for system.\r\n \r\n Returns:\r\n Available RAM in bytes, or None if detection fails.\r\n \"\"\"\r\n try:\r\n if HAS_PSUTIL:\r\n # Use psutil for cross-platform RAM detection\r\n mem = psutil.virtual_memory()\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem.total * 0.8)\r\n return available\r\n elif HAS_CTYPES and platform.system() == \"Windows\":\r\n # Windows fallback using ctypes\r\n class MEMORYSTATUSEX(ctypes.Structure):\r\n _fields_ = [\r\n (\"dwLength\", ctypes.c_ulong),\r\n (\"dwMemoryLoad\", ctypes.c_ulong),\r\n (\"ullTotalPhys\", ctypes.c_ulonglong),\r\n (\"ullAvailPhys\", ctypes.c_ulonglong),\r\n (\"ullTotalPageFile\", ctypes.c_ulonglong),\r\n (\"ullAvailPageFile\", ctypes.c_ulonglong),\r\n (\"ullTotalVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailVirtual\", ctypes.c_ulonglong),\r\n (\"ullAvailExtendedVirtual\", ctypes.c_ulonglong),\r\n ]\r\n \r\n kernel32 = ctypes.windll.kernel32\r\n kernel32.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(MEMORYSTATUSEX)]\r\n kernel32.GlobalMemoryStatusEx.restype = ctypes.c_bool\r\n \r\n mem_status = MEMORYSTATUSEX()\r\n mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)\r\n \r\n if kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status)):\r\n # Reserve 20% for system, use 80% for compression jobs\r\n available = int(mem_status.ullTotalPhys * 0.8)\r\n return available\r\n except Exception:\r\n pass\r\n \r\n return None\r\n\r\n\r\ndef estimate_ram_per_job(seq_dir: Path, seq_state: dict) -> int:\r\n \"\"\"Estimate RAM usage per compression job based on folder size.\r\n \r\n Args:\r\n seq_dir: Path to the sequence directory\r\n seq_state: State dictionary containing file information\r\n \r\n Returns:\r\n Estimated RAM usage in bytes\r\n \"\"\"\r\n # Calculate total folder size from seq_state\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n \r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n FIXED_DICT_SIZE_BYTES = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Dictionary RAM: ~11x dictionary size (1GB dict = 11GB RAM for dictionary operations)\r\n # This is the main memory consumer for 7z LZMA compression\r\n dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary\r\n \r\n # Input buffer: 7z processes in chunks, doesn't need full file in RAM\r\n # Use 15% of sequence size, capped at 2GB\r\n input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)\r\n \r\n # Compression overhead: additional memory for compression process\r\n overhead = 1 * 1024 * 1024 * 1024 # 1GB\r\n \r\n # Total RAM estimate\r\n estimated_ram = dict_ram + input_buffer + overhead\r\n \r\n return estimated_ram\r\n else:\r\n # zip compression is more memory-efficient\r\n # Conservative estimate: 1GB per job\r\n return 1024 * 1024 * 1024 # 1 GB\r\n\r\n\r\ndef max_workers(\r\n requested: int | None,\r\n work_items: list[tuple[Path, Path, Path, dict]] | None = None,\r\n *,\r\n verbose: bool = False\r\n) -> tuple[int, int | None]:\r\n \"\"\"Calculate maximum worker count based on CPU and RAM constraints.\r\n \r\n Args:\r\n requested: User-requested worker count (from --jobs)\r\n work_items: List of work items (seq_dir, zip_path, state_path, seq_state)\r\n verbose: Whether to log RAM-based calculations\r\n \r\n Returns:\r\n Tuple of (worker_count, per_job_memory_limit_bytes)\r\n per_job_memory_limit_bytes is None if not using 7z or RAM detection failed\r\n \"\"\"\r\n cpu = os.cpu_count() or 1\r\n cpu_limit = max(1, min(8, cpu))\r\n if requested and requested > 0:\r\n cpu_limit = min(requested, max(1, cpu))\r\n \r\n # If no work items provided, return CPU-based limit\r\n if work_items is None or len(work_items) == 0:\r\n return (cpu_limit, {})\r\n \r\n # Try to calculate RAM-based limit\r\n available_ram = get_available_ram()\r\n \r\n if available_ram is None:\r\n # RAM detection failed, fall back to CPU limit\r\n if verbose:\r\n log(\"zip\", \"RAM detection failed, using CPU-based worker limit\", verbose_only=True, verbose=verbose)\r\n return (cpu_limit, {})\r\n \r\n # For 7z: use fixed dictionary size and calculate workers\r\n if ZIPPER_TYPE == \"7z\":\r\n # Fixed dictionary size: 1GB (1024MB)\r\n FIXED_DICT_SIZE_MB = 1024\r\n fixed_dict_size_bytes = FIXED_DICT_SIZE_MB * 1024 * 1024\r\n \r\n # Check if Max7zInst is configured\r\n if MAX_7Z_INSTANCES is not None:\r\n # Use configured maximum instances, but still respect user's --jobs and work items\r\n final_limit = MAX_7Z_INSTANCES\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n final_limit = min(final_limit, num_work_items)\r\n if requested and requested > 0:\r\n final_limit = min(final_limit, requested)\r\n \r\n # Create RAM limits dict (all use fixed dict size, but return as dict for consistency)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = fixed_dict_size_bytes\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"Using Max7zInst={MAX_7Z_INSTANCES} from config → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n \r\n # Auto-calculate based on RAM if Max7zInst not configured\r\n # Use bin-packing algorithm with size-aware RAM estimation\r\n if available_ram is not None:\r\n # available_ram is already 80% of total (20% reserved for system)\r\n # Use 95% of available RAM for compression jobs\r\n compression_ram = int(available_ram * 0.95)\r\n \r\n # Estimate RAM for each work item and create list with (seq_dir, estimated_ram, work_item)\r\n work_items_with_ram: list[tuple[Path, int, tuple[Path, Path, Path, dict]]] = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n \r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n work_items_with_ram.append((seq_dir, estimated_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use a safe default (12GB minimum for 1GB dict)\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB\r\n work_items_with_ram.append((seq_dir, default_ram, (seq_dir, zip_path, state_path, seq_state)))\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n # Sort by estimated RAM (largest first) for bin-packing\r\n work_items_with_ram.sort(key=lambda x: x[1], reverse=True)\r\n \r\n # Bin-packing algorithm: pack largest items first\r\n bins: list[list[tuple[Path, int, tuple[Path, Path, Path, dict]]]] = []\r\n bin_remaining: list[int] = []\r\n \r\n for seq_dir, estimated_ram, work_item in work_items_with_ram:\r\n # Try to fit in existing bin\r\n placed = False\r\n for i, remaining in enumerate(bin_remaining):\r\n if remaining >= estimated_ram:\r\n bins[i].append((seq_dir, estimated_ram, work_item))\r\n bin_remaining[i] -= estimated_ram\r\n placed = True\r\n break\r\n \r\n # If doesn't fit, create new bin\r\n if not placed:\r\n bins.append([(seq_dir, estimated_ram, work_item)])\r\n bin_remaining.append(compression_ram - estimated_ram)\r\n \r\n # Worker count is number of bins\r\n worker_count = len(bins)\r\n \r\n # Cap at number of actual work items (can't have more workers than jobs)\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n worker_count = min(worker_count, num_work_items)\r\n \r\n # Respect user's --jobs if provided\r\n if requested and requested > 0:\r\n worker_count = min(worker_count, requested)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n compression_ram_gb = compression_ram / (1024 ** 3)\r\n total_estimated_gb = sum(ram for _, ram, _ in work_items_with_ram) / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available (80% of total), {compression_ram_gb:.1f}GB for compression (95%)\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Estimated RAM per sequence: {total_estimated_gb:.1f}GB total across {num_work_items} sequences\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n if len(bins) > 0:\r\n bin_sizes = [sum(ram for _, ram, _ in bin_items) / (1024 ** 3) for bin_items in bins]\r\n log(\r\n \"zip\",\r\n f\"Bin-packing: {worker_count} workers, bin sizes: {[f'{s:.1f}GB' for s in bin_sizes[:5]]}{'...' if len(bin_sizes) > 5 else ''}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n log(\r\n \"zip\",\r\n f\"Final worker count: {worker_count} (requested: {requested})\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n # Return worker count and RAM limits dict\r\n return (worker_count, ram_limits_dict)\r\n \r\n # RAM detection failed, use a safe default (no CPU limit)\r\n default_limit = 4\r\n num_work_items = len(work_items) if work_items else 0\r\n if num_work_items > 0:\r\n default_limit = min(default_limit, num_work_items)\r\n if requested and requested > 0:\r\n default_limit = min(default_limit, requested)\r\n \r\n # Create RAM limits dict with safe defaults (12GB per job for 1GB dict)\r\n ram_limits_dict: dict[Path, int] = {}\r\n if work_items:\r\n default_ram = 12 * 1024 * 1024 * 1024 # 12GB default\r\n for seq_dir, _, _, _ in work_items:\r\n ram_limits_dict[seq_dir] = default_ram\r\n \r\n if verbose:\r\n log(\r\n \"zip\",\r\n f\"RAM detection failed and Max7zInst not set, using default worker limit → \"\r\n f\"work items: {num_work_items}, requested: {requested}, final: {default_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n return (default_limit, ram_limits_dict)\r\n \r\n # For zip compression, use existing estimation-based approach\r\n # Estimate RAM per job for each work item\r\n ram_estimates = []\r\n ram_limits_dict: dict[Path, int] = {}\r\n for seq_dir, zip_path, state_path, seq_state in work_items:\r\n try:\r\n estimated_ram = estimate_ram_per_job(seq_dir, seq_state)\r\n ram_estimates.append(estimated_ram)\r\n ram_limits_dict[seq_dir] = estimated_ram\r\n except Exception:\r\n # If estimation fails, use fallback estimate\r\n fallback_ram = 1024 * 1024 * 1024 # 1GB fallback for zip\r\n ram_estimates.append(fallback_ram)\r\n ram_limits_dict[seq_dir] = fallback_ram\r\n \r\n if not ram_estimates:\r\n return (cpu_limit, {})\r\n \r\n max_ram_per_job = max(ram_estimates)\r\n ram_limit = max(1, available_ram // max_ram_per_job)\r\n ram_limit = min(ram_limit, 6) # Conservative limit for zip\r\n final_limit = min(cpu_limit, ram_limit)\r\n \r\n if verbose:\r\n ram_gb = available_ram / (1024 ** 3)\r\n max_ram_gb = max_ram_per_job / (1024 ** 3)\r\n log(\r\n \"zip\",\r\n f\"RAM: {ram_gb:.1f}GB available, ~{max_ram_gb:.1f}GB per job → \"\r\n f\"RAM limit: {ram_limit}, CPU limit: {cpu_limit}, final: {final_limit}\",\r\n verbose_only=True,\r\n verbose=verbose\r\n )\r\n \r\n return (final_limit, ram_limits_dict)\r\n\r\n\r\ndef is_archive_path(path: Path) -> bool:\r\n return any(part in (\"_archive\", \"_CURRENT\") for part in path.parts)\r\n\r\n\r\ndef find_sequence_dirs(root: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(root):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)\r\n if has_frames:\r\n yield path\r\n\r\n\r\ndef iter_sequence_files(seq_dir: Path) -> Iterator[Path]:\r\n for dirpath, dirnames, filenames in os.walk(seq_dir):\r\n path = Path(dirpath)\r\n dirnames[:] = [d for d in dirnames if d not in (\"_archive\", \"_CURRENT\")]\r\n if is_archive_path(path):\r\n continue\r\n for filename in filenames:\r\n yield path / filename\r\n\r\n\r\ndef compute_state(seq_dir: Path) -> dict:\r\n entries = []\r\n files = sorted(\r\n iter_sequence_files(seq_dir),\r\n key=lambda p: p.relative_to(seq_dir).as_posix(),\r\n )\r\n for file_path in files:\r\n stat = file_path.stat()\r\n entries.append(\r\n {\r\n \"path\": file_path.relative_to(seq_dir).as_posix(),\r\n \"size\": stat.st_size,\r\n \"mtime_ns\": stat.st_mtime_ns,\r\n }\r\n )\r\n return {\"files\": entries}\r\n\r\n\r\ndef current_state(seq_dir: Path) -> dict:\r\n if not seq_dir.exists() or not seq_dir.is_dir():\r\n return {\"files\": []}\r\n return compute_state(seq_dir)\r\n\r\n\r\ndef load_state(state_path: Path) -> dict | None:\r\n if not state_path.exists():\r\n return None\r\n try:\r\n return json.loads(state_path.read_text())\r\n except json.JSONDecodeError:\r\n return None\r\n\r\n\r\ndef state_changed(seq_state: dict, stored_state: dict | None) -> bool:\r\n if stored_state is None:\r\n return True\r\n return seq_state != stored_state\r\n\r\n\r\ndef archive_path_for(seq_dir: Path) -> Path:\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n suffix = \".7z\" if ZIPPER_TYPE == \"7z\" else \".zip\"\r\n return (ARCHIVE_ROOT / rel).with_suffix(suffix)\r\n\r\n\r\ndef sequence_dir_for(zip_path: Path) -> Path:\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n return (RENDER_ROOT / rel).with_suffix(\"\")\r\n\r\n\r\ndef state_path_for(zip_path: Path) -> Path:\r\n return zip_path.with_suffix(zip_path.suffix + STATE_SUFFIX)\r\n\r\n\r\ndef zip_sequence(seq_dir: Path, zip_path: Path, per_job_memory_limit: int | None = None) -> None:\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z compression requested but 7z executable not found in PATH. \"\r\n \"Please install 7z (e.g., via Chocolatey: choco install 7zip) \"\r\n \"or set zipper to 'zip' in config.json\"\r\n )\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n \r\n # If creating a .7z file, remove any existing .zip file for the same sequence\r\n if zip_path.suffix == \".7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n \r\n # Build list of files to archive with relative paths\r\n file_list = []\r\n for file_path in iter_sequence_files(seq_dir):\r\n rel_path = file_path.relative_to(seq_dir).as_posix()\r\n file_list.append(rel_path)\r\n \r\n if not file_list:\r\n raise RuntimeError(f\"No files found to archive in {seq_dir}\")\r\n \r\n # Create zip in temporary location first to avoid issues with corrupted existing files\r\n temp_zip = None\r\n list_file_path = None\r\n try:\r\n # Create temporary archive file path (but don't create the file - let 7z create it)\r\n temp_zip_path = tempfile.mktemp(suffix=\".7z\", dir=zip_path.parent)\r\n temp_zip = Path(temp_zip_path)\r\n \r\n # Create list file with absolute path\r\n fd, temp_path = tempfile.mkstemp(suffix=\".lst\", text=True)\r\n list_file_path = Path(temp_path)\r\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as list_file:\r\n for rel_path in file_list:\r\n list_file.write(rel_path + \"\\n\")\r\n list_file.flush()\r\n os.fsync(list_file.fileno()) # Ensure data is written to disk\r\n # File is closed here by context manager, small delay to ensure OS releases handle\r\n time.sleep(0.1)\r\n \r\n # Use absolute paths for both list file and temp zip\r\n list_file_abs = list_file_path.resolve()\r\n temp_zip_abs = temp_zip.resolve()\r\n # Create archive in temp location first (7z will create it fresh)\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"a\",\r\n \"-y\",\r\n \"-bb0\", # Suppress progress output\r\n f\"-mx={COMPRESSION_LEVEL}\",\r\n \"-t7z\", # Use 7z format, not zip\r\n ]\r\n \r\n # Always use fixed dictionary size: 1GB (1024MB)\r\n # The per_job_memory_limit parameter is the estimated RAM usage (for logging/info only)\r\n # We keep dictionary at 1GB for best compression regardless of RAM estimate\r\n FIXED_DICT_SIZE_MB = 1024\r\n cmd.append(f\"-md={FIXED_DICT_SIZE_MB}m\")\r\n \r\n cmd.extend([\r\n str(temp_zip_abs),\r\n f\"@{list_file_abs}\",\r\n ])\r\n result = subprocess.run(\r\n cmd,\r\n cwd=seq_dir,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z compression failed: {error_msg}\")\r\n \r\n # Move temp zip to final location, replacing any existing file\r\n if zip_path.exists():\r\n zip_path.unlink()\r\n temp_zip.replace(zip_path)\r\n temp_zip = None # Mark as moved so we don't delete it\r\n finally:\r\n # Clean up temp zip if it wasn't moved\r\n if temp_zip and temp_zip.exists():\r\n try:\r\n temp_zip.unlink(missing_ok=True)\r\n except OSError:\r\n pass\r\n # Clean up list file, with retry in case 7z still has it open\r\n if list_file_path and list_file_path.exists():\r\n for attempt in range(3):\r\n try:\r\n list_file_path.unlink(missing_ok=True)\r\n break\r\n except PermissionError:\r\n if attempt < 2:\r\n time.sleep(0.1) # Wait 100ms before retry\r\n else:\r\n # Last attempt failed, just log and continue\r\n # The temp file will be cleaned up by the OS eventually\r\n pass\r\n return\r\n\r\n # Use zipfile (only if ZIPPER_TYPE == \"zip\")\r\n if ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile\r\n\r\n zip_path.parent.mkdir(parents=True, exist_ok=True)\r\n if COMPRESSION_LEVEL <= 0:\r\n compression = ZIP_STORED\r\n zip_kwargs = {}\r\n else:\r\n compression = ZIP_DEFLATED\r\n zip_kwargs = {\"compresslevel\": COMPRESSION_LEVEL}\r\n\r\n with ZipFile(zip_path, \"w\", compression=compression, **zip_kwargs) as archive:\r\n for file_path in iter_sequence_files(seq_dir):\r\n archive.write(file_path, arcname=file_path.relative_to(seq_dir).as_posix())\r\n return\r\n\r\n # Unknown ZIPPER_TYPE - fail with clear error\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n\r\ndef expand_sequence(zip_path: Path, seq_state: dict) -> None:\r\n target_dir = sequence_dir_for(zip_path)\r\n if target_dir.exists():\r\n shutil.rmtree(target_dir)\r\n target_dir.mkdir(parents=True, exist_ok=True)\r\n\r\n if ZIPPER_TYPE == \"7z\":\r\n if SEVEN_Z_EXE is None:\r\n raise RuntimeError(\r\n \"7z extraction requested but 7z executable not found in PATH. \"\r\n \"Please install 7z or set zipper to 'zip' in config.json\"\r\n )\r\n cmd = [\r\n SEVEN_Z_EXE,\r\n \"x\",\r\n \"-y\",\r\n str(zip_path),\r\n f\"-o{target_dir}\",\r\n ]\r\n result = subprocess.run(\r\n cmd,\r\n check=False,\r\n stdout=subprocess.PIPE,\r\n stderr=subprocess.PIPE,\r\n text=True,\r\n )\r\n if result.returncode != 0:\r\n error_msg = result.stderr.strip() if result.stderr else \"Unknown error\"\r\n if result.stdout:\r\n error_msg += f\"\\nstdout: {result.stdout.strip()}\"\r\n raise RuntimeError(f\"7z extraction failed: {error_msg}\")\r\n elif ZIPPER_TYPE == \"zip\":\r\n from zipfile import ZipFile\r\n\r\n with ZipFile(zip_path, \"r\") as archive:\r\n archive.extractall(target_dir)\r\n else:\r\n raise RuntimeError(\r\n f\"Unsupported ZIPPER_TYPE: {ZIPPER_TYPE!r}. \"\r\n f\"Expected '7z' or 'zip'. \"\r\n f\"Config zipper value: {CONFIG.get('zipper', 'not set')!r}\"\r\n )\r\n\r\n for entry in seq_state.get(\"files\", []):\r\n file_path = target_dir / entry[\"path\"]\r\n if file_path.exists():\r\n os.utime(file_path, ns=(entry[\"mtime_ns\"], entry[\"mtime_ns\"]))\r\n\r\n\r\ndef process_zip(seq_dir: Path, zip_path: Path, state_path: Path, seq_state: dict, per_job_memory_limit: int | None, *, verbose: bool) -> Sequence[Path]:\r\n log(\"zip\", f\"{seq_dir} -> {zip_path}\", verbose_only=True, verbose=verbose)\r\n zip_sequence(seq_dir, zip_path, per_job_memory_limit)\r\n state_path.write_text(json.dumps(seq_state, indent=2))\r\n return (zip_path, state_path)\r\n\r\n\r\ndef process_expand(zip_path: Path, state: dict, *, verbose: bool) -> None:\r\n log(\"expand\", f\"{zip_path} -> {sequence_dir_for(zip_path)}\", verbose_only=True, verbose=verbose)\r\n expand_sequence(zip_path, state)\r\n\r\n\r\ndef run_zip(requested_workers: int | None, *, verbose: bool) -> int:\r\n work_items: list[tuple[Path, Path, Path, dict]] = []\r\n log(\"init\", f\"Scanning sequences under {RENDER_ROOT.resolve()}\")\r\n total_scanned = 0\r\n quick_skipped = 0\r\n state_skipped = 0\r\n empty_dirs = 0\r\n queued = 0\r\n\r\n if RENDER_ROOT.exists():\r\n for seq_dir in find_sequence_dirs(RENDER_ROOT):\r\n total_scanned += 1\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n if total_scanned <= 5 or total_scanned % 10 == 0:\r\n log(\"scan\", f\"[{total_scanned}] Inspecting {rel}\")\r\n\r\n # Get the target archive path (will be .7z if ZIPPER_TYPE is \"7z\")\r\n zip_path = archive_path_for(seq_dir)\r\n state_path = state_path_for(zip_path)\r\n \r\n # Quick check: if archive exists, load stored state first (fast)\r\n stored_state = load_state(state_path)\r\n \r\n # Check if we need to upgrade from .zip to .7z\r\n old_zip_path = None\r\n old_stored_state = None\r\n if ZIPPER_TYPE == \"7z\":\r\n old_zip_path = zip_path.with_suffix(\".zip\")\r\n if old_zip_path.exists():\r\n old_state_path = state_path_for(old_zip_path)\r\n old_stored_state = load_state(old_state_path)\r\n # If old .zip exists and .7z doesn't, use old .zip's state for comparison\r\n if not zip_path.exists() and old_stored_state is not None:\r\n stored_state = old_stored_state\r\n \r\n # If .7z archive exists and we have stored state, do quick check before computing full state\r\n if zip_path.exists() and stored_state is not None:\r\n # Quick check: if directory mtime is older than archive, likely unchanged\r\n try:\r\n dir_mtime = seq_dir.stat().st_mtime_ns\r\n archive_mtime = zip_path.stat().st_mtime_ns\r\n # If directory wasn't modified since archive was created, skip state computation\r\n if dir_mtime <= archive_mtime:\r\n quick_skipped += 1\r\n if quick_skipped <= 5:\r\n log(\"scan\", f\"Skipping {rel} (unchanged since archive)\")\r\n # Still need to check for old .zip cleanup (we have .7z, so .zip is obsolete)\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n continue\r\n except OSError:\r\n # If stat fails, fall through to full state computation\r\n pass\r\n \r\n # Compute current state only if we need to\r\n seq_state = compute_state(seq_dir)\r\n if not seq_state[\"files\"]:\r\n empty_dirs += 1\r\n if empty_dirs <= 5:\r\n log(\"scan\", f\"{rel} has no files; skipping\")\r\n continue\r\n \r\n # Check if state changed\r\n if stored_state is not None and not state_changed(seq_state, stored_state):\r\n # Metadata matches stored state\r\n state_skipped += 1\r\n if state_skipped <= 5:\r\n log(\"scan\", f\"{rel} metadata unchanged; archive up to date\")\r\n if zip_path.exists():\r\n # .7z exists and is up to date, clean up old .zip if it exists\r\n if old_zip_path and old_zip_path.exists():\r\n old_zip_path.unlink(missing_ok=True)\r\n old_state_path = state_path_for(old_zip_path)\r\n if old_state_path.exists():\r\n old_state_path.unlink(missing_ok=True)\r\n elif old_zip_path and old_zip_path.exists() and old_stored_state is not None:\r\n # .7z doesn't exist, but .zip exists and metadata matches\r\n # Keep the .zip file, don't create .7z\r\n continue\r\n else:\r\n # No archive exists, but state matches (shouldn't happen, but be safe)\r\n continue\r\n\r\n work_items.append((seq_dir, zip_path, state_path, seq_state))\r\n queued += 1\r\n if queued <= 5 or queued % 5 == 0:\r\n total_bytes = sum(entry.get(\"size\", 0) for entry in seq_state.get(\"files\", []))\r\n size_gb = total_bytes / (1024 ** 3)\r\n log(\"scan\", f\"Queued {rel} for compression (~{size_gb:.2f}GB) [{queued} total]\")\r\n\r\n if not work_items:\r\n if not RENDER_ROOT.exists():\r\n log(\"zip\", \"Render root 'Renders' not found; nothing to zip.\")\r\n else:\r\n log(\"zip\", \"Archives already up to date; no sequences needed zipping.\")\r\n log(\r\n \"scan\",\r\n f\"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, \"\r\n f\"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}\",\r\n )\r\n return 0\r\n\r\n # Calculate RAM-aware worker count based on work items\r\n worker_count, ram_limits_dict = max_workers(requested_workers, work_items, verbose=verbose)\r\n log(\r\n \"init\",\r\n f\"Preparing to compress {len(work_items)} sequence(s) with {worker_count} worker(s)\",\r\n )\r\n\r\n updated_paths: list[Path] = []\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_zip, seq_dir, zip_path, state_path, seq_state, ram_limits_dict.get(seq_dir), verbose=verbose): seq_dir\r\n for seq_dir, zip_path, state_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n updated_paths.extend(future.result())\r\n completed += 1\r\n seq_dir = future_map[future]\r\n rel = seq_dir.relative_to(RENDER_ROOT)\r\n log(\"zip\", f\"{completed}/{total} {rel}\")\r\n\r\n updated_count = len(updated_paths) // 2\r\n log(\"zip\", f\"Updated {updated_count} sequence archive(s).\", verbose=verbose)\r\n if updated_paths:\r\n log(\r\n \"zip\",\r\n \"Archives updated. Stage manually with `git add Renders/_zipped`, if desired.\",\r\n verbose_only=True,\r\n verbose=verbose,\r\n )\r\n log(\r\n \"scan\",\r\n f\"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, \"\r\n f\"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}\",\r\n )\r\n\r\n removed = cleanup_orphan_archives(verbose=verbose)\r\n if removed:\r\n log(\"zip\", f\"Removed {removed} orphan archive(s).\", verbose=verbose)\r\n\r\n return updated_count\r\n\r\n\r\ndef run_expand(worker_count: int, *, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n log(\"expand\", \"No archives to expand (missing 'Renders/_zipped').\")\r\n return 0\r\n\r\n work_items: list[tuple[Path, dict]] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n state_path = state_path_for(zip_path)\r\n seq_state = load_state(state_path)\r\n if seq_state is None:\r\n log(\"expand\", f\"Skipping {zip_path} (missing metadata)\")\r\n continue\r\n\r\n target_dir = sequence_dir_for(zip_path)\r\n if current_state(target_dir) == seq_state:\r\n continue\r\n\r\n work_items.append((zip_path, seq_state))\r\n\r\n if not work_items:\r\n log(\"expand\", \"Working folders already match archives; nothing to expand.\")\r\n return 0\r\n\r\n total = len(work_items)\r\n completed = 0\r\n\r\n with ThreadPoolExecutor(max_workers=worker_count) as executor:\r\n future_map = {\r\n executor.submit(process_expand, zip_path, seq_state, verbose=verbose): zip_path\r\n for zip_path, seq_state in work_items\r\n }\r\n\r\n for future in as_completed(future_map):\r\n future.result()\r\n completed += 1\r\n zip_path = future_map[future]\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"expand\", f\"{completed}/{total} {rel}\")\r\n\r\n log(\"expand\", f\"Refreshed {len(work_items)} sequence folder(s).\", verbose=verbose)\r\n return len(work_items)\r\n\r\n\r\ndef cleanup_orphan_archives(*, verbose: bool) -> int:\r\n if not ARCHIVE_ROOT.exists():\r\n return 0\r\n\r\n removed: list[Path] = []\r\n\r\n # Look for both .zip and .7z archives\r\n archive_patterns = [\"*.zip\", \"*.7z\"]\r\n for pattern in archive_patterns:\r\n for zip_path in ARCHIVE_ROOT.rglob(pattern):\r\n seq_dir = sequence_dir_for(zip_path)\r\n if seq_dir.exists():\r\n continue\r\n\r\n rel = zip_path.relative_to(ARCHIVE_ROOT)\r\n log(\"zip\", f\"Removing orphan archive {rel}\", verbose_only=True, verbose=verbose)\r\n\r\n zip_path.unlink(missing_ok=True)\r\n state_path = state_path_for(zip_path)\r\n if state_path.exists():\r\n state_path.unlink()\r\n removed.append(zip_path)\r\n\r\n if not removed:\r\n return 0\r\n\r\n for parent in sorted({p.parent for p in removed}, key=lambda p: len(p.parts), reverse=True):\r\n if not parent.exists():\r\n continue\r\n while parent != ARCHIVE_ROOT and not any(parent.iterdir()):\r\n parent.rmdir()\r\n parent = parent.parent\r\n\r\n return len(removed)\r\n\r\n\r\ndef main() -> int:\r\n args = parse_args()\r\n log(\"init\", \"zip_sequences starting up...\")\r\n log(\"init\", f\"Working directory: {Path.cwd()}\")\r\n log(\"init\", f\"Mode: {args.mode}, zipper: {ZIPPER_TYPE}, jobs arg: {args.jobs or 'auto'}\")\r\n if ZIPPER_TYPE == \"7z\":\r\n exe = SEVEN_Z_EXE or \"not found\"\r\n max_inst = MAX_7Z_INSTANCES if MAX_7Z_INSTANCES is not None else \"auto\"\r\n log(\"init\", f\"7z executable: {exe}, Max7zInst: {max_inst}\")\r\n\r\n if args.mode == \"expand\":\r\n # For expand mode, use simple CPU-based worker calculation\r\n workers, _ = max_workers(args.jobs, work_items=None, verbose=args.verbose)\r\n run_expand(workers, verbose=args.verbose)\r\n return 0\r\n\r\n # For zip mode, work items will be calculated in run_zip\r\n updated = run_zip(args.jobs, verbose=args.verbose)\r\n return 0 if updated >= 0 else 1\r\n\r\n\r\nif __name__ == \"__main__\":\r\n try:\r\n raise SystemExit(main())\r\n except Exception as exc: # broad to surface unexpected errors cleanly\r\n print(f\"Sequence sync failed: {exc}\", file=sys.stderr)\r\n raise\r\n\r\n"
}
User decision: accepted
Status: completed
Thought Process
Checking imports and running lintsTool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Thought Process
Summarizing final response detailsI'll wrap up by highlighting that the logs' counters trigger even when RENDER_ROOT is missing but total_scanned remains zero, and the summary still outputs. I'll include code snippets referencing the new log function near lines 52-69, run_zip changes around 720-870, and main logging near 920-930, plus mention config logging, as a helpful overview for the user.
- Added immediate-flush logging at startup so the console shows configuration discovery, working directory, mode, and 7z details right away, plus graceful fallback notes when configs are missing.
DEFAULT_CONFIG = {
"zipper": "7z",
"compression": 9,
"dailyFormat": "daily_YYMMDD",
"Max7zInst": 0, # Maximum concurrent 7z instances (0 = auto-calculate)
}
def log(mode: str, message: str, *, verbose_only: bool = False, verbose: bool = False) -> None:
if verbose_only and not verbose:
return
print(f"[{mode}] {message}", flush=True)
def load_config() -> dict:
...
log("init", "Loading configuration sources...")
...
log("init", f"Reading {source} config at {config_path}")
- Instrumented the sequence scan so you see periodic updates while it walks directories: per-directory progress (with throttling), reasons for skips, newly queued jobs with rough sizes, and an overall summary even when nothing needs zipping.
def run_zip(requested_workers: int | None, *, verbose: bool) -> int:
...
log("init", f"Scanning sequences under {RENDER_ROOT.resolve()}")
...
if total_scanned <= 5 or total_scanned % 10 == 0:
log("scan", f"[{total_scanned}] Inspecting {rel}")
...
quick_skipped += 1
if quick_skipped <= 5:
log("scan", f"Skipping {rel} (unchanged since archive)")
...
if queued <= 5 or queued % 5 == 0:
...
log("scan", f"Queued {rel} for compression (~{size_gb:.2f}GB) [{queued} total]")
...
log(
"scan",
f"Summary: scanned {total_scanned}, quick-skipped {quick_skipped}, "
f"state-skipped {state_skipped}, empty {empty_dirs}, queued {queued}",
)
- Logged compression setup details (worker count, job totals) and ensured the main entry point always prints an initial banner so the window never sits blank before heavy work begins.
def main() -> int:
args = parse_args()
log("init", "zip_sequences starting up...")
log("init", f"Working directory: {Path.cwd()}")
log("init", f"Mode: {args.mode}, zipper: {ZIPPER_TYPE}, jobs arg: {args.jobs or 'auto'}")
if ZIPPER_TYPE == "7z":
exe = SEVEN_Z_EXE or "not found"
max_inst = MAX_7Z_INSTANCES if MAX_7Z_INSTANCES is not None else "auto"
log("init", f"7z executable: {exe}, Max7zInst: {max_inst}")
Next steps: run ZipSeqArchv.bat once to confirm the new output meets your expectations. No lints reported.
User (2025-11-12 07:20Z)
PS R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders> & "R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders\ZipSeqArchv.bat" [init] Loading configuration sources... [init] Reading project config at R:\Creative\artsy\maya\2025-02 BondingArtifact.config\config.json [init] Configuration loaded from project [init] zip_sequences starting up... [init] Working directory: R:\Creative\artsy\maya\2025-02 BondingArtifact [init] Mode: zip, zipper: 7z, jobs arg: auto [init] 7z executable: C:\ProgramData\chocolatey\bin\7z.EXE, Max7zInst: auto [init] Scanning sequences under \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders [scan] [1] Inspecting 0_GaslightRender\2024-11-23\GaslightRender_smol [scan] Skipping 0_GaslightRender\2024-11-23\GaslightRender_smol (unchanged since archive) [scan] [2] Inspecting 1_PonderingOrb\2021-01-10\rendertest [scan] Skipping 1_PonderingOrb\2021-01-10\rendertest (unchanged since archive) [scan] [3] Inspecting 1_PonderingOrb\2024-11-23\PonderingOrb [scan] Skipping 1_PonderingOrb\2024-11-23\PonderingOrb (unchanged since archive) [scan] [4] Inspecting 3_background\2025-01-13\background [scan] Skipping 3_background\2025-01-13\background (unchanged since archive) [scan] [5] Inspecting 3_background\2025-10-26\3_background_dark [scan] Queued 3_background\2025-10-26\3_background_dark for compression (~667.98GB) [1 total] [scan] Queued 3_background\2025-10-30\3_background_dark for compression (~527.10GB) [2 total] [scan] Skipping 3_background\2025-10-30\3_background_dark_4k (unchanged since archive) [scan] Queued 4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered for compression (~20.66GB) [3 total] [scan] [10] Inspecting 4_StartupScreen\2025-02-08\Shot1Redo_1024 [scan] Queued 4_StartupScreen\2025-02-08\Shot2Redo_1024 for compression (~8.83GB) [4 total] [scan] Queued 4_StartupScreen\2025-02-08\Shot3Redo_128 for compression (~8.44GB) [5 total] [scan] Queued 4_StartupScreen\2025-10-21\Shot4 for compression (~2.36GB) [10 total] [scan] [20] Inspecting 4_StartupScreen\2025-10-24\4_StartupScreen_dark [scan] Queued 4_StartupScreen\2025-11-02\Shot1_dark for compression (~4.48GB) [15 total] [scan] [30] Inspecting 4_StartupScreen\2025-11-04\Shot3_dark [scan] [40] Inspecting 5_goodbye\2025-02-23\goodbye_test [zip] RAM: 76.7GB available (80% of total), 72.9GB for compression (95%) [zip] Estimated RAM per sequence: 250.7GB total across 19 sequences [zip] Bin-packing: 4 workers, bin sizes: ['70.0GB', '68.6GB', '62.8GB', '49.3GB'] [zip] Final worker count: 4 (requested: None) [init] Preparing to compress 19 sequence(s) with 4 worker(s) [zip] Renders\3_background\2025-10-26\3_background_dark -> Renders_zipped\3_background\2025-10-26\3_background_dark.7z [zip] Renders\3_background\2025-10-30\3_background_dark -> Renders_zipped\3_background\2025-10-30\3_background_dark.7z [zip] Renders\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered -> Renders_zipped\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered.7z [zip] Renders\4_StartupScreen\2025-02-08\Shot2Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot2Redo_1024.7z [zip] Renders\4_StartupScreen\2025-02-08\Shot3Redo_128 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot3Redo_128.7z [zip] Renders\4_StartupScreen\2025-02-09\Shot1 -> Renders_zipped\4_StartupScreen\2025-02-09\Shot1.7z [zip] Renders\4_StartupScreen\2025-02-10\Shot2 -> Renders_zipped\4_StartupScreen\2025-02-10\Shot2.7z [zip] Renders\4_StartupScreen\2025-02-12\Shot3 -> Renders_zipped\4_StartupScreen\2025-02-12\Shot3.7z [zip] Renders\4_StartupScreen\2025-02-19\StartupScreen_finalfix -> Renders_zipped\4_StartupScreen\2025-02-19\StartupScreen_finalfix.7z [zip] Renders\4_StartupScreen\2025-10-21\Shot4 -> Renders_zipped\4_StartupScreen\2025-10-21\Shot4.7z [zip] Renders\4_StartupScreen\2025-10-24\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-10-24\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-10-24\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot3_dark.7z [zip] Renders\4_StartupScreen\2025-10-25\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-10-25\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot3_dark.7z [zip] Renders\4_StartupScreen\2025-11-02\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-02\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-11-04\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-04\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-11-07\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-07\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-11-07\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot3_dark.7z [zip] Renders\5_goodbye\2025-02-23\goodbye -> Renders_zipped\5_goodbye\2025-02-23\goodbye.7z Sequence sync failed: 7z compression failed: ERROR: Can't allocate required memory! stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive: 14402 files, 717236181577 bytes (668 GiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\3_background\2025-10-26\tmptg4k1cqi.7z
Add new data to archive: 14402 files, 717236181577 bytes (668 GiB) Traceback (most recent call last): File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 991, in raise SystemExit(main()) ~~~~^^ File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 985, in main updated = run_zip(args.jobs, verbose=args.verbose) File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 857, in run_zip updated_paths.extend(future.result()) ~~~~~~~~~~~~~^^ File "C:\Python313\Lib\concurrent\futures_base.py", line 449, in result return self.__get_result() ~~~~~~~~~~~~~~~~~^^ File "C:\Python313\Lib\concurrent\futures_base.py", line 401, in __get_result raise self._exception File "C:\Python313\Lib\concurrent\futures\thread.py", line 59, in run result = self.fn(*self.args, **self.kwargs) File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 723, in process_zip zip_sequence(seq_dir, zip_path, per_job_memory_limit) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 618, in zip_sequence raise RuntimeError(f"7z compression failed: {error_msg}") RuntimeError: 7z compression failed: ERROR: Can't allocate required memory! stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive: 14402 files, 717236181577 bytes (668 GiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\3_background\2025-10-26\tmptg4k1cqi.7z
Add new data to archive: 14402 files, 717236181577 bytes (668 GiB) . was unexpected at this time.
Agent (model default, mode Agent)
Logging looks good. RAM estimation is too low for very large sequences (e.g., 668GB). Updating the formula to account for large sequences:
Tool use: **read_file** • Read file: zip_sequences.py
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 14, lines removed: 4
@@ -218,10 +218,20 @@
dict_ram = FIXED_DICT_SIZE_BYTES * 11 # 11GB for 1GB dictionary
- # Input buffer: 7z processes in chunks, doesn't need full file in RAM
- # Use 15% of sequence size, capped at 2GB
- input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)
+ # Input buffer: 7z processes in chunks, but for very large sequences needs more RAM
+ # For sequences >100GB, use a larger buffer factor to handle file metadata and processing
+ if total_bytes > 100 * 1024 * 1024 * 1024: # >100GB
+ # Very large sequences: use 5% of size, capped at 32GB
+ # This accounts for file metadata, directory structures, and processing overhead
+ input_buffer = min(int(total_bytes * 0.05), 32 * 1024 * 1024 * 1024)
+ else:
+ # Smaller sequences: use 15% of size, capped at 2GB
+ input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)
# Compression overhead: additional memory for compression process
- overhead = 1 * 1024 * 1024 * 1024 # 1GB
+ # For very large sequences, increase overhead
+ if total_bytes > 100 * 1024 * 1024 * 1024: # >100GB
+ overhead = 4 * 1024 * 1024 * 1024 # 4GB for large sequences
+ else:
+ overhead = 1 * 1024 * 1024 * 1024 # 1GB for smaller sequences
# Total RAM estimate
Tool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Logging is working. RAM estimation is too low for very large sequences (e.g., 668GB). Updating the formula to better handle large sequences:
Tool use: **read_file** • Read file: zip_sequences.py
The bin-packing is creating bins that exceed available RAM. For a 668GB sequence, the estimate is ~47GB (11GB dict + 32GB buffer + 4GB overhead), but the bins show 70GB, suggesting the estimate is higher or the packing is incorrect. Checking the bin-packing logic:
Tool use: **read_file** • Read file: zip_sequences.py
For a 668GB sequence with 14,402 files, 7z needs more RAM for metadata and directory structures. Increasing the estimate for very large sequences:
Tool use: **code_edit** • Edit file: zip_sequences.py
Chunk 1 Lines added: 7, lines removed: 8
@@ -220,17 +220,16 @@
# Input buffer: 7z processes in chunks, but for very large sequences needs more RAM
# For sequences >100GB, use a larger buffer factor to handle file metadata and processing
- if total_bytes > 100 * 1024 * 1024 * 1024: # >100GB
- # Very large sequences: use 5% of size, capped at 32GB
+ if total_bytes > 500 * 1024 * 1024 * 1024: # >500GB (extremely large)
+ # Extremely large sequences: use 8% of size, capped at 64GB
# This accounts for file metadata, directory structures, and processing overhead
+ input_buffer = min(int(total_bytes * 0.08), 64 * 1024 * 1024 * 1024)
+ overhead = 8 * 1024 * 1024 * 1024 # 8GB overhead for extremely large sequences
+ elif total_bytes > 100 * 1024 * 1024 * 1024: # >100GB
+ # Very large sequences: use 5% of size, capped at 32GB
input_buffer = min(int(total_bytes * 0.05), 32 * 1024 * 1024 * 1024)
+ overhead = 4 * 1024 * 1024 * 1024 # 4GB for large sequences
else:
# Smaller sequences: use 15% of size, capped at 2GB
input_buffer = min(int(total_bytes * 0.15), 2 * 1024 * 1024 * 1024)
-
- # Compression overhead: additional memory for compression process
- # For very large sequences, increase overhead
- if total_bytes > 100 * 1024 * 1024 * 1024: # >100GB
- overhead = 4 * 1024 * 1024 * 1024 # 4GB for large sequences
- else:
overhead = 1 * 1024 * 1024 * 1024 # 1GB for smaller sequences
Tool use: **read_lints** • Read lints for 1 file
Lint paths:
zip_sequences.py
Lint results
No lint errors found
Updated RAM estimation for very large sequences (>500GB). For a 668GB sequence, the estimate is now:
- 11GB (dictionary) + 53.4GB (8% buffer, capped at 64GB) + 8GB (overhead) = ~72.4GB
This should better account for file metadata and directory structures in very large sequences. The bin-packing algorithm will use this to determine how many can run concurrently.
The startup logging is working—you can see what's happening during the scan phase. The script now shows:
- Configuration loading
- Sequence scanning progress
- Which sequences are queued vs skipped
- RAM estimation and bin-packing results
Try running it again; it should allocate RAM more accurately for the 668GB sequence and prevent the memory allocation error.
User (2025-11-12 07:20Z)
oh, those huge sequences were supposed to be in an _archive folder to get skipped. oops!
Agent (model default, mode Agent)
The script already skips _archive and _CURRENT folders. Those large sequences (668GB, 527GB) were in regular directories, so they were processed.
The skip logic is in find_sequence_dirs() and iter_sequence_files():
def find_sequence_dirs(root: Path) -> Iterator[Path]:
for dirpath, dirnames, filenames in os.walk(root):
path = Path(dirpath)
dirnames[:] = [d for d in dirnames if d not in ("_archive", "_CURRENT")]
if is_archive_path(path):
continue
has_frames = any(Path(dirpath, f).suffix.lower() in SEQUENCE_EXTENSIONS for f in filenames)
if has_frames:
yield path
If you move those sequences into _archive folders, they'll be skipped automatically. The RAM estimation updates will still help if you do need to compress very large sequences in the future.
User (2025-11-12 18:23Z)
what happened this time?
PS R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders> & "R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders\ZipSeqArchv.bat" [init] Loading configuration sources... [init] Reading project config at R:\Creative\artsy\maya\2025-02 BondingArtifact.config\config.json [init] Configuration loaded from project [init] zip_sequences starting up... [init] Working directory: R:\Creative\artsy\maya\2025-02 BondingArtifact [init] Mode: zip, zipper: 7z, jobs arg: auto [init] 7z executable: C:\ProgramData\chocolatey\bin\7z.EXE, Max7zInst: auto [init] Scanning sequences under \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders [scan] [1] Inspecting 0_GaslightRender\2024-11-23\GaslightRender_smol [scan] Queued 0_GaslightRender\2024-11-23\GaslightRender_smol for compression (~0.19GB) [1 total] [scan] [2] Inspecting 1_PonderingOrb\2021-01-10\rendertest [scan] Queued 1_PonderingOrb\2021-01-10\rendertest for compression (~2.33GB) [2 total] [scan] [3] Inspecting 1_PonderingOrb\2024-11-23\PonderingOrb [scan] Queued 1_PonderingOrb\2024-11-23\PonderingOrb for compression (~19.76GB) [3 total] [scan] [4] Inspecting 3_background\2025-01-13\background [scan] Queued 3_background\2025-01-13\background for compression (~16.73GB) [4 total] [scan] [5] Inspecting 3_background\2025-10-30\3_background_dark_4k [scan] Queued 3_background\2025-10-30\3_background_dark_4k for compression (~27.57GB) [5 total] [scan] [10] Inspecting 4_StartupScreen\2025-02-08\Shot3Redo_128 [scan] Queued 4_StartupScreen\2025-02-08\Shot3Redo_128 for compression (~8.44GB) [10 total] [scan] Queued 4_StartupScreen\2025-02-14\Shot8 for compression (~0.65GB) [15 total] [scan] [20] Inspecting 4_StartupScreen\2025-10-24\Shot2_dark [scan] Queued 4_StartupScreen\2025-10-24\Shot2_dark for compression (~2.36GB) [20 total] [scan] Queued 4_StartupScreen\2025-11-03\Shot1_dark for compression (~0.78GB) [25 total] [scan] [30] Inspecting 4_StartupScreen\2025-11-07\Shot2_dark [scan] Queued 4_StartupScreen\2025-11-07\Shot2_dark for compression (~2.37GB) [30 total] [scan] Queued 5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed for compression (~0.16GB) [35 total] [zip] RAM: 76.7GB available (80% of total), 72.9GB for compression (95%) [zip] Estimated RAM per sequence: 486.6GB total across 38 sequences [zip] Bin-packing: 8 workers, bin sizes: ['70.0GB', '69.4GB', '65.0GB', '62.6GB', '61.9GB']... [zip] Final worker count: 8 (requested: None) [init] Preparing to compress 38 sequence(s) with 8 worker(s) [zip] Renders\0_GaslightRender\2024-11-23\GaslightRender_smol -> Renders_zipped\0_GaslightRender\2024-11-23\GaslightRender_smol.7z [zip] Renders\1_PonderingOrb\2021-01-10\rendertest -> Renders_zipped\1_PonderingOrb\2021-01-10\rendertest.7z [zip] Renders\1_PonderingOrb\2024-11-23\PonderingOrb -> Renders_zipped\1_PonderingOrb\2024-11-23\PonderingOrb.7z [zip] Renders\3_background\2025-01-13\background -> Renders_zipped\3_background\2025-01-13\background.7z [zip] Renders\3_background\2025-10-30\3_background_dark_4k -> Renders_zipped\3_background\2025-10-30\3_background_dark_4k.7z [zip] Renders\3_background\2025-11-02\3_background_dark -> Renders_zipped\3_background\2025-11-02\3_background_dark.7z [zip] Renders\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered -> Renders_zipped\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered.7z [zip] Renders\4_StartupScreen\2025-02-08\Shot1Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot1Redo_1024.7z [zip] Renders\4_StartupScreen\2025-02-08\Shot2Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot2Redo_1024.7z [zip] 1/38 0_GaslightRender\2024-11-23\GaslightRender_smol [zip] Renders\4_StartupScreen\2025-02-08\Shot3Redo_128 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot3Redo_128.7z [zip] Renders\4_StartupScreen\2025-02-09\Shot1 -> Renders_zipped\4_StartupScreen\2025-02-09\Shot1.7z [zip] Renders\4_StartupScreen\2025-02-10\Shot2 -> Renders_zipped\4_StartupScreen\2025-02-10\Shot2.7z [zip] Renders\4_StartupScreen\2025-02-12\Shot3 -> Renders_zipped\4_StartupScreen\2025-02-12\Shot3.7z [zip] Renders\4_StartupScreen\2025-02-13\Shot6 -> Renders_zipped\4_StartupScreen\2025-02-13\Shot6.7z [zip] Renders\4_StartupScreen\2025-02-14\Shot8 -> Renders_zipped\4_StartupScreen\2025-02-14\Shot8.7z [zip] Renders\4_StartupScreen\2025-02-19\StartupScreen_finalfix -> Renders_zipped\4_StartupScreen\2025-02-19\StartupScreen_finalfix.7z [zip] Renders\4_StartupScreen\2025-10-21\Shot4 -> Renders_zipped\4_StartupScreen\2025-10-21\Shot4.7z [zip] Renders\4_StartupScreen\2025-10-24\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-10-24\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-10-24\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-10-24\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot2_dark.7z [zip] Renders\4_StartupScreen\2025-10-24\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot3_dark.7z [zip] Renders\4_StartupScreen\2025-10-25\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-10-25\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot3_dark.7z [zip] Renders\4_StartupScreen\2025-11-02\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-02\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-11-03\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-03\Shot1_dark.7z [zip] Renders\4_StartupScreen\2025-11-04\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-04\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-11-04\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-11-04\Shot2_dark.7z [zip] Renders\4_StartupScreen\2025-11-04\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-04\Shot3_dark.7z [zip] Renders\4_StartupScreen\2025-11-07\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-07\4_StartupScreen_dark.7z [zip] Renders\4_StartupScreen\2025-11-07\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot2_dark.7z [zip] Renders\4_StartupScreen\2025-11-07\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot3_dark.7z [zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR1 -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR1.7z [zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR1_evensrenamed -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR1_evensrenamed.7z [zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR2 -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR2.7z [zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed.7z [zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR3 -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR3.7z [zip] Renders\5_goodbye\2025-02-23\goodbye -> Renders_zipped\5_goodbye\2025-02-23\goodbye.7z [zip] Renders\5_goodbye\2025-02-23\goodbye_test -> Renders_zipped\5_goodbye\2025-02-23\goodbye_test.7z Sequence sync failed: 7z compression failed: ERROR: Can't allocate required memory! stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive: 901 files, 29607407902 bytes (28 GiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\3_background\2025-10-30\tmp0dcysds4.7z
Add new data to archive: 901 files, 29607407902 bytes (28 GiB) Traceback (most recent call last): File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 1000, in raise SystemExit(main()) ~~~~^^ File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 994, in main updated = run_zip(args.jobs, verbose=args.verbose) File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 866, in run_zip updated_paths.extend(future.result()) ~~~~~~~~~~~~~^^ File "C:\Python313\Lib\concurrent\futures_base.py", line 449, in result return self.__get_result() ~~~~~~~~~~~~~~~~~^^ File "C:\Python313\Lib\concurrent\futures_base.py", line 401, in __get_result raise self._exception File "C:\Python313\Lib\concurrent\futures\thread.py", line 59, in run result = self.fn(*self.args, **self.kwargs) File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 732, in process_zip zip_sequence(seq_dir, zip_path, per_job_memory_limit) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "R:\Creative\artsy\maya\0 ProjectStructure\zip_sequences.py", line 627, in zip_sequence raise RuntimeError(f"7z compression failed: {error_msg}") RuntimeError: 7z compression failed: ERROR: Can't allocate required memory! stdout: 7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
Scanning the drive: 901 files, 29607407902 bytes (28 GiB)
Creating archive: \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders_zipped\3_background\2025-10-30\tmp0dcysds4.7z
Add new data to archive: 901 files, 29607407902 bytes (28 GiB) . was unexpected at this time.
Agent (model default, mode Ask)
The bin-packing is creating bins that sum to ~70GB each, but with 8 workers running concurrently, total RAM usage exceeds the 72.9GB available.
The algorithm packs sequences into bins but doesn't account for all bins running at once. If 8 bins each use ~70GB, that's ~560GB total, far above the limit.
Checking the bin-packing logic:
Tool use: **read_file** • Read file: zip_sequences.py
User (2025-11-12 18:23Z)
did it work?
PS R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders> & "R:\Creative\artsy\maya\2025-02 BondingArtifact\Renders\ZipSeqArchv.bat"
[init] Loading configuration sources...
[init] Reading project config at R:\Creative\artsy\maya\2025-02 BondingArtifact.config\config.json
[init] Configuration loaded from project
[init] zip_sequences starting up...
[init] Working directory: R:\Creative\artsy\maya\2025-02 BondingArtifact
[init] Mode: zip, zipper: 7z, jobs arg: auto
[init] 7z executable: C:\ProgramData\chocolatey\bin\7z.EXE, Max7zInst: auto
[init] Scanning sequences under \Hydra\Hydra\Creative\artsy\maya\2025-02 BondingArtifact\Renders
[scan] [1] Inspecting 0_GaslightRender\2024-11-23\GaslightRender_smol
[scan] Skipping 0_GaslightRender\2024-11-23\GaslightRender_smol (unchanged since archive)
[scan] [2] Inspecting 1_PonderingOrb\2021-01-10\rendertest
[scan] Skipping 1_PonderingOrb\2021-01-10\rendertest (unchanged since archive)
[scan] [3] Inspecting 1_PonderingOrb\2024-11-23\PonderingOrb
[scan] Queued 1_PonderingOrb\2024-11-23\PonderingOrb for compression (~19.76GB) [1 total]
[scan] [4] Inspecting 3_background\2025-01-13\background
[scan] Skipping 3_background\2025-01-13\background (unchanged since archive)
[scan] [5] Inspecting 3_background\2025-10-30\3_background_dark_4k
[scan] Queued 3_background\2025-10-30\3_background_dark_4k for compression (~27.57GB) [2 total]
[scan] Queued 3_background\2025-11-02\3_background_dark for compression (~7.40GB) [3 total]
[scan] Queued 4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered for compression (~20.66GB) [4 total]
[scan] Queued 4_StartupScreen\2025-02-08\Shot1Redo_1024 for compression (~9.26GB) [5 total]
[scan] [10] Inspecting 4_StartupScreen\2025-02-08\Shot3Redo_128
[scan] Queued 4_StartupScreen\2025-02-12\Shot3 for compression (~4.04GB) [10 total]
[scan] Skipping 4_StartupScreen\2025-02-14\Shot8 (unchanged since archive)
[scan] Skipping 4_StartupScreen\2025-10-21\Shot4 (unchanged since archive)
[scan] [20] Inspecting 4_StartupScreen\2025-10-24\Shot2_dark
[scan] Queued 4_StartupScreen\2025-10-24\Shot2_dark for compression (~2.36GB) [15 total]
[scan] Queued 4_StartupScreen\2025-11-03\Shot1_dark for compression (~0.78GB) [20 total]
[scan] [30] Inspecting 4_StartupScreen\2025-11-07\Shot2_dark
[scan] Queued 4_StartupScreen\2025-11-07\Shot2_dark for compression (~2.37GB) [25 total]
[scan] Queued 5_goodbye\2025-02-23\goodbye for compression (~49.80GB) [30 total]
[zip] RAM: 76.7GB available (80% of total), 72.9GB for compression (95%)
[zip] Estimated RAM per sequence: 93.0GB total across 31 sequences
[zip] Using bin-packing: 2 workers needed
[zip] Final worker count: 2 (requested: None)
[init] Preparing to compress 31 sequence(s) with 2 worker(s)
[zip] Renders\1_PonderingOrb\2024-11-23\PonderingOrb -> Renders_zipped\1_PonderingOrb\2024-11-23\PonderingOrb.7z
[zip] Renders\3_background\2025-10-30\3_background_dark_4k -> Renders_zipped\3_background\2025-10-30\3_background_dark_4k.7z
[zip] Renders\3_background\2025-11-02\3_background_dark -> Renders_zipped\3_background\2025-11-02\3_background_dark.7z
[zip] 1/31 1_PonderingOrb\2024-11-23\PonderingOrb
[zip] Renders\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered -> Renders_zipped\4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered.7z
[zip] 2/31 3_background\2025-11-02\3_background_dark
[zip] 3/31 3_background\2025-10-30\3_background_dark_4k
[zip] Renders\4_StartupScreen\2025-02-08\Shot1Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot1Redo_1024.7z
[zip] Renders\4_StartupScreen\2025-02-08\Shot2Redo_1024 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot2Redo_1024.7z
[zip] 4/31 4_StartupScreen\2025-02-08\Shot1Redo_1024
[zip] Renders\4_StartupScreen\2025-02-08\Shot3Redo_128 -> Renders_zipped\4_StartupScreen\2025-02-08\Shot3Redo_128.7z
[zip] 5/31 4_StartupScreen\2025-02-05\4_StartupScreen_evensrenumbered
[zip] Renders\4_StartupScreen\2025-02-09\Shot1 -> Renders_zipped\4_StartupScreen\2025-02-09\Shot1.7z
[zip] 6/31 4_StartupScreen\2025-02-08\Shot2Redo_1024
[zip] Renders\4_StartupScreen\2025-02-10\Shot2 -> Renders_zipped\4_StartupScreen\2025-02-10\Shot2.7z
[zip] 7/31 4_StartupScreen\2025-02-09\Shot1
[zip] Renders\4_StartupScreen\2025-02-12\Shot3 -> Renders_zipped\4_StartupScreen\2025-02-12\Shot3.7z
[zip] 8/31 4_StartupScreen\2025-02-08\Shot3Redo_128
[zip] Renders\4_StartupScreen\2025-02-13\Shot6 -> Renders_zipped\4_StartupScreen\2025-02-13\Shot6.7z
[zip] 9/31 4_StartupScreen\2025-02-10\Shot2
[zip] Renders\4_StartupScreen\2025-02-19\StartupScreen_finalfix -> Renders_zipped\4_StartupScreen\2025-02-19\StartupScreen_finalfix.7z
[zip] 10/31 4_StartupScreen\2025-02-12\Shot3
[zip] Renders\4_StartupScreen\2025-10-24\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-10-24\4_StartupScreen_dark.7z
[zip] 11/31 4_StartupScreen\2025-02-13\Shot6
[zip] Renders\4_StartupScreen\2025-10-24\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot1_dark.7z
[zip] 12/31 4_StartupScreen\2025-10-24\4_StartupScreen_dark
[zip] Renders\4_StartupScreen\2025-10-24\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot2_dark.7z
[zip] 13/31 4_StartupScreen\2025-10-24\Shot1_dark
[zip] Renders\4_StartupScreen\2025-10-24\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-24\Shot3_dark.7z
[zip] 14/31 4_StartupScreen\2025-02-19\StartupScreen_finalfix
[zip] Renders\4_StartupScreen\2025-10-25\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot1_dark.7z
[zip] 15/31 4_StartupScreen\2025-10-24\Shot2_dark
[zip] Renders\4_StartupScreen\2025-10-25\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-10-25\Shot3_dark.7z
[zip] 16/31 4_StartupScreen\2025-10-24\Shot3_dark
[zip] Renders\4_StartupScreen\2025-11-02\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-02\Shot1_dark.7z
[zip] 17/31 4_StartupScreen\2025-10-25\Shot1_dark
[zip] Renders\4_StartupScreen\2025-11-03\Shot1_dark -> Renders_zipped\4_StartupScreen\2025-11-03\Shot1_dark.7z
[zip] 18/31 4_StartupScreen\2025-10-25\Shot3_dark
[zip] Renders\4_StartupScreen\2025-11-04\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-04\4_StartupScreen_dark.7z
[zip] 19/31 4_StartupScreen\2025-11-03\Shot1_dark
[zip] Renders\4_StartupScreen\2025-11-04\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-11-04\Shot2_dark.7z
[zip] 20/31 4_StartupScreen\2025-11-02\Shot1_dark
[zip] Renders\4_StartupScreen\2025-11-04\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-04\Shot3_dark.7z
[zip] 21/31 4_StartupScreen\2025-11-04\Shot2_dark
[zip] Renders\4_StartupScreen\2025-11-07\4_StartupScreen_dark -> Renders_zipped\4_StartupScreen\2025-11-07\4_StartupScreen_dark.7z
[zip] 22/31 4_StartupScreen\2025-11-04\Shot3_dark
[zip] Renders\4_StartupScreen\2025-11-07\Shot2_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot2_dark.7z
[zip] 23/31 4_StartupScreen\2025-11-04\4_StartupScreen_dark
[zip] Renders\4_StartupScreen\2025-11-07\Shot3_dark -> Renders_zipped\4_StartupScreen\2025-11-07\Shot3_dark.7z
[zip] 24/31 4_StartupScreen\2025-11-07\Shot2_dark
[zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR1 -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR1.7z
[zip] 25/31 4_StartupScreen\2025-11-07\Shot3_dark
[zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR1_evensrenamed -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR1_evensrenamed.7z
[zip] 26/31 5_goodbye\2025-02-23\219_goodbye_RR1
[zip] Renders\5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed -> Renders_zipped\5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed.7z
[zip] 27/31 4_StartupScreen\2025-11-07\4_StartupScreen_dark
[zip] Renders\5_goodbye\2025-02-23\goodbye -> Renders_zipped\5_goodbye\2025-02-23\goodbye.7z
[zip] 28/31 5_goodbye\2025-02-23\219_goodbye_RR2_evensrenamed
[zip] Renders\5_goodbye\2025-02-23\goodbye_test -> Renders_zipped\5_goodbye\2025-02-23\goodbye_test.7z
[zip] 29/31 5_goodbye\2025-02-23\219_goodbye_RR1_evensrenamed
[zip] 30/31 5_goodbye\2025-02-23\goodbye_test
[zip] 31/31 5_goodbye\2025-02-23\goodbye
[zip] Updated 31 sequence archive(s).
[zip] Archives updated. Stage manually with git add Renders/_zipped, if desired.
[scan] Summary: scanned 38, quick-skipped 7, state-skipped 0, empty 0, queued 31
. was unexpected at this time.
Agent (model default, mode Ask)
Yes, it worked.
The script completed successfully:
- All 31 sequences compressed (1/31 through 31/31)
- Bin-packing calculated 2 workers (down from 8)
- No memory allocation errors
- Final message: "Updated 31 sequence archive(s)."
The bin-packing correctly limited workers to 2 based on RAM estimates, preventing the previous memory overflow.
The only remaining issue is the batch file error at the end: . was unexpected at this time. This is a Windows batch file syntax error in ZipSeqArchv.bat, not a Python error. The Python script finished successfully; the batch file likely has a syntax issue in its error handling or cleanup code.
The compression is working correctly with the new size-aware RAM estimation and bin-packing algorithm.