diff --git a/.vscode/settings.json b/.vscode/settings.json index f1f841f..e57e989 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -240,6 +240,8 @@ "msgspec", "MSVC", "musllinux", + "muxed", + "muxing", "mycli", "myconfig", "myprog", diff --git a/CHANGELOG.md b/CHANGELOG.md index 548d9f4..6cb3011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. - [Changelog](#changelog) - - [V.V.V - 2026-06-DD - Placeholder](#vvv---2026-06-dd---placeholder) + - [2.1.0 - 2026-06-20](#210---2026-06-20) - [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) @@ -30,16 +30,19 @@ This project follows a pragmatic versioning approach: - **Minor**: new features or non-breaking changes. - **Major**: breaking changes (command renames, incompatible output formats). -## V.V.V - 2026-06-DD - Placeholder +## 2.1.0 - 2026-06-20 - Added - - Placeholder for future additions. + - **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 - - Placeholder for future changes. + - **MP4 metadata re-muxing optimization** (`core/zoom.py`): `ReWriteVideoMP4Meta()` completely rewritten to use external ffmpeg re-muxing instead of re-encoding; new implementation calls `imageio_ffmpeg.get_ffmpeg_exe()` with `copy` codec and `map_metadata` flags for lossless stream copying; dramatically reduces MP4 re-processing time from full re-encode to simple container re-mux operation; preserves all video frames and streams without quality loss; uses `subprocess.run()` to invoke ffmpeg directly with proper error handling and metadata injection support. + - **Test animation outputs** (`scripts/make_test_images.sh`, `tests_integration/test_cython_equivalence.py`, `tests_integration/test_installed_cli.py`): updated to account for MP4 re-muxing changes. + - **Example outputs in README**: updated example command outputs to show new "Copy file to destination" message when animations are saved; examples now display Lanczos resampling method in output format `{[PNG*2/Lanczos: ...]}` to indicate interpolation method being used. - Fixed - - Placeholder for future fixes. + - **MP4 video stability**: MP4 files created with metadata injection are now stable and byte-for-byte identical across multiple re-mux operations (ffmpeg re-mux with same metadata always produces identical output), whereas previous re-encoding approach introduced minor frame variations due to codec variability. + - **Animation rendering consistency**: animations now properly track hash values through metadata injection workflow, ensuring that multiple renders with `--inject` flag produce consistent, reproducible metadata. ## 2.0.0 - 2026-06-19 diff --git a/README.md b/README.md index c39a3bb..a3ddd70 100644 --- a/README.md +++ b/README.md @@ -839,6 +839,7 @@ Command-level options: | `--i-frames` | Number of interpolated frames to add between each pair of real frames (0–7); increases effective FPS via temporal interpolation; see [Frame interpolation](#frame-interpolation---i-frames) | `0` | | `--loop` | Number of GIF loops; `0` = infinite (ignored for MP4) | `0` | | `--save-frames/--no-save-frames` | Save each intermediate PNG frame to disk | off | +| `--inject/--no-inject` | Whether to re-save the animation after rendering to inject the final hash into metadata; MP4 uses lossless ffmpeg re-mux (fast), GIF requires re-encoding (slow); enable only if final hash in metadata is critical (e.g., for testing or verification); most users should leave off | `--no-inject` | Mark options (`--mark`, `--mark-color`, `--mark-width`) are **`tranz zoom` subgroup flags** (see [above](#tranz-zoom-subgroup-flags)) and apply to all zoom commands, including `auto`. @@ -884,7 +885,7 @@ This section describes the internal pipeline that `tranz zoom auto` follows, ste 8. **Render and animation assembly**: All frames are rendered (applying the color normalization from Step 7) and fed to `imageio` for assembly into the final GIF or MP4. In streaming mode, frames are loaded from disk one at a time so peak RAM equals roughly one frame rather than the whole animation. -9. **Metadata embedding**: The final file receives `tranZoom:zoom:*` metadata tags: frame count, FPS, duration, zoom step, magnification per step, marker frame indices, depth frame data (pre- and post-smoothing depths), and the zoom hash. This metadata can be inspected with `tranz image read`. +9. **Metadata embedding**: The final file receives `tranZoom:zoom:*` metadata tags: frame count, FPS, duration, zoom step, magnification per step, marker frame indices, depth frame data (pre- and post-smoothing depths), and the zoom hash. This metadata can be inspected with `tranz image read`. By default (`--no-inject`), the final hash is computed but not written back to the file to save time. If `--inject` is specified, the animation is re-processed to embed the final hash in the metadata: MP4 files are re-muxed losslessly using ffmpeg (preserving all frame data while updating the metadata container), while GIF files must be re-encoded (slower, slight quality loss). Most users should leave off (`--no-inject`) unless final hash verification is critical. 10. **DB persistence**: A `ZoomData` entry is written to the DB referencing all individual frames, the marker subset, and the depth subset. Future runs with the same parameters will find the cached video file and return it immediately without repeating any computation. @@ -995,12 +996,9 @@ You can easily make animations! ```sh $ poetry run tranz --no-db --no-date zoom -s 220 --mark "(-5578776469/7500000000,8244620127/62500000000)" auto " -5578776469/7500000000" "8244620127/62500000000" "0.00073801" "0.00073801" "1" --fps 5 --duration 4 --i-frames 1 -220 × 220 'GIF': 'sahara' 'Mandelbrot' 10^1.0000 magnitude ZOOM, 4.000 s long, at 5.00*2 FPS, - with 20|39 frames (2 markers, 10.00%, and 4 depth frames, 20.00%), 12.8838%/step, CYTHON OPTIMIZED... -ZOOM: <{[MANDELBROT: (-5578776469/7500000000, 8244620127/62500000000) ± 73801/100000000] : - [220 × 220, AUTO]} -> / - (mag:1, n:20|39, d:4, fps:(5)*2, l:0)> ... [MANDELBROT: (-5578776469/7500000000, 8244620127/62500000000) ± 73801/1000000000] +220 × 220 'GIF': 'sahara' 'Mandelbrot' 10^1.0000 magnitude ZOOM, 4.000 s long, at 5.00*2 FPS, with 20|39 frames (2 markers, 10.00%, and 4 depth frames, 20.00%), 12.8838%/step, CYTHON OPTIMIZED... +ZOOM: <{[MANDELBROT: (-5578776469/7500000000, 8244620127/62500000000) ± 73801/100000000] : [220 × 220, AUTO]} -> + / (mag:1, n:20|39, d:4, fps:(5)*2, l:0)> ... [MANDELBROT: (-5578776469/7500000000, 8244620127/62500000000) ± 73801/1000000000] Making 4 depth computations... Depth 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4/4 [ 0:00:01 < 0:00:00 , 3 fr/s ] @@ -1023,6 +1021,7 @@ ZOOM: Color norm: built from 2 marker frames Render: Render 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 20/20 [ 0:00:03 < 0:00:00 , 5 fr/s ] +Move file to destination Render: DONE Success: GIF 'd9204b9c2aec64555ca7ce48226301684737cce8b673febe86629c2e8a36ae19' in 1.498 s (depth) + 14.956 s (frames) + 4.353 s (render) @@ -1063,18 +1062,18 @@ $ poetry run tranz --no-db --palette electric --set max --set-palette sunset --n 512 × 377 Julia w/ SET 'max', 10^2.630 magnitude, CYTHON OPTIMIZED... Compute: {[JULIA: (-313420497/429687500, 6567/10000) ± (17/3125, 1/250) @ (13667/50000, 371/50000)] : [512 × 377, AUTO] : max} -Pre 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 576/576 [ 0:00:00 < 0:00:00 , 799 px/s ] +Pre 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 576/576 [ 0:00:00 < 0:00:00 , 777 px/s ] Picked depth 1000, histogram {43: 3, 44: 31, 45: 30, ...: 245, 425: 1, 431: 1, 1175: 1}, 264/576 set points -Img 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 193,024/193,024 [ 0:00:02 < 0:00:00 , 64,311 px/s ] -Compute: Julia: DONE, with precision 140 bits, 33.769 MiB, in 4.501 s +Img 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 193,024/193,024 [ 0:00:02 < 0:00:00 , 67,370 px/s ] +Compute: Julia: DONE, with precision 140 bits, 33.769 MiB, in 4.542 s -Render: {[PNG*2: ELECTRIC, SUNSET]} +Render: {[PNG*2/Lanczos: ELECTRIC, SUNSET]} Interpolating rendered frame 512 × 377 -> 1024 × 754 (*2) -Render: PNG: DONE (1024 × 754), '95a6acd116fb1ca043f089f093fb0a8c139ffb490a6a24be068fa474c8636871' in 435.756 ms -Saved to 'julia-95a6acd116fb1ca043f0.png' ('2f682ec344a556b1'), 554.419 KiB +Render: PNG: DONE (1024 × 754), 'c748e691dbbfbec2c7008cb902f608e99f11950be2f469f0231a276bc8dbf3a2' in 407.142 ms +Saved to 'julia-c748e691dbbfbec2c700.png' ('ae20f9d115f7940f'), 637.245 KiB ``` -Notice the `512 × 377 -> 1024 × 754 (*2)` showing the resolution increase. If the hash of this image changes, remember to change it in `src/tranzoom/cli/base.py`. +Notice the `512 × 377 -> 1024 × 754 (*2)` showing the resolution increase and the `{[PNG*2/Lanczos: ELECTRIC, SUNSET]}` showing the method "Lanczos". If the hash of this image changes, remember to change it in `src/tranzoom/cli/base.py`. #### Powers of 1000 diff --git a/poetry.lock b/poetry.lock index 4d10e50..1cb9efa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1015,13 +1015,13 @@ files = [ [[package]] name = "llama-cpp-python" -version = "0.3.30" +version = "0.3.31" description = "Python bindings for the llama.cpp library" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "llama_cpp_python-0.3.30.tar.gz", hash = "sha256:798c9b42652d2e0bff5fe81e7e762089f425a99e67f66ffe5ae156957876e0d1"}, + {file = "llama_cpp_python-0.3.31.tar.gz", hash = "sha256:c3fd23b2c7197770321020a89f23c6515a366511dce4fcda1e81cfb749267a94"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 6290b8d..33feabb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "tranzoom" -version = "2.0.0" # also update src/tranzoom/__init__.py +version = "2.1.0" # also update src/tranzoom/__init__.py description = "Mandelbrot/Julia Fractal generation and manipulation with AI/LLMs" license = "Apache-2.0" diff --git a/requirements.txt b/requirements.txt index 593cb64..ea07948 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ idna==3.18 ; python_version >= "3.12" and python_version < "4.0" imageio-ffmpeg==0.6.0 ; python_version >= "3.12" and python_version < "4.0" imageio==2.37.3 ; python_version >= "3.12" and python_version < "4.0" jinja2==3.1.6 ; python_version >= "3.12" and python_version < "4.0" -llama-cpp-python==0.3.30 ; python_version >= "3.12" and python_version < "4.0" +llama-cpp-python==0.3.31 ; python_version >= "3.12" and python_version < "4.0" lmstudio==1.5.0 ; python_version >= "3.12" and python_version < "4.0" 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" diff --git a/scripts/make_test_images.sh b/scripts/make_test_images.sh index 1fed15f..c5e2023 100755 --- a/scripts/make_test_images.sh +++ b/scripts/make_test_images.sh @@ -30,7 +30,7 @@ set -euxo pipefail # Render Seahorse Tail original poetry run tranz --no-db --force --set imaginary --no-date --no-hash --prefix "demo-mandel-seahorse-tail" -o tests/data/images image mandel " -0.7436499" "0.13188204" "0.00073801" # Render Animated Seahorse Tail zoom -poetry run tranz --no-db --force --no-date --no-hash --prefix "demo-mandel-seahorse-tail-anim" -o tests/data/images zoom -s 220 --mark "(-5578776469/7500000000,8244620127/62500000000)" auto " -5578776469/7500000000" "8244620127/62500000000" "0.00073801" "0.00073801" "1" --fps 5 --duration 4 --i-frames 1 +poetry run tranz --no-db --force --no-date --no-hash --prefix "demo-mandel-seahorse-tail-anim" -o tests/data/images zoom -s 220 --mark "(-5578776469/7500000000,8244620127/62500000000)" auto " -5578776469/7500000000" "8244620127/62500000000" "0.00073801" "0.00073801" "1" --fps 5 --duration 4 --i-frames 1 --inject # Render Julia Suzana Wave poetry run tranz --no-db --force --set max --no-date --no-hash --prefix "demo-julia-suzana-wave" -o tests/data/images --palette "electric" --set-palette sunset image -s 512 --i-pixels 1 julia "13667/50000" "371/50000" " -313420497/429687500" "0.6567" "0.00544" "0.004" @@ -49,14 +49,14 @@ H300="127/6100000000000000000000000000000000000000000000000000000000000000000000 # these are meant to stress the mandelbrot/julia math python/cython implementations # 1: superficial Mandelbrot zoom, with set[IMAGINARY] -poetry run tranz --no-db --force --palette "lava" --set imaginary --set-palette "toxic" --no-date --no-hash --prefix "test-mandel-z-auto-seahorse" -o tests/data/images zoom -s 53 --i-pixels 2 --resample bilinear auto " -0.7436499" "0.13188204" "227/193" "167/193" "131/43" --fps 1 --duration "31.7" --i-frames 1 +poetry run tranz --no-db --force --palette "lava" --set imaginary --set-palette "toxic" --no-date --no-hash --prefix "test-mandel-z-auto-seahorse" -o tests/data/images zoom -s 53 --i-pixels 2 --resample bilinear auto " -0.7436499" "0.13188204" "227/193" "167/193" "131/43" --fps 1 --duration "31.7" --i-frames 1 --inject # 2: ultra-deep Mandelbrot zoom, no set -poetry run tranz --no-db --force --palette "electric" --no-date --no-hash --prefix "test-mandel-z-auto-seeds300" -o tests/data/images zoom -s 31 --i-pixels 3 auto "$CX300" "$CY300" "$W300" "$H300" "43/41" --fps 1 --duration "10.1" --i-frames 1 +poetry run tranz --no-db --force --palette "electric" --no-date --no-hash --prefix "test-mandel-z-auto-seeds300" -o tests/data/images zoom -s 31 --i-pixels 3 auto "$CX300" "$CY300" "$W300" "$H300" "43/41" --fps 1 --duration "10.1" --i-frames 1 --inject # 3: ultra-deep Mandelbrot with mini-brot and set coloring # TODO: still missing a mini-brot ultra zoom so we can stress the set math # 4: julia zoom, Suzana point, with set[ANGLE] -poetry run tranz --no-db --force --palette "sahara" --set angle --set-palette "iris" --no-date --no-hash --prefix "test-julia-z-auto-suzana" -o tests/data/images zoom -s 59 -f julia --julia-re "13667/50000" --julia-im "371/50000" --i-pixels 1 auto " -313420497/429687500" "0.6567" "167/193" "227/193" "241/139" --fps 2 --duration "10.7" --i-frames 2 +poetry run tranz --no-db --force --palette "sahara" --set angle --set-palette "iris" --no-date --no-hash --prefix "test-julia-z-auto-suzana" -o tests/data/images zoom -s 59 -f julia --julia-re "13667/50000" --julia-im "371/50000" --i-pixels 1 auto " -313420497/429687500" "0.6567" "167/193" "227/193" "241/139" --fps 2 --duration "10.7" --i-frames 2 --inject # 5: julia zoom, different "Dragon" point, with set[MIN] -poetry run tranz --no-db --force --palette "lava" --set min --set-palette "electric" --no-date --no-hash --prefix "test-julia-z-auto-dragon" -o tests/data/images zoom -s 67 -f julia --julia-re " -0.11" --julia-im "0.6557" --i-pixels 1 auto "0" "0" "223/73" "281/71" "37/97" --fps 2 --duration "4.1" --i-frames 3 +poetry run tranz --no-db --force --palette "lava" --set min --set-palette "electric" --no-date --no-hash --prefix "test-julia-z-auto-dragon" -o tests/data/images zoom -s 67 -f julia --julia-re " -0.11" --julia-im "0.6557" --i-pixels 1 auto "0" "0" "223/73" "281/71" "37/97" --fps 2 --duration "4.1" --i-frames 3 --inject # 6: julia zoom, different "Blob" point, with set[MAX] -poetry run tranz --no-db --force --palette "sahara" --set max --set-palette "electric" --no-date --no-hash --prefix "test-julia-z-auto-blob" -o tests/data/images zoom -s 71 -f julia --julia-re " -0.481762" --julia-im " -0.531657" --i-pixels 1 auto "0" "0" "281/71" "223/73" "37/97" --fps 2 --duration "4.3" --i-frames 3 +poetry run tranz --no-db --force --palette "sahara" --set max --set-palette "electric" --no-date --no-hash --prefix "test-julia-z-auto-blob" -o tests/data/images zoom -s 71 -f julia --julia-re " -0.481762" --julia-im " -0.531657" --i-pixels 1 auto "0" "0" "281/71" "223/73" "37/97" --fps 2 --duration "4.3" --i-frames 3 --inject diff --git a/src/tranzoom/__init__.py b/src/tranzoom/__init__.py index 2e4771d..6d78154 100644 --- a/src/tranzoom/__init__.py +++ b/src/tranzoom/__init__.py @@ -3,7 +3,7 @@ """TranZoom: Mandelbrot/Julia Fractal generation and manipulation with AI/LLMs.""" __all__: list[str] = ['__author__', '__version__'] -__version__ = '2.0.0' # also update pyproject.toml +__version__ = '2.1.0' # also update pyproject.toml __author__ = 'Daniel Balparda , Bella Keri ' __app__ = 'tranZoom' diff --git a/src/tranzoom/cli/base.py b/src/tranzoom/cli/base.py index 3bf53b5..70b6561 100644 --- a/src/tranzoom/cli/base.py +++ b/src/tranzoom/cli/base.py @@ -8,6 +8,7 @@ import enum import logging import pathlib +import shutil import tempfile import warnings from collections import abc @@ -667,6 +668,17 @@ class CleanupOutputFormat(enum.Enum): 'so 0 is no interpolation, 1 means add 1 interpolated frame between every pair of frames, etc' ), ) +ANIM_INJECT_FINAL_HASH_OPTION: typer.models.OptionInfo = typer.Option( + False, + '--inject/--no-inject', + help=( + 'If True, will re-save the animation just to inject the final hash into the metadata; ' + 'if False, the animation metadata will be missing the final hash; default is False; ' + 'both GIF and MP4 have to be re-saved if the final hash must be present, but the process ' + 'is expensive; MP4s are done losslessly while GIFs are lossy, so this option is only useful ' + 'if for some reason (like testing) you really need the final hash in the metadata' + ), +) CONFIG_SETTABLE_KEYS: dict[str, type] = { # the keys you can actually read/set @@ -1204,7 +1216,9 @@ def ProduceFractalAnimation( # noqa: C901, PLR0912, PLR0914, PLR0915 config: TranZoomConfig, out: image.ImageOutputConfig, zoom_params: zoom.ZoomParameters, + *, save_frames: bool, + inject_hash: bool, ) -> tuple[pathlib.Path, int]: """Make the animation file, returning its path and size. @@ -1213,6 +1227,7 @@ def ProduceFractalAnimation( # noqa: C901, PLR0912, PLR0914, PLR0915 out (image.ImageOutputConfig): the image output config with all options for rendering. zoom_params (zoom.ZoomParameters): the parameters of the zoom to render. save_frames (bool): whether to save the individual frames as images in the output directory. + inject_hash (bool): whether to inject the final hash into the animation metadata. Returns: tuple[pathlib.Path, int]: A tuple of the path to the saved animation file and its size in bytes. @@ -1631,6 +1646,49 @@ def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame: idx=i, data=img_data, data_hash=data_hash, img_path=img_path ) + # create metadata; BEWARE: we don't yet have the video hash; it is impossible to have b/c + # (1) we stream the frames and (2) the video hash is computed from the hashes of the frames, + # which are only available after the video is rendered, and (3) the video saving + # methods PIL.save() and imageio.get_writer() consume the meta at frame 1 so no lazy object + # would work; for this we compute what we can and then rewrite the meta after the video + # is saved to disk with the final hash ONLY if the user really wants it; for MP4 it is + # done losslessly, for GIF it is done with some loss (PIL does not support lossless meta + # rewriting for GIF) so it should be avoided by default + meta: dict[str, str] = image.MakeImageMeta( # use destination/final frame; empty hash!! + _SmartImage(zoom_params.n_frames - 1), zoom_params.render, '' + ) + # add video-specific metadata + meta[image.META_IMAGE_ANIMATION_KEY] = zoom_params.render.anim.value.lower() + meta.update( + # the extra animation keys + { + image.META_ZOOM_TYPE_KEY: zoom_params.render.anim.value.lower(), + image.META_ZOOM_INITIAL_WIDTH_RE_KEY: str(all_frames[0].size[0]), + image.META_ZOOM_INITIAL_HEIGHT_IM_KEY: str(all_frames[0].size[1]), + image.META_ZOOM_MAGNITUDE_KEY: str(zoom_params.mag), + image.META_ZOOM_FRAMES_KEY: str(zoom_params.n_frames), + image.META_ZOOM_SECONDS_KEY: str(zoom_params.n_seconds), + image.META_ZOOM_LOOP_KEY: str(zoom_params.loop), + image.META_ZOOM_STEPS_KEY: str(zoom_params.n_steps), + image.META_ZOOM_FPS_KEY: str(zoom_params.fps), + image.META_ZOOM_I_FPS_KEY: str(zoom_params.ifps), + image.META_ZOOM_I_FRAMES_KEY: str(zoom_params.render.i_frames), + image.META_ZOOM_ALL_FRAMES_KEY: str(zoom_params.all_frames), + image.META_ZOOM_MAGNITUDE_PER_STEP_KEY: str(zoom_params.mag_per_step), + image.META_ZOOM_MAGNIFICATION_PER_STEP_KEY: str( + zoom_params.scalar_magnification_per_step + ), + image.META_ZOOM_MARKER_INDEX_LIST_KEY: str([idx for idx, _ in all_markers]), + image.META_ZOOM_DEPTH_FRAMES_LIST_KEY: str( + # we include pre- and post-smoothing depths for all depth frames + [ + (idx, depth_computations[idx][1], depth_computations[idx][2]) + for idx in sorted_depth_keys + ] + ), + image.META_ZOOM_HASH_KEY: zoom_params.sha, + } + ) # render the video to a temporary path, using the two-frame stream to interpolate frames tmp_path: pathlib.Path = ( pathlib.Path(tmpdir) / f'temp_video.{zoom_params.render.anim.value.lower()}' @@ -1652,6 +1710,7 @@ def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame: zoom_params.img.height, zoom_params.all_frames, float(zoom_params.n_seconds), + meta=meta, loop=zoom_params.loop, ) elif zoom_params.render.anim == pixels.AnimationEncoding.MP4: @@ -1662,57 +1721,31 @@ def _StreamingRenderFrame(i: int) -> zoom.RenderedZoomFrame: zoom_params.img.height, zoom_params.all_frames, float(zoom_params.n_seconds), + meta=meta, ) else: raise UsageError(f'Unsupported animation type: {zoom_params.render.anim}') + del all_img_obj # this should help free all generated images from memory finally: # we are done, close the progress bar, free memory p_bar.close() # we can finally compute the hash, which is stable if the image data and order does not change video_hash = hashes.Hash256(('|'.join(all_hash)).encode('ascii')).hex() - # create metadata - meta: dict[str, str] = image.MakeImageMeta( # use destination frame (final) as reference - _SmartImage(zoom_params.n_frames - 1), zoom_params.render, video_hash - ) - del all_img_obj # this should help free all generated images from memory - # add video-specific metadata - meta[image.META_IMAGE_ANIMATION_KEY] = zoom_params.render.anim.value.lower() - meta.update( - # the extra animation keys - { - image.META_ZOOM_TYPE_KEY: zoom_params.render.anim.value.lower(), - image.META_ZOOM_INITIAL_WIDTH_RE_KEY: str(all_frames[0].size[0]), - image.META_ZOOM_INITIAL_HEIGHT_IM_KEY: str(all_frames[0].size[1]), - image.META_ZOOM_MAGNITUDE_KEY: str(zoom_params.mag), - image.META_ZOOM_FRAMES_KEY: str(zoom_params.n_frames), - image.META_ZOOM_SECONDS_KEY: str(zoom_params.n_seconds), - image.META_ZOOM_LOOP_KEY: str(zoom_params.loop), - image.META_ZOOM_STEPS_KEY: str(zoom_params.n_steps), - image.META_ZOOM_FPS_KEY: str(zoom_params.fps), - image.META_ZOOM_I_FPS_KEY: str(zoom_params.ifps), - image.META_ZOOM_I_FRAMES_KEY: str(zoom_params.render.i_frames), - image.META_ZOOM_ALL_FRAMES_KEY: str(zoom_params.all_frames), - image.META_ZOOM_MAGNITUDE_PER_STEP_KEY: str(zoom_params.mag_per_step), - image.META_ZOOM_MAGNIFICATION_PER_STEP_KEY: str( - zoom_params.scalar_magnification_per_step - ), - image.META_ZOOM_MARKER_INDEX_LIST_KEY: str([idx for idx, _ in all_markers]), - image.META_ZOOM_DEPTH_FRAMES_LIST_KEY: str( - # we include pre- and post-smoothing depths for all depth frames - [ - (idx, depth_computations[idx][1], depth_computations[idx][2]) - for idx in sorted_depth_keys - ] - ), - image.META_ZOOM_HASH_KEY: zoom_params.sha, - } - ) - # move the file! + # move the file! we can compute the final path video_path = full_path(video_hash) - if zoom_params.render.anim == pixels.AnimationEncoding.GIF: - zoom.ReWriteAnimatedGIFMeta(tmp_path, video_path, meta) - elif zoom_params.render.anim == pixels.AnimationEncoding.MP4: - zoom.ReWriteVideoMP4Meta(tmp_path, video_path, meta) + if inject_hash: + # manually add hash to the metadata + meta[pixels.META_IMAGE_HASH_KEY] = video_hash + config.console.print(f'[red]Re-saving/Re-muxing to inject hash {video_hash!r}[/]') + # move the temporary file to the final path and rewrite the metadata with the final hash + if zoom_params.render.anim == pixels.AnimationEncoding.GIF: + zoom.ReWriteAnimatedGIFMeta(tmp_path, video_path, meta) + elif zoom_params.render.anim == pixels.AnimationEncoding.MP4: + zoom.ReWriteVideoMP4Meta(tmp_path, video_path, meta) + else: + # just instantaneous move, no meta rewriting + config.console.print('[white]Move file to destination[/]') + shutil.move(tmp_path, video_path) # closed temporary directory, video is saved in final destination with final metadata config.console.print('[yellow]Render:[/] [green]DONE[/]\n') # we just freed the temporary directory; add to DB diff --git a/src/tranzoom/cli/zoomcommand.py b/src/tranzoom/cli/zoomcommand.py index c42d767..1bba5dc 100644 --- a/src/tranzoom/cli/zoomcommand.py +++ b/src/tranzoom/cli/zoomcommand.py @@ -247,6 +247,7 @@ def Auto( # documentation is help/epilog/args # noqa: D103 i_frames: int = base.ANIM_INTERPOLATION_FRAMES_OPTION, # type: ignore[assignment] loop: int = base.ANIM_LOOP_OPTION, # type: ignore[assignment] save_frames: bool = base.ANIM_SAVE_FRAMES_OPTION, # type: ignore[assignment] + inject_hash: bool = base.ANIM_INJECT_FINAL_HASH_OPTION, # type: ignore[assignment] ) -> None: # we intend passing config, so we add the options here... config: base.TranZoomConfig = ctx.obj @@ -279,7 +280,9 @@ def Auto( # documentation is help/epilog/args # noqa: D103 # call img_p: pathlib.Path img_sz: int - img_p, img_sz = base.ProduceFractalAnimation(config, out, zoom_params, save_frames) + img_p, img_sz = base.ProduceFractalAnimation( + config, out, zoom_params, save_frames=save_frames, inject_hash=inject_hash + ) # log config.console.print( f'Saved {zoom_params.render.anim.value.upper()} to ' diff --git a/src/tranzoom/core/pixels.py b/src/tranzoom/core/pixels.py index 6853f03..8c90eae 100644 --- a/src/tranzoom/core/pixels.py +++ b/src/tranzoom/core/pixels.py @@ -1244,7 +1244,7 @@ def GetBasicDataFromMP4(img_bytes: bytes) -> ObjInfo: reader.close() # read container tags (including our JSON comment) via ffmpeg -f ffmetadata; # this is the only way to access format.tags since imageio doesn't expose them - proc = subprocess.run( # noqa: S603 + proc: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 [ imageio_ffmpeg.get_ffmpeg_exe(), '-v', @@ -1277,16 +1277,18 @@ def GetBasicDataFromMP4(img_bytes: bytes) -> ObjInfo: # try to get the data hash from the JSON metadata; if not valid JSON, log an error and ignore it try: mp4_meta = json.loads(mp4_tags['comment']) - data_hash = str(mp4_meta[META_IMAGE_HASH_KEY]) - except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError, KeyError): - logging.error('MP4 comment metadata not valid JSON, ignoring; DO NOT trust this MP4 hash') + data_hash = str(mp4_meta[META_IMAGE_HASH_KEY]).strip() + if not data_hash: + raise Error(f'empty {META_IMAGE_HASH_KEY} in MP4 metadata (missing --inject flag?)') # noqa: TRY301 + except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError, KeyError, Error) as e: + logging.error(f'MP4 comment metadata not valid, ignoring; DO NOT trust this MP4 hash: {e}') # build the ObjInfo object return ObjInfo( anim=AnimationEncoding.MP4, width=width, height=height, bin_hash=bin_hash, - data_hash=data_hash, + data_hash=data_hash.strip() or bin_hash, meta=cast('dict[str, str]', mp4_meta), ) @@ -1338,9 +1340,9 @@ def GetBasicData(img_bytes: bytes) -> tuple[ObjInfo, Pixels | None]: except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError, KeyError) as err: logging.error(f'GIF "comment" metadata is not valid, ignoring: {err}') data_hash: str - if META_IMAGE_HASH_KEY in meta: + if META_IMAGE_HASH_KEY in meta and meta[META_IMAGE_HASH_KEY].strip(): # this is the happy path: the GIF has a valid hash in its metadata, so we can trust it - data_hash = meta[META_IMAGE_HASH_KEY] + data_hash = meta[META_IMAGE_HASH_KEY].strip() else: data_hash = hashes.Hash256(img.convert('RGB').tobytes()).hex() logging.error(f'GIF does not have a tranZoom image hash; DO NOT TRUST this hash: {data_hash}') diff --git a/src/tranzoom/core/zoom.py b/src/tranzoom/core/zoom.py index 6a589c0..4d791d0 100644 --- a/src/tranzoom/core/zoom.py +++ b/src/tranzoom/core/zoom.py @@ -11,11 +11,13 @@ import logging import math import pathlib +import subprocess # noqa: S404 from collections import abc from typing import cast import gmpy2 import imageio +import imageio_ffmpeg # type: ignore import numpy as np from numpy.typing import NDArray from PIL import Image as PILImage @@ -1270,39 +1272,48 @@ def WriteVideoMP4( def ReWriteVideoMP4Meta( - old_path: pathlib.Path, new_path: pathlib.Path, meta: dict[str, str] | None + old_path: pathlib.Path, + new_path: pathlib.Path, + meta: dict[str, str] | None, ) -> None: """Read old_path MP4 and re-write to new_path with the same frames and new metadata. - We more-or-less assume the file was written with WriteVideoMP4(). + We more-or-less assume the file was written with WriteVideoMP4(). We call external ffmpeg to + re-mux the file with new metadata, which is more efficient than re-encoding and is lossless. Args: old_path (pathlib.Path): The file path of the original MP4 to read. new_path (pathlib.Path): The file path to save the modified MP4. meta (dict[str, str] | None): Optional metadata to include in the new MP4; default None + Raises: + Error: on error + """ - # open the original MP4 and read the fps from its metadata - reader = imageio.get_reader(old_path, format='ffmpeg') # type: ignore[arg-type] - fps: float = float(reader.get_meta_data().get('fps', 25.0)) - # prepare metadata output params, same settings as WriteVideoMP4 - output_params: list[str] = [] - output_params.extend(['-movflags', '+faststart']) # allows start playing before fully downloaded - output_params.extend(['-crf', '16']) # good quality, lower is better - output_params.extend(['-preset', 'slow']) # slower presets give better compression + # prepare ffmpeg command + cmd: list[str] = [ + imageio_ffmpeg.get_ffmpeg_exe(), + '-y', + '-i', + str(old_path), + '-map', + '0', + '-c', + 'copy', + '-map_metadata', + '0', + ] + # either add the metadata (if any) or explicitly clear the comment while preserving other metadata if meta: - # store all metadata as a single JSON string in the 'comment' field (mirrors WriteVideoMP4) - output_params.extend(['-metadata', f'comment={json.dumps(meta)}']) - # stream frames from reader directly into writer (no full in-memory buffering) - with imageio.get_writer( # pyright: ignore[reportUnknownMemberType] - new_path, - fps=fps, - format='ffmpeg', # type: ignore[arg-type] - codec='libx264', - pixelformat='yuv420p', - macro_block_size=1, - output_params=output_params, - ) as writer: - for frm in reader: # type: ignore[attr-defined] - writer.append_data(frm) # type: ignore[attr-defined] - reader.close() + cmd.extend(['-metadata', f'comment={json.dumps(meta)}']) + else: + cmd.extend(['-metadata', 'comment=']) + # add faststart to allow playback before fully downloaded + cmd.extend(['-movflags', '+faststart', str(new_path)]) # destination path must be last argument + # run ffmpeg command + proc: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 + cmd, capture_output=True, text=True, check=False + ) + # check return code and raise error if ffmpeg failed + if proc.returncode != 0: + raise Error(f'ffmpeg metadata re-mux failed:\n{proc.stderr}') diff --git a/tests/data/images/demo-mandel-seahorse-tail-video.mp4 b/tests/data/images/demo-mandel-seahorse-tail-video.mp4 index a4f81de..a0c07d9 100644 Binary files a/tests/data/images/demo-mandel-seahorse-tail-video.mp4 and b/tests/data/images/demo-mandel-seahorse-tail-video.mp4 differ diff --git a/tests_integration/test_cython_equivalence.py b/tests_integration/test_cython_equivalence.py index 55df97c..5a22df0 100644 --- a/tests_integration/test_cython_equivalence.py +++ b/tests_integration/test_cython_equivalence.py @@ -112,6 +112,7 @@ def test_python_cython_equivalence_seahorse(cli: pathlib.Path, opt: str) -> None '31.7', '--i-frames', '1', + '--inject', ] ) assert r.returncode == 0, f'tranz image failed:\n{r.stderr}' @@ -314,6 +315,7 @@ def test_python_cython_equivalence_seeds300(cli: pathlib.Path, opt: str) -> None '10.1', '--i-frames', '1', + '--inject', ] ) assert r.returncode == 0, f'tranz image failed:\n{r.stderr}' @@ -620,6 +622,7 @@ def test_python_cython_equivalence_suzana(cli: pathlib.Path, opt: str) -> None: '10.7', '--i-frames', '2', + '--inject', ] ) assert r.returncode == 0, f'tranz image failed:\n{r.stderr}' @@ -800,6 +803,7 @@ def test_python_cython_equivalence_dragon(cli: pathlib.Path, opt: str) -> None: '4.1', '--i-frames', '3', + '--inject', ] ) assert r.returncode == 0, f'tranz image failed:\n{r.stderr}' @@ -978,6 +982,7 @@ def test_python_cython_equivalence_blob(cli: pathlib.Path, opt: str) -> None: '4.3', '--i-frames', '3', + '--inject', ] ) assert r.returncode == 0, f'tranz image failed:\n{r.stderr}' diff --git a/tests_integration/test_installed_cli.py b/tests_integration/test_installed_cli.py index 010cf83..961f6f6 100644 --- a/tests_integration/test_installed_cli.py +++ b/tests_integration/test_installed_cli.py @@ -208,6 +208,7 @@ def test_animated_seahorse_tail(cli: pathlib.Path) -> None: '4', '--i-frames', '1', + '--inject', ] ) assert r.returncode == 0, f'tranz zoom auto failed:\n{r.stderr}' diff --git a/tranz.md b/tranz.md index 9efd134..47a0e66 100644 --- a/tranz.md +++ b/tranz.md @@ -1070,6 +1070,23 @@ Usage: tranz zoom auto [OPTIONS] [CENTER_RE] [CENTER_IM] [F_WIDTH] [F_HEIGHT] │ intermediate frames will not │ │ be saved; default is False │ │ │ +│ --inject --no-inject If True, will re-save the │ +│ animation just to inject the │ +│ final hash into the metadata; │ +│ if False, the animation │ +│ metadata will be missing the │ +│ final hash; default is False; │ +│ both GIF and MP4 have to be │ +│ re-saved if the final hash │ +│ must be present, but the │ +│ process is expensive; MP4s │ +│ are done losslessly while │ +│ GIFs are lossy, so this │ +│ option is only useful if for │ +│ some reason (like testing) │ +│ you really need the final │ +│ hash in the metadata │ +│ │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯