Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"cffi",
"cfgv",
"charliermarsh",
"chunksize",
"cimport",
"cimports",
"clibase",
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
All notable changes to this project will be documented in this file.

- [Changelog](#changelog)
- [2.1.0 - 2026-06-20](#210---2026-06-20)
- [2.1.0 - 2026-06-26](#210---2026-06-26)
- [2.0.0 - 2026-06-19](#200---2026-06-19)
- [1.9.0 - 2026-06-11](#190---2026-06-11)
- [1.8.0 - 2026-06-06](#180---2026-06-06)
Expand All @@ -30,9 +30,10 @@ This project follows a pragmatic versioning approach:
- **Minor**: new features or non-breaking changes.
- **Major**: breaking changes (command renames, incompatible output formats).

## 2.1.0 - 2026-06-20
## 2.1.0 - 2026-06-26

- Added
- **Multi-process frame interpolation rendering** (`core/zoom.py`, `InterpolatedFrameStream()`): frame interpolation (`--i-frames`) now uses Python's `concurrent.futures.ProcessPoolExecutor` to parallelize rendering of interpolated frames across multiple CPU cores; new `InterpolateFrameWorker()` function runs as a separate process to compute individual interpolated frames (linear or quadratic); new `InterpolationJob` and `InterpolationResult` dataclasses encapsulate job distribution and result collection; `InterpolatedFrameStream()` accepts optional `max_threads` parameter (default None = use all available cores) to control parallelism; executor automatically disabled when `i_frames=0` (no interpolation) or single-threaded execution is requested; dramatically speeds up animation generation for high-FPS outputs (e.g., `--fps 10 --i-frames 7` now renders 73 total frames from 10 real frames with ~7× speedup due to parallelism).
- **Animation metadata hash injection control** (`--inject/--no-inject` flag, `cli/base.py`, `cli/zoomcommand.py`): new `tranz zoom auto` flag allows users to control whether the final hash is re-injected into animation metadata after rendering; `--inject` (default False) re-saves the animation file to include the final computed hash in metadata, which requires re-processing (lossless for MP4, lossy for GIF); `--no-inject` skips this step for faster completion when the final hash in metadata is not critical; useful for testing or when metadata space is constrained; both GIF and MP4 re-saving is expensive but preserves content fidelity for MP4 via ffmpeg re-mux.

- Changed
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Built with:
- [Modules / packages](#modules--packages)
- [Performance characteristics](#performance-characteristics)
- [Three-tier optimization system](#three-tier-optimization-system)
- [Multi-process frame interpolation](#multi-process-frame-interpolation)
- [Development Instructions](#development-instructions)
- [File structure](#file-structure)
- [Development Setup](#development-setup)
Expand Down Expand Up @@ -1163,6 +1164,16 @@ tranZoom (≥1.7.0) provides three computation modes with progressively better p

The default (`--opt` not specified) automatically uses the **best available** optimization. Starting with version 1.8.0, wheels built with `poetry build` include pre-compiled Cython extensions automatically.

#### Multi-process frame interpolation

Frame interpolation (`--i-frames`) uses **multi-process parallelization** to speed up animation rendering. When frame interpolation is active, interpolated frames are generated in parallel across available CPU cores:

- **Architecture:** Uses Python's `concurrent.futures.ProcessPoolExecutor` to distribute interpolation jobs across separate OS processes, avoiding Python's Global Interpreter Lock (GIL)
- **Worker function:** `InterpolateFrameWorker()` computes individual interpolated frames (supports both linear and quadratic interpolation modes) in independent processes
- **Speedup:** Renders high-FPS animations much faster; e.g., `--fps 10 --i-frames 7` (producing 73 frames) benefits from ~7–8× speedup with all cores utilized; actual speedup depends on CPU count and per-frame workload
- **Control:** Pass `max_threads` at the command level to cap parallelism; default uses all available cores
- **Automatic fallback:** When `i_frames=0` (no interpolation) or on single-core systems, no process pool is created; overhead is negligible

## Development Instructions

### File structure
Expand Down
615 changes: 290 additions & 325 deletions poetry.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ dependencies = [
"gmpy2>=2.3", # if you change here, also change "requires" above
"ImageIO>=2.37",
"imageio-ffmpeg>=0.6",
"numpy>=2.4",
"numpy>=2.5",
"Pillow>=12.2",
"rich>=15.0",
"setuptools>=82.0", # if you change here, also change "requires" above
"tqdm>=4.68",
"transai>=1.3.3",
"transcrypto>=2.7",
"typer>=0.26.7", # if this changes, also change: [tool.poetry.dependencies]
"typer>=0.26.8", # if this changes, also change: [tool.poetry.dependencies]

]

Expand Down Expand Up @@ -125,7 +125,7 @@ script = "scripts/build_extensions.py"
# prefer to add dependencies inside the [project.dependencies] section

python = "^3.12" # if version changes, remember to change README.md
typer = { version = "^0.26.7", extras = ["all"] } # if this changes, also change: dependencies
typer = { version = "^0.26.8", extras = ["all"] } # if this changes, also change: dependencies

[tool.poetry.group.dev.dependencies]

Expand All @@ -138,7 +138,7 @@ pytest = "^9.1"
pytest-cov = "^7.1"
pytest-flakefinder = "^1.1"
pre-commit = "^4.6"
ruff = "^0.15.18"
ruff = "^0.15.20"
typeguard = "^4.5"

# project-specific dev-only
Expand Down
12 changes: 6 additions & 6 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
annotated-doc==0.0.4 ; python_version >= "3.12" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
anyio==4.14.0 ; python_version >= "3.12" and python_version < "4.0"
anyio==4.14.1 ; python_version >= "3.12" and python_version < "4.0"
certifi==2026.6.17 ; python_version >= "3.12" and python_version < "4.0"
cffi==2.0.0 ; python_version >= "3.12" and python_version < "4.0" and platform_python_implementation != "PyPy"
click==8.4.1 ; python_version >= "3.12" and python_version < "4.0"
click==8.4.2 ; python_version >= "3.12" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and platform_system == "Windows"
cryptography==49.0.0 ; python_version >= "3.12" and python_version < "4.0"
cython==3.2.5 ; python_version >= "3.12" and python_version < "4.0"
cython==3.2.6 ; python_version >= "3.12" and python_version < "4.0"
diskcache==5.6.3 ; python_version >= "3.12" and python_version < "4.0"
filelock==3.29.4 ; python_version >= "3.12" and python_version < "4.0"
fsspec==2026.6.0 ; python_version >= "3.12" and python_version < "4.0"
func-timeout==4.3.5 ; python_version >= "3.12" and python_version < "4.0"
gmpy2==2.3.0 ; python_version >= "3.12" and python_version < "4.0"
gmpy2==2.3.1 ; python_version >= "3.12" and python_version < "4.0"
h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0"
hf-xet==1.5.1 ; python_version >= "3.12" and python_version < "4.0" and (platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "arm64" or platform_machine == "aarch64")
httpcore==1.0.9 ; python_version >= "3.12" and python_version < "4.0"
Expand All @@ -28,7 +28,7 @@ markdown-it-py==4.2.0 ; python_version >= "3.12" and python_version < "4.0"
markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "4.0"
mdurl==0.1.2 ; python_version >= "3.12" and python_version < "4.0"
msgspec==0.21.1 ; python_version >= "3.12" and python_version < "4.0"
numpy==2.4.6 ; python_version >= "3.12" and python_version < "4.0"
numpy==2.5.0 ; python_version >= "3.12" and python_version < "4.0"
packaging==26.2 ; python_version >= "3.12" and python_version < "4.0"
pillow==12.2.0 ; python_version >= "3.12" and python_version < "4.0"
platformdirs==4.10.0 ; python_version >= "3.12" and python_version < "4.0"
Expand All @@ -43,7 +43,7 @@ shellingham==1.5.4 ; python_version >= "3.12" and python_version < "4.0"
tqdm==4.68.3 ; python_version >= "3.12" and python_version < "4.0"
transai==1.3.3 ; python_version >= "3.12" and python_version < "4.0"
transcrypto==2.7.0 ; python_version >= "3.12" and python_version < "4.0"
typer==0.26.7 ; python_version >= "3.12" and python_version < "4.0"
typer==0.26.8 ; python_version >= "3.12" and python_version < "4.0"
typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "4.0"
typing-inspection==0.4.2 ; python_version >= "3.12" and python_version < "4.0"
wsproto==1.3.2 ; python_version >= "3.12" and python_version < "4.0"
Expand Down
15 changes: 8 additions & 7 deletions src/tranzoom/cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1575,7 +1575,7 @@ def _SmartImage(i: int) -> image.Image:
try:

def _TwoFrameRenderStream() -> abc.Iterator[
tuple[zoom.RenderedZoomFrame, zoom.RenderedZoomFrame | None]
tuple[pixels.RenderedZoomFrame, pixels.RenderedZoomFrame | None]
]:
"""Render base frames with a rolling [curr, next] window.

Expand All @@ -1589,27 +1589,27 @@ def _TwoFrameRenderStream() -> abc.Iterator[
(frameN-1, None)

Yields:
tuple[zoom.RenderedZoomFrame, zoom.RenderedZoomFrame | None]: A tuple of
tuple[pixels.RenderedZoomFrame, pixels.RenderedZoomFrame | None]: A tuple of
the current frame and the next frame (or None if at the end)

"""
curr_frame: zoom.RenderedZoomFrame = _StreamingRenderFrame(0)
next_frame: zoom.RenderedZoomFrame | None = _StreamingRenderFrame(1) # (MIN_FRAMES is 3)
curr_frame: pixels.RenderedZoomFrame = _StreamingRenderFrame(0)
next_frame: pixels.RenderedZoomFrame | None = _StreamingRenderFrame(1) # (MIN_FRAMES=3)
for i in range(zoom_params.n_frames):
yield (curr_frame, next_frame)
if next_frame is None:
break
curr_frame = next_frame
next_frame = _StreamingRenderFrame(i + 2) if i + 2 < zoom_params.n_frames else None

def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame:
def _StreamingRenderFrame(i: int) -> pixels.RenderedZoomFrame:
"""Render a single frame, returning the image data. Only one in memory at a time.

Args:
i (int): The index of the frame in the zoom sequence.

Returns:
zoom.RenderedZoomFrame: The rendered image data for the frame at index i.
pixels.RenderedZoomFrame: The rendered image data for the frame at index i.

"""
# render the frame, get the image data and hash
Expand Down Expand Up @@ -1642,7 +1642,7 @@ def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame:
# update progress bar, return data
if p_bar:
p_bar.update(1)
return zoom.RenderedZoomFrame(
return pixels.RenderedZoomFrame(
idx=i, data=img_data, data_hash=data_hash, img_path=img_path
)

Expand Down Expand Up @@ -1701,6 +1701,7 @@ def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame:
i_frames=zoom_params.render.i_frames,
zoom_per_step=float(zoom_params.scalar_magnification_per_step),
use_quadratic=zoom.DEFAULT_USE_QUADRATIC,
max_threads=config.max_threads,
)
if zoom_params.render.anim == pixels.AnimationEncoding.GIF:
zoom.WriteAnimatedGIF(
Expand Down
4 changes: 2 additions & 2 deletions src/tranzoom/core/fractal.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,10 @@ def ComputeFractal(
if params.frm.fractal == frame.Fractal.MANDELBROT
else PY_JULIA_COMPUTATION
)
# log the start of the render (not pre-computation anymore here)
# log the start of the actual computation (not pre-computation anymore here)
logging.info(
f'{params.frm.fractal.value.upper()} using {n_processes} process(es) '
f'for {"PRE " if is_preprocess else ""}rendering - {actual_opt_msg}'
f'for {"PRE " if is_preprocess else ""}computation - {actual_opt_msg}'
)
# create inputs
inp: list[image.FractalTaskInput] = [
Expand Down
Loading
Loading