burns 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ *.ipynb linguist-documentation
@@ -0,0 +1,48 @@
1
+ # wads CI — calls the reusable workflow hosted in i2mint/wads.
2
+ #
3
+ # All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
4
+ # To customize the workflow itself (rare), replace this file with the
5
+ # full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
6
+ #
7
+ # Pinning: `@master` floats with wads. If you need version stability for
8
+ # a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`).
9
+ # CI failure does not block a published release — it blocks the publish
10
+ # step itself — so floating master is generally safe.
11
+ #
12
+ # Permissions: GitHub validates that the caller grants AT LEAST the
13
+ # permissions any job in the called workflow requests — at workflow-parse
14
+ # time, not at run-time, even if the job would be skipped via `if:`.
15
+ # The reusable workflow needs:
16
+ # contents: write for the publish job's version-bump push-back
17
+ # and for the github-pages job's gh-pages branch push
18
+ # pages: write for the github-pages job's REST API Pages config
19
+ # Both default to `write` on org-account GITHUB_TOKEN and need to be
20
+ # granted explicitly on personal-account callers (where the default is
21
+ # read-only). No `id-token: write` needed — the publish-github-pages
22
+ # action uses peaceiris/actions-gh-pages (branch-based) + REST API,
23
+ # not the OIDC `actions/deploy-pages` flow.
24
+ name: Continuous Integration
25
+ on: [push, pull_request]
26
+ jobs:
27
+ ci:
28
+ uses: i2mint/wads/.github/workflows/uv-ci.yml@master
29
+ permissions:
30
+ contents: write
31
+ pages: write
32
+ # Explicit pass-through (not `secrets: inherit`) because `inherit` does
33
+ # not reliably propagate caller-repo secrets to a reusable workflow
34
+ # owned by a different account (verified empirically: caller in a
35
+ # personal account, called in i2mint org → `${{ secrets.PYPI_PASSWORD }}`
36
+ # resolved to empty inside the reusable workflow despite the secret
37
+ # being set on the caller repo). Listing each secret here makes the
38
+ # propagation unambiguous regardless of caller-vs-called ownership.
39
+ # Missing secrets on the caller resolve to empty strings, harmless for
40
+ # the optional ones; PYPI_PASSWORD must be set for the publish job.
41
+ secrets:
42
+ PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
43
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
44
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
45
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
46
+ HUGGINGFACE_TOKEN: ${{ secrets.HUGGINGFACE_TOKEN }}
47
+ KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
48
+ KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
burns-0.0.0/.gitignore ADDED
@@ -0,0 +1,120 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+ docs/*
81
+
82
+ # PyBuilder
83
+ target/
84
+
85
+ # Jupyter Notebook
86
+ .ipynb_checkpoints
87
+
88
+ # pyenv
89
+ .python-version
90
+
91
+ # celery beat schedule file
92
+ celerybeat-schedule
93
+
94
+ # SageMath parsed files
95
+ *.sage.py
96
+
97
+ # Environments
98
+ .env
99
+ .venv
100
+ env/
101
+ venv/
102
+ ENV/
103
+ env.bak/
104
+ venv.bak/
105
+
106
+ # Spyder project settings
107
+ .spyderproject
108
+ .spyproject
109
+
110
+ # Rope project settings
111
+ .ropeproject
112
+
113
+ # mkdocs documentation
114
+ /site
115
+
116
+ # mypy
117
+ .mypy_cache/
118
+
119
+ # PyCharm
120
+ .idea
burns-0.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
burns-0.0.0/PKG-INFO ADDED
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: burns
3
+ Version: 0.0.0
4
+ Summary: Ken Burns pan/zoom video effects: turn a still image (or a sequence of stills) into a cinematic pan/zoom film.
5
+ Project-URL: Homepage, https://github.com/thorwhalen/burns
6
+ Project-URL: Repository, https://github.com/thorwhalen/burns
7
+ Project-URL: Documentation, https://thorwhalen.github.io/burns
8
+ Author: Thor Whalen
9
+ License: mit
10
+ License-File: LICENSE
11
+ Keywords: animation,ken-burns,moviepy,pan-zoom,slideshow,video
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: moviepy
14
+ Requires-Dist: numpy
15
+ Requires-Dist: pillow
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
20
+ Provides-Extra: docs
21
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
22
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # burns
26
+
27
+ Ken Burns pan/zoom video effects: turn a still image — or a sequence of stills —
28
+ into a cinematic pan/zoom film.
29
+
30
+ The [Ken Burns effect](https://en.wikipedia.org/wiki/Ken_Burns_effect) animates a
31
+ static photograph by slowly panning across it and zooming in or out, giving still
32
+ images a sense of motion. `burns` does exactly that, with a tiny API and no
33
+ configuration required.
34
+
35
+ ```bash
36
+ pip install burns
37
+ ```
38
+
39
+ `burns` needs `ffmpeg` available on your system (moviepy uses it to encode video).
40
+ On macOS: `brew install ffmpeg`. On Debian/Ubuntu: `sudo apt-get install ffmpeg`.
41
+
42
+ ## Quickstart
43
+
44
+ A standard 2-second push-in, written next to the source image:
45
+
46
+ ```python
47
+ from burns import ken_burns_video
48
+
49
+ ken_burns_video("photo.jpg") # → photo_kenburns.mp4
50
+ ```
51
+
52
+ That's it. The result is an mp4 that slowly zooms into the center of `photo.jpg`.
53
+
54
+ ## A little more control
55
+
56
+ The camera path is a list of **phases**, each `(start_rect, end_rect, duration_s)`.
57
+ A *rect* is `(cx, cy, s)` — a pan center `(cx, cy)` in `[0, 1]` image units and a
58
+ zoom scale `s` (`1.0` = the full frame, `> 1.0` = zoomed in). The camera moves
59
+ linearly from `start_rect` to `end_rect` over `duration_s` seconds; phases play
60
+ back-to-back.
61
+
62
+ ```python
63
+ from burns import ken_burns_video
64
+
65
+ # Pan from the full frame toward the upper-right while zooming in, over 5s.
66
+ ken_burns_video(
67
+ "photo.jpg",
68
+ phases=[((0.5, 0.5, 1.0), (0.65, 0.40, 1.2), 5.0)],
69
+ saveas="out.mp4",
70
+ )
71
+
72
+ # A three-phase move: zoom in, pan across, settle back to center.
73
+ ken_burns_video(
74
+ "photo.jpg",
75
+ phases=[
76
+ ((0.5, 0.5, 1.0), (0.65, 0.40, 1.2), 4.0),
77
+ ((0.65, 0.40, 1.2), (0.35, 0.60, 1.2), 4.0),
78
+ ((0.35, 0.60, 1.2), (0.5, 0.5, 1.3), 4.0),
79
+ ],
80
+ )
81
+ ```
82
+
83
+ Rects accept flexible shorthand: a bare number is a centered zoom (`1.3`), a pair
84
+ is a pan center at full scale (`(0.3, 0.7)`), a triple is the full spec.
85
+
86
+ ## Let `burns` design the motion for you
87
+
88
+ Hand-authoring rectangles for every image gets tedious. `ken_burns_path` generates
89
+ a cohesive, **deterministic, non-repetitive** path from a few intent parameters —
90
+ pass the image's position (`index`) and a duration, and it picks the framing:
91
+
92
+ ```python
93
+ from burns import ken_burns_video, ken_burns_path
94
+
95
+ # index seeds the focal direction; odd indices push in, even pull out.
96
+ ken_burns_video("photo.jpg", phases=ken_burns_path(1, 5.0))
97
+
98
+ # styles: "push" (zoom-led, the default) or "drift" (pure horizontal pan)
99
+ ken_burns_video("photo.jpg", phases=ken_burns_path(2, 5.0, style="drift"))
100
+
101
+ # ease=True replaces constant velocity with a slow-fast-slow curve
102
+ ken_burns_video("photo.jpg", phases=ken_burns_path(1, 6.0, ease=True))
103
+ ```
104
+
105
+ ## Multi-image films
106
+
107
+ `ken_burns_film` renders a sequence of `(image, phases)` panels as **one
108
+ continuous film** — a single encode pass, so there are no concatenation seams and
109
+ no per-image freeze frames at the cuts. Pass an optional pre-built audio track to
110
+ mux it in.
111
+
112
+ ```python
113
+ from burns import ken_burns_film, ken_burns_path
114
+
115
+ panels = [
116
+ ("a.jpg", ken_burns_path(1, 4.0)),
117
+ ("b.jpg", ken_burns_path(2, 4.0)),
118
+ ("c.jpg", ken_burns_path(3, 4.0)),
119
+ ]
120
+ ken_burns_film(panels, saveas="film.mp4", fps=30, audio_path="narration.mp3")
121
+ ```
122
+
123
+ ## API
124
+
125
+ | Function | What it does |
126
+ |----------|--------------|
127
+ | `ken_burns_video(image, *, phases=..., fps=30, saveas=None, ...)` | Render one image into a multi-phase pan/zoom mp4. Accepts a path, a `PIL.Image`, or a numpy array. |
128
+ | `ken_burns_film(panels, *, saveas, fps=30, audio_path=None, ...)` | Render a sequence of `(image, phases)` panels as one continuous film, with optional audio. |
129
+ | `ken_burns_path(index, duration_s, *, style="push", zoom=1.10, pan=0.03, ease=False)` | Generate a deterministic pan/zoom path (the `phases` the renderers consume). |
130
+
131
+ All rectangles are `(cx, cy, s)` — pan center in `[0, 1]`, zoom scale (`1.0` = full
132
+ frame). Because the crop box is clamped to the image, you cannot zoom out past the
133
+ original; express a zoom-out as a start `s > 1` panning to an end `s = 1`.
burns-0.0.0/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # burns
2
+
3
+ Ken Burns pan/zoom video effects: turn a still image — or a sequence of stills —
4
+ into a cinematic pan/zoom film.
5
+
6
+ The [Ken Burns effect](https://en.wikipedia.org/wiki/Ken_Burns_effect) animates a
7
+ static photograph by slowly panning across it and zooming in or out, giving still
8
+ images a sense of motion. `burns` does exactly that, with a tiny API and no
9
+ configuration required.
10
+
11
+ ```bash
12
+ pip install burns
13
+ ```
14
+
15
+ `burns` needs `ffmpeg` available on your system (moviepy uses it to encode video).
16
+ On macOS: `brew install ffmpeg`. On Debian/Ubuntu: `sudo apt-get install ffmpeg`.
17
+
18
+ ## Quickstart
19
+
20
+ A standard 2-second push-in, written next to the source image:
21
+
22
+ ```python
23
+ from burns import ken_burns_video
24
+
25
+ ken_burns_video("photo.jpg") # → photo_kenburns.mp4
26
+ ```
27
+
28
+ That's it. The result is an mp4 that slowly zooms into the center of `photo.jpg`.
29
+
30
+ ## A little more control
31
+
32
+ The camera path is a list of **phases**, each `(start_rect, end_rect, duration_s)`.
33
+ A *rect* is `(cx, cy, s)` — a pan center `(cx, cy)` in `[0, 1]` image units and a
34
+ zoom scale `s` (`1.0` = the full frame, `> 1.0` = zoomed in). The camera moves
35
+ linearly from `start_rect` to `end_rect` over `duration_s` seconds; phases play
36
+ back-to-back.
37
+
38
+ ```python
39
+ from burns import ken_burns_video
40
+
41
+ # Pan from the full frame toward the upper-right while zooming in, over 5s.
42
+ ken_burns_video(
43
+ "photo.jpg",
44
+ phases=[((0.5, 0.5, 1.0), (0.65, 0.40, 1.2), 5.0)],
45
+ saveas="out.mp4",
46
+ )
47
+
48
+ # A three-phase move: zoom in, pan across, settle back to center.
49
+ ken_burns_video(
50
+ "photo.jpg",
51
+ phases=[
52
+ ((0.5, 0.5, 1.0), (0.65, 0.40, 1.2), 4.0),
53
+ ((0.65, 0.40, 1.2), (0.35, 0.60, 1.2), 4.0),
54
+ ((0.35, 0.60, 1.2), (0.5, 0.5, 1.3), 4.0),
55
+ ],
56
+ )
57
+ ```
58
+
59
+ Rects accept flexible shorthand: a bare number is a centered zoom (`1.3`), a pair
60
+ is a pan center at full scale (`(0.3, 0.7)`), a triple is the full spec.
61
+
62
+ ## Let `burns` design the motion for you
63
+
64
+ Hand-authoring rectangles for every image gets tedious. `ken_burns_path` generates
65
+ a cohesive, **deterministic, non-repetitive** path from a few intent parameters —
66
+ pass the image's position (`index`) and a duration, and it picks the framing:
67
+
68
+ ```python
69
+ from burns import ken_burns_video, ken_burns_path
70
+
71
+ # index seeds the focal direction; odd indices push in, even pull out.
72
+ ken_burns_video("photo.jpg", phases=ken_burns_path(1, 5.0))
73
+
74
+ # styles: "push" (zoom-led, the default) or "drift" (pure horizontal pan)
75
+ ken_burns_video("photo.jpg", phases=ken_burns_path(2, 5.0, style="drift"))
76
+
77
+ # ease=True replaces constant velocity with a slow-fast-slow curve
78
+ ken_burns_video("photo.jpg", phases=ken_burns_path(1, 6.0, ease=True))
79
+ ```
80
+
81
+ ## Multi-image films
82
+
83
+ `ken_burns_film` renders a sequence of `(image, phases)` panels as **one
84
+ continuous film** — a single encode pass, so there are no concatenation seams and
85
+ no per-image freeze frames at the cuts. Pass an optional pre-built audio track to
86
+ mux it in.
87
+
88
+ ```python
89
+ from burns import ken_burns_film, ken_burns_path
90
+
91
+ panels = [
92
+ ("a.jpg", ken_burns_path(1, 4.0)),
93
+ ("b.jpg", ken_burns_path(2, 4.0)),
94
+ ("c.jpg", ken_burns_path(3, 4.0)),
95
+ ]
96
+ ken_burns_film(panels, saveas="film.mp4", fps=30, audio_path="narration.mp3")
97
+ ```
98
+
99
+ ## API
100
+
101
+ | Function | What it does |
102
+ |----------|--------------|
103
+ | `ken_burns_video(image, *, phases=..., fps=30, saveas=None, ...)` | Render one image into a multi-phase pan/zoom mp4. Accepts a path, a `PIL.Image`, or a numpy array. |
104
+ | `ken_burns_film(panels, *, saveas, fps=30, audio_path=None, ...)` | Render a sequence of `(image, phases)` panels as one continuous film, with optional audio. |
105
+ | `ken_burns_path(index, duration_s, *, style="push", zoom=1.10, pan=0.03, ease=False)` | Generate a deterministic pan/zoom path (the `phases` the renderers consume). |
106
+
107
+ All rectangles are `(cx, cy, s)` — pan center in `[0, 1]`, zoom scale (`1.0` = full
108
+ frame). Because the crop box is clamped to the image, you cannot zoom out past the
109
+ original; express a zoom-out as a start `s > 1` panning to an end `s = 1`.
@@ -0,0 +1,48 @@
1
+ """burns — Ken Burns pan/zoom video effects.
2
+
3
+ Turn a still image (or a sequence of stills) into a cinematic pan/zoom film.
4
+
5
+ Three building blocks:
6
+
7
+ - :func:`ken_burns_video` — render one image into a multi-phase pan/zoom mp4.
8
+ - :func:`ken_burns_film` — render a sequence of ``(image, phases)`` panels as
9
+ one continuous film (no concat seams, no per-panel freezes), with optional
10
+ audio.
11
+ - :func:`ken_burns_path` — generate a cohesive, non-repetitive pan/zoom *path*
12
+ (the ``phases`` the renderers consume) from a few intent parameters.
13
+
14
+ Quickstart:
15
+
16
+ >>> from burns import ken_burns_video, ken_burns_path
17
+ >>> ken_burns_video("photo.jpg") # standard 2s push-in # doctest: +SKIP
18
+ >>> ken_burns_video( # path generated per index/style # doctest: +SKIP
19
+ ... "photo.jpg", phases=ken_burns_path(1, 5.0, style="push", ease=True)
20
+ ... )
21
+
22
+ Rectangles everywhere are ``(cx, cy, s)`` — pan center in ``[0, 1]`` and zoom
23
+ scale (``1.0`` = full frame). See :func:`ken_burns_video` for the full spec.
24
+ """
25
+
26
+ from burns.render import (
27
+ ken_burns_video,
28
+ ken_burns_film,
29
+ DEFAULT_KENBURNS_PHASES,
30
+ )
31
+ from burns.paths import (
32
+ ken_burns_path,
33
+ KenBurnsRect,
34
+ KenBurnsPhase,
35
+ KenBurnsPath,
36
+ PanelInput,
37
+ )
38
+
39
+ __all__ = [
40
+ "ken_burns_video",
41
+ "ken_burns_film",
42
+ "ken_burns_path",
43
+ "DEFAULT_KENBURNS_PHASES",
44
+ "KenBurnsRect",
45
+ "KenBurnsPhase",
46
+ "KenBurnsPath",
47
+ "PanelInput",
48
+ ]
@@ -0,0 +1,78 @@
1
+ """Internal helpers for :mod:`burns` — output-path resolution and
2
+ collision-safe naming.
3
+
4
+ These are deliberately self-contained so ``burns`` carries no dependency
5
+ beyond ``numpy`` / ``moviepy`` / ``pillow``.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Container
10
+
11
+
12
+ def _auto_video_path(src_path: str, suffix: str, *, ext: str | None = None) -> Path:
13
+ """Generate an output video path by appending ``suffix`` to the stem.
14
+
15
+ Args:
16
+ src_path: Source file path (image or video).
17
+ suffix: Suffix to add to the stem (e.g. ``"_kenburns"``).
18
+ ext: Optional extension override (e.g. ``".mp4"``).
19
+
20
+ Returns:
21
+ Path with format ``{stem}{suffix}{ext}``.
22
+
23
+ Examples:
24
+ >>> str(_auto_video_path("photo.jpg", "_kenburns", ext=".mp4"))
25
+ 'photo_kenburns.mp4'
26
+ """
27
+ src = Path(src_path)
28
+ output = src.with_stem(f"{src.stem}{suffix}")
29
+ if ext:
30
+ output = output.with_suffix(ext)
31
+ return output
32
+
33
+
34
+ def _ensure_output_path(path: str | Path) -> Path:
35
+ """Convert to :class:`~pathlib.Path` and ensure the parent directory exists.
36
+
37
+ Examples:
38
+ >>> import tempfile
39
+ >>> from pathlib import Path
40
+ >>> tmp = Path(tempfile.mkdtemp())
41
+ >>> out = _ensure_output_path(tmp / "sub" / "film.mp4")
42
+ >>> out.parent.exists()
43
+ True
44
+ """
45
+ path = Path(path)
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ return path
48
+
49
+
50
+ def _non_colliding_key(
51
+ key: str, exclude: Container[str], *, max_attempts: int = 10000
52
+ ) -> str:
53
+ """Return a filename not present in ``exclude``.
54
+
55
+ If ``key`` is already unique it is returned as-is; otherwise a
56
+ ``" (N)"`` suffix is inserted before the extension until a free name is
57
+ found. Mirrors ``dol.non_colliding_key``'s default string behavior so
58
+ auto-named renders never silently overwrite an existing file.
59
+
60
+ Examples:
61
+ >>> _non_colliding_key("film.mp4", set())
62
+ 'film.mp4'
63
+ >>> _non_colliding_key("film.mp4", {"film.mp4"})
64
+ 'film (1).mp4'
65
+ >>> _non_colliding_key("film.mp4", {"film.mp4", "film (1).mp4"})
66
+ 'film (2).mp4'
67
+ """
68
+ if key not in exclude:
69
+ return key
70
+ p = Path(key)
71
+ stem, suffix = p.stem, p.suffix
72
+ for attempt in range(1, max_attempts + 1):
73
+ candidate = f"{stem} ({attempt}){suffix}"
74
+ if candidate not in exclude:
75
+ return candidate
76
+ raise ValueError(
77
+ f"_non_colliding_key: no free name for {key!r} within {max_attempts} attempts"
78
+ )
@@ -0,0 +1,180 @@
1
+ """Deterministic Ken Burns pan/zoom **paths**.
2
+
3
+ A *path* is a list of ``(start_rect, end_rect, duration_s)`` phases describing
4
+ how the virtual camera moves over a single still image. :func:`ken_burns_path`
5
+ builds such a path from a small set of intent parameters (style, zoom, pan,
6
+ ease) so a sequence of images gets cohesive, non-repetitive motion without the
7
+ caller hand-authoring rectangles.
8
+
9
+ Rectangles are ``(cx, cy, s)`` — pan center ``(cx, cy)`` in ``[0, 1]`` image
10
+ units and zoom scale ``s`` (``1.0`` = full frame, ``> 1.0`` = zoomed in). This
11
+ is the same rectangle parameterization the renderers in :mod:`burns.render`
12
+ consume, so a path drops straight into :func:`burns.ken_burns_video` (as its
13
+ ``phases``) or :func:`burns.ken_burns_film` (as a panel's phases).
14
+
15
+ The functions here are **pure** — they map intent to geometry with no I/O — so
16
+ they are cheap to unit-test and produce identical paths for identical inputs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+
23
+ # A single (start_rect, end_rect, duration_s) segment of a path.
24
+ # Rect is (cx, cy, s) — pan center in [0, 1] and zoom scale (1.0 = full).
25
+ KenBurnsRect = tuple[float, float, float]
26
+ KenBurnsPhase = tuple[KenBurnsRect, KenBurnsRect, float]
27
+ KenBurnsPath = list[KenBurnsPhase]
28
+
29
+ # A single panel input to a film renderer: image to animate + its pan/zoom
30
+ # path. Audio, when present, is supplied separately as one combined track for
31
+ # the whole film, so the renderer stays pure visual.
32
+ from pathlib import Path # noqa: E402 (kept next to the alias that uses it)
33
+
34
+ PanelInput = tuple[Path, KenBurnsPath]
35
+
36
+
37
+ _EASE_PHASES: tuple[tuple[float, float], ...] = (
38
+ (0.25, 0.10), # slow start: 25% of time covers 10% of motion
39
+ (0.50, 0.70), # mid: 50% of time covers 70% of motion
40
+ (0.25, 0.20), # slow end: 25% of time covers 20% of motion
41
+ )
42
+ """Three (time_fraction, motion_fraction) phases approximating a quadratic
43
+ ease. Slow-fast-slow — preserves single-direction motion while losing the
44
+ "robotic" constant-velocity feel."""
45
+
46
+
47
+ _VALID_STYLES = ("push", "drift")
48
+
49
+
50
+ def ken_burns_path(
51
+ index: int,
52
+ duration_s: float,
53
+ *,
54
+ style: str = "push",
55
+ zoom: float = 1.10,
56
+ pan: float = 0.03,
57
+ ease: bool = False,
58
+ ) -> KenBurnsPath:
59
+ """A deterministic pan/zoom path for one image.
60
+
61
+ Two named styles, opt-in ease curve:
62
+
63
+ - ``style="push"`` (default) — the "cinematic push". One slow zoom
64
+ (dominant motion) plus a subtle pan toward an off-center focal
65
+ point. Odd indices push *in*, even indices pull *out* — a sequence
66
+ of images has visual rhythm without changing direction *within*
67
+ a shot.
68
+
69
+ - ``style="drift"`` — pure horizontal pan, no zoom variance,
70
+ alternating direction per index. Useful for museum-style sequences
71
+ where the dominant motion should be pan, not zoom.
72
+
73
+ - ``ease=True`` — splits the single-phase motion into three phases
74
+ (slow-start, mid, slow-end) approximating a quadratic ease.
75
+ Velocity changes; direction and total magnitude do not.
76
+
77
+ Per-index deterministic: same args always return the same path.
78
+
79
+ Args:
80
+ index: the image's 1-based position in the sequence (seeds the
81
+ focal-point direction and the push-in/pull-out alternation).
82
+ duration_s: total time the path should cover, in seconds.
83
+ style: ``"push"`` (default) or ``"drift"``.
84
+ zoom: the zoomed-end scale (> 1.0). Ignored for ``"drift"`` —
85
+ drift uses a single constant zoom end-to-end (currently 1.0).
86
+ pan: how far the framing drifts off-center, in [0, 1] image
87
+ units. For ``"push"`` it controls focal-point offset; for
88
+ ``"drift"`` it is the total horizontal distance covered.
89
+ ease: when True, split the single phase into the slow-fast-slow
90
+ 3-phase ease curve.
91
+
92
+ Returns:
93
+ A :data:`KenBurnsPath` — a list of ``(start_rect, end_rect,
94
+ duration_s)`` phases whose durations sum to ``duration_s``.
95
+
96
+ Examples:
97
+ >>> ken_burns_path(1, 5.0)
98
+ [((0.5, 0.5, 1.0), (0.4788, 0.5212, 1.1), 5.0)]
99
+ >>> ken_burns_path(2, 5.0)[0][2] # one phase, full duration
100
+ 5.0
101
+ >>> len(ken_burns_path(1, 6.0, ease=True)) # slow-fast-slow
102
+ 3
103
+ """
104
+ if duration_s <= 0:
105
+ raise ValueError(f"ken_burns_path: duration_s must be > 0, got {duration_s}")
106
+ if style not in _VALID_STYLES:
107
+ raise ValueError(
108
+ f"ken_burns_path: style must be one of {_VALID_STYLES}, got {style!r}"
109
+ )
110
+
111
+ start, end = _endpoints_for_style(style, index, zoom=zoom, pan=pan)
112
+ if not ease:
113
+ return [(start, end, duration_s)]
114
+ return _split_with_ease(start, end, duration_s)
115
+
116
+
117
+ def _endpoints_for_style(
118
+ style: str, index: int, *, zoom: float, pan: float
119
+ ) -> tuple[KenBurnsRect, KenBurnsRect]:
120
+ """Return the ``(start_rect, end_rect)`` for one image under one style."""
121
+ if style == "push":
122
+ # The focal-point direction cycles through compass octants per index —
123
+ # each image zooms toward (or pulls back from) a different corner.
124
+ angle = (index * 2 + 1) * (math.pi / 4)
125
+ off_cx = round(0.5 + pan * math.cos(angle), 4)
126
+ off_cy = round(0.5 + pan * math.sin(angle), 4)
127
+ center: KenBurnsRect = (0.5, 0.5, 1.0)
128
+ offset: KenBurnsRect = (off_cx, off_cy, zoom)
129
+ if index % 2 == 1:
130
+ return center, offset # push in
131
+ return offset, center # pull out
132
+
133
+ # style == "drift": purely horizontal pan, constant zoom.
134
+ # Alternate direction per index — odd drifts right, even drifts left.
135
+ direction = 1 if index % 2 == 1 else -1
136
+ # The drift distance scales with `pan`; we use a wider default offset
137
+ # than the push style because there is no zoom rhythm to share the
138
+ # frame budget with. A `pan` of 0.03 gives a noticeable but not
139
+ # frenetic horizontal slide; the renderer's clamp handles the edge.
140
+ half = pan
141
+ start_cx = round(0.5 - direction * half, 4)
142
+ end_cx = round(0.5 + direction * half, 4)
143
+ # A flat (no-variance) zoom keeps the dominant motion lateral; we use
144
+ # 1.0 so the renderer never crops more than the image's full frame —
145
+ # the eye reads a pure pan and the off-center crop window slides.
146
+ drift_zoom = 1.0
147
+ return (
148
+ (start_cx, 0.5, drift_zoom),
149
+ (end_cx, 0.5, drift_zoom),
150
+ )
151
+
152
+
153
+ def _split_with_ease(
154
+ start: KenBurnsRect, end: KenBurnsRect, duration_s: float
155
+ ) -> KenBurnsPath:
156
+ """Break ``[start → end]`` into the slow-fast-slow 3-phase ease curve.
157
+
158
+ The straight-line motion is identical; only the *velocity* changes
159
+ along the way. Each phase's time fraction and motion fraction come
160
+ from :data:`_EASE_PHASES`. The chain is continuous — phase N+1
161
+ starts exactly where phase N ended.
162
+ """
163
+ phases: KenBurnsPath = []
164
+ cumulative_motion = 0.0
165
+ last_point = start
166
+ for time_frac, motion_frac in _EASE_PHASES:
167
+ cumulative_motion += motion_frac
168
+ next_point = _interp_rect(start, end, cumulative_motion)
169
+ phases.append((last_point, next_point, duration_s * time_frac))
170
+ last_point = next_point
171
+ return phases
172
+
173
+
174
+ def _interp_rect(a: KenBurnsRect, b: KenBurnsRect, t: float) -> KenBurnsRect:
175
+ """Linear interpolation between two ``(cx, cy, s)`` rectangles."""
176
+ return (
177
+ round(a[0] + (b[0] - a[0]) * t, 6),
178
+ round(a[1] + (b[1] - a[1]) * t, 6),
179
+ round(a[2] + (b[2] - a[2]) * t, 6),
180
+ )
@@ -0,0 +1,446 @@
1
+ """Ken Burns **renderers** — turn still images into pan/zoom video.
2
+
3
+ Two entry points, both backed by a lazy ``moviepy.VideoClip`` so the whole
4
+ clip is never materialised in memory:
5
+
6
+ - :func:`ken_burns_video` — one image, a multi-phase pan/zoom, one mp4.
7
+ - :func:`ken_burns_film` — a sequence of ``(image, phases)`` panels rendered
8
+ as a **single** continuous film (no per-panel intermediate files, no concat
9
+ seams, no per-panel tail freezes), with an optional pre-built audio track
10
+ muxed in.
11
+
12
+ Both consume the ``(cx, cy, s)`` rectangle spec described under
13
+ :func:`ken_burns_video`. Use :func:`burns.ken_burns_path` to generate cohesive
14
+ multi-phase paths instead of hand-authoring rectangles.
15
+ """
16
+
17
+ import os
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import moviepy as mp
23
+ from PIL import Image as PIL_Image
24
+
25
+ from ._util import _auto_video_path, _ensure_output_path, _non_colliding_key
26
+
27
+ _VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv")
28
+
29
+
30
+ def _parse_rectangle(rect, default=(0.5, 0.5, 1.0)):
31
+ """Normalize a rectangle input to a ``(cx, cy, s)`` tuple.
32
+
33
+ Accepts:
34
+ - ``None``: returns ``default``
35
+ - single number: ``(0.5, 0.5, value)`` (a centered zoom)
36
+ - pair: ``(cx, cy, 1.0)`` (a pan center at full scale)
37
+ - triple: ``(cx, cy, s)`` (the full spec)
38
+ """
39
+ if rect is None:
40
+ return default
41
+ if isinstance(rect, (int, float)):
42
+ return (0.5, 0.5, float(rect))
43
+ if isinstance(rect, (list, tuple)):
44
+ if len(rect) == 1:
45
+ return (0.5, 0.5, float(rect[0]))
46
+ elif len(rect) == 2:
47
+ return (float(rect[0]), float(rect[1]), 1.0)
48
+ elif len(rect) == 3:
49
+ return (float(rect[0]), float(rect[1]), float(rect[2]))
50
+ raise ValueError(f"Invalid rectangle: {rect}")
51
+
52
+
53
+ def _rect_to_box(cx, cy, s, img_w, img_h):
54
+ """Convert ``(cx, cy, s)`` to a pixel crop box ``(xmin, ymin, xmax, ymax)``.
55
+
56
+ The crop's width and height in pixels are ``img_w / s`` and ``img_h / s``
57
+ respectively — same aspect ratio as the source image, so the resize-back-to-
58
+ ``(img_w, img_h)`` step that follows never stretches the frame.
59
+
60
+ Pan-center clamping: when ``(cx, cy)`` would push the crop past an edge, we
61
+ clamp the **center**, not the crop box. Clamping the box itself shrinks one
62
+ side and breaks the aspect ratio — that was the source of the breathing /
63
+ stretching artefact in long pans. By clamping the center, the box "rides
64
+ the wall" at the edge but stays the right size.
65
+ """
66
+ half_w = 0.5 / s
67
+ half_h = 0.5 / s
68
+ # Clamp the pan center so the crop box fits inside the image. When zoom
69
+ # s <= 1.0, half_w >= 0.5 → the valid range collapses to {0.5} (i.e. the
70
+ # only legal center is the image middle); ``max(low, min(high, x))`` is
71
+ # robust to that case because ``min(...)`` still returns 0.5.
72
+ cx = max(half_w, min(1.0 - half_w, cx))
73
+ cy = max(half_h, min(1.0 - half_h, cy))
74
+ xmin = (cx - half_w) * img_w
75
+ ymin = (cy - half_h) * img_h
76
+ xmax = (cx + half_w) * img_w
77
+ ymax = (cy + half_h) * img_h
78
+ # Round (not truncate) for symmetric int conversion — the ±1 px drift
79
+ # from truncation can also nudge aspect ratio on extreme zooms.
80
+ return (
81
+ int(round(xmin)),
82
+ int(round(ymin)),
83
+ int(round(xmax)),
84
+ int(round(ymax)),
85
+ )
86
+
87
+
88
+ DEFAULT_KENBURNS_PHASES = (((0.5, 0.5, 1.0), (0.5, 0.5, 1.3), 2.0),)
89
+
90
+
91
+ def ken_burns_video(
92
+ image,
93
+ *,
94
+ phases=DEFAULT_KENBURNS_PHASES,
95
+ fps: int = 30,
96
+ saveas: str | None = None,
97
+ codec: str = "libx264",
98
+ audio_codec: str = "aac",
99
+ **write_kwargs,
100
+ ) -> Path:
101
+ """Create a Ken Burns effect video from an image with multi-phase pan/zoom.
102
+
103
+ Args:
104
+ image: Path to image file or image object (PIL.Image, np.ndarray)
105
+ phases: Iterable of ``(start_rect, end_rect, duration_s)`` phases. The
106
+ camera moves linearly from ``start_rect`` to ``end_rect`` over each
107
+ phase's ``duration_s``; phases play back-to-back so total clip
108
+ length is the sum of their durations. Default is a single 2-second
109
+ standard Ken Burns push-in.
110
+ fps: Frames per second (default 30)
111
+ saveas: Where to save video (default: image path with "_kenburns" appended before extension)
112
+ codec: Video codec (default libx264)
113
+ audio_codec: Audio codec (default aac)
114
+ **write_kwargs: Passed to write_videofile
115
+
116
+ Returns:
117
+ Path to saved video
118
+
119
+ Rectangle parameterization:
120
+ Rect = (cx, cy, s) — pan center (cx, cy) in [0, 1] and zoom scale s > 0.
121
+ s = 1: the full image; s > 1: zoomed in (a smaller crop box).
122
+ The crop box is clamped to the image, so you cannot zoom out past the
123
+ original — express a zoom-out as start s > 1 panning to end s = 1.
124
+ Standard Ken Burns: the full image (s=1) zooming in to s=1.3.
125
+ Rects accept the same flexible forms as elsewhere: a scalar zoom, a
126
+ (cx, cy) pan pair, or the full (cx, cy, s) triple.
127
+
128
+ Phase continuity:
129
+ A phase's ``end_rect`` does not need to match the next phase's
130
+ ``start_rect`` — discontinuities are allowed and produce an
131
+ instantaneous cut. For continuous motion, set each phase's
132
+ ``start_rect`` equal to the previous phase's ``end_rect``.
133
+
134
+ Examples:
135
+ >>> ken_burns_video("photo.jpg") # Standard 2s push-in # doctest: +SKIP
136
+ >>> ken_burns_video( # doctest: +SKIP
137
+ ... "photo.jpg",
138
+ ... phases=[((0.5, 0.5, 1.0), (0.65, 0.4, 1.2), 5.0)],
139
+ ... )
140
+ >>> ken_burns_video( # doctest: +SKIP
141
+ ... "photo.jpg",
142
+ ... phases=[
143
+ ... ((0.5, 0.5, 1.0), (0.65, 0.4, 1.2), 4.0),
144
+ ... ((0.65, 0.4, 1.2), (0.35, 0.6, 1.2), 4.0),
145
+ ... ((0.35, 0.6, 1.2), (0.5, 0.5, 1.3), 4.0),
146
+ ... ],
147
+ ... )
148
+ """
149
+ # Accept image as path, PIL.Image, or np.ndarray.
150
+ # Track the original image path for output path generation.
151
+ image_path = None
152
+ if isinstance(image, (str, Path)):
153
+ image_path = Path(image)
154
+ img = PIL_Image.open(str(image)).convert("RGB")
155
+ elif isinstance(image, np.ndarray):
156
+ img = PIL_Image.fromarray(image)
157
+ elif hasattr(image, "convert"):
158
+ img = image.convert("RGB")
159
+ else:
160
+ raise ValueError(f"Unsupported image type: {type(image)}")
161
+
162
+ img_w, img_h = img.size
163
+ img_np = np.array(img) # preload once for fast per-frame cropping
164
+
165
+ # Normalize phases: each entry → (start_rect, end_rect, duration_s).
166
+ parsed_phases: list[tuple[tuple, tuple, float]] = []
167
+ for i, phase in enumerate(phases):
168
+ if not isinstance(phase, (list, tuple)) or len(phase) != 3:
169
+ raise ValueError(
170
+ f"phase {i}: expected (start_rect, end_rect, duration_s), got {phase!r}"
171
+ )
172
+ start_rect = _parse_rectangle(phase[0], default=(0.5, 0.5, 1.0))
173
+ end_rect = _parse_rectangle(phase[1], default=(0.5, 0.5, 1.3))
174
+ dur = float(phase[2])
175
+ if dur <= 0:
176
+ raise ValueError(f"phase {i}: duration_s must be > 0, got {dur}")
177
+ parsed_phases.append((start_rect, end_rect, dur))
178
+ if not parsed_phases:
179
+ raise ValueError("ken_burns_video: phases must be non-empty")
180
+
181
+ duration_s = sum(p[2] for p in parsed_phases)
182
+ # Cumulative phase-start times for fast lookup at frame time.
183
+ cum_starts = [0.0]
184
+ for _, _, dur in parsed_phases:
185
+ cum_starts.append(cum_starts[-1] + dur)
186
+
187
+ def _lerp(a, b, t):
188
+ return a + (b - a) * t
189
+
190
+ def _ken_burns_frame(t):
191
+ """Render the pan/zoom frame at time ``t`` (seconds), one frame at a time.
192
+
193
+ Computed lazily so the whole clip is never materialised in memory.
194
+ Picks the active phase by ``t`` and lerps linearly within it; clamps
195
+ to the last phase's end-rect once ``t`` reaches the total duration.
196
+ """
197
+ if t >= duration_s:
198
+ start_rect, end_rect, _ = parsed_phases[-1]
199
+ cx, cy, s = end_rect
200
+ else:
201
+ # Linear scan is fine — a film typically has < 20 phases per image.
202
+ for idx, (start_rect, end_rect, dur) in enumerate(parsed_phases):
203
+ if t < cum_starts[idx + 1]:
204
+ local_t = (t - cum_starts[idx]) / dur
205
+ cx = _lerp(start_rect[0], end_rect[0], local_t)
206
+ cy = _lerp(start_rect[1], end_rect[1], local_t)
207
+ s = _lerp(start_rect[2], end_rect[2], local_t)
208
+ break
209
+ else: # pragma: no cover — covered by the t >= duration_s branch
210
+ start_rect, end_rect, _ = parsed_phases[-1]
211
+ cx, cy, s = end_rect
212
+ xmin, ymin, xmax, ymax = _rect_to_box(cx, cy, s, img_w, img_h)
213
+ crop = img_np[ymin:ymax, xmin:xmax]
214
+ crop_img = PIL_Image.fromarray(crop).resize(
215
+ (img_w, img_h), resample=PIL_Image.BICUBIC
216
+ )
217
+ return np.asarray(crop_img)
218
+
219
+ # Determine output path.
220
+ if saveas is None:
221
+ if image_path is not None:
222
+ # Append "_kenburns" to the source image's filename.
223
+ output_path = _auto_video_path(str(image_path), "_kenburns", ext=".mp4")
224
+ else:
225
+ # Fallback to temp directory when there is no source path.
226
+ output_path = Path(tempfile.gettempdir()) / f"kenburns_{os.getpid()}.mp4"
227
+ else:
228
+ output_path = _ensure_output_path(saveas)
229
+ # Ensure it has a video extension.
230
+ if not output_path.suffix or output_path.suffix.lower() not in _VIDEO_EXTS:
231
+ output_path = output_path.with_suffix(".mp4")
232
+
233
+ # Avoid overwriting an existing auto-generated path.
234
+ if saveas is None:
235
+ directory = output_path.parent
236
+ filename = output_path.name
237
+ try:
238
+ existing_files = (
239
+ set(os.listdir(directory)) if directory else set(os.listdir("."))
240
+ )
241
+ except OSError:
242
+ existing_files = set()
243
+ if filename in existing_files:
244
+ output_path = directory / _non_colliding_key(filename, existing_files)
245
+
246
+ # Create the clip lazily: frames are computed on demand during encoding.
247
+ clip = mp.VideoClip(_ken_burns_frame, duration=duration_s).with_fps(fps)
248
+
249
+ # Set default kwargs for better compatibility.
250
+ write_kwargs.setdefault("bitrate", "5000k")
251
+ write_kwargs.setdefault("preset", "medium")
252
+ write_kwargs.setdefault("logger", None) # Suppress verbose output
253
+
254
+ clip.write_videofile(
255
+ str(output_path), codec=codec, audio_codec=audio_codec, **write_kwargs
256
+ )
257
+ clip.close()
258
+
259
+ print(f"Saved Ken Burns video to: {output_path}")
260
+ return output_path
261
+
262
+
263
+ # --------------------------------------------------------------------- #
264
+ # Multi-panel Ken Burns film
265
+ # --------------------------------------------------------------------- #
266
+
267
+
268
+ def ken_burns_film(
269
+ panels,
270
+ *,
271
+ saveas: str,
272
+ fps: int = 30,
273
+ audio_path=None,
274
+ codec: str = "libx264",
275
+ audio_codec: str = "aac",
276
+ **write_kwargs,
277
+ ) -> Path:
278
+ """Render an N-panel Ken Burns film in a single pass — no per-panel
279
+ intermediate files, no concat seams, no per-panel tail freezes.
280
+
281
+ Each panel is one ``(image, phases)`` pair where ``image`` is a path /
282
+ PIL.Image / np.ndarray and ``phases`` is the same shape as in
283
+ :func:`ken_burns_video`. The film plays panels back-to-back; the camera
284
+ cuts at panel boundaries (different image) but motion never pauses on
285
+ a static frame within a panel.
286
+
287
+ Args:
288
+ panels: iterable of ``(image, phases)`` pairs. Each panel's total
289
+ duration = sum of its phase durations. Film total = sum across
290
+ panels.
291
+ saveas: output mp4 path (required — multi-panel films don't have a
292
+ single source image to derive an auto-name from).
293
+ fps: frame rate of the film.
294
+ audio_path: optional pre-built audio track (already concatenated,
295
+ already matching the film duration). When supplied it is muxed
296
+ in. Per-panel audio is the caller's job to assemble — keep
297
+ the renderer pure visual.
298
+ codec, audio_codec, **write_kwargs: forwarded to ``write_videofile``.
299
+
300
+ Why a single VideoClip rather than per-panel render + concatenate:
301
+
302
+ - Concat re-encodes at I-frame boundaries; the last few frames of each
303
+ input clip can drop and the next clip's first frame may freeze
304
+ briefly. With one VideoClip the encoder writes a single stream.
305
+ - No per-panel tail-pad: the pan reaches the panel's last frame exactly
306
+ when the panel ends; the next frame is already the next panel's
307
+ first.
308
+ - Lazy frame generation: a single ``make_frame(t)`` closure dispatches
309
+ by global ``t`` to the right (panel, phase) — the whole film is
310
+ never materialised in memory.
311
+
312
+ Returns:
313
+ Path to the written mp4.
314
+ """
315
+ # Load each panel's image once; pre-parse phases.
316
+ panel_renders = []
317
+ film_duration = 0.0
318
+ for idx, panel in enumerate(panels):
319
+ if not isinstance(panel, (list, tuple)) or len(panel) != 2:
320
+ raise ValueError(
321
+ f"panel {idx}: expected (image, phases) pair, got {panel!r}"
322
+ )
323
+ image, phases = panel
324
+ if isinstance(image, (str, Path)):
325
+ img = PIL_Image.open(str(image)).convert("RGB")
326
+ elif isinstance(image, np.ndarray):
327
+ img = PIL_Image.fromarray(image)
328
+ elif hasattr(image, "convert"):
329
+ img = image.convert("RGB")
330
+ else:
331
+ raise ValueError(f"panel {idx}: unsupported image type: {type(image)}")
332
+ img_w, img_h = img.size
333
+ img_np = np.array(img)
334
+
335
+ parsed: list[tuple[tuple, tuple, float]] = []
336
+ for j, phase in enumerate(phases):
337
+ if not isinstance(phase, (list, tuple)) or len(phase) != 3:
338
+ raise ValueError(
339
+ f"panel {idx}, phase {j}: expected (start, end, dur), got {phase!r}"
340
+ )
341
+ s = _parse_rectangle(phase[0], default=(0.5, 0.5, 1.0))
342
+ e = _parse_rectangle(phase[1], default=(0.5, 0.5, 1.3))
343
+ d = float(phase[2])
344
+ if d <= 0:
345
+ raise ValueError(
346
+ f"panel {idx}, phase {j}: duration_s must be > 0, got {d}"
347
+ )
348
+ parsed.append((s, e, d))
349
+ if not parsed:
350
+ raise ValueError(f"panel {idx}: phases must be non-empty")
351
+
352
+ panel_dur = sum(p[2] for p in parsed)
353
+ cum = [0.0]
354
+ for _, _, d in parsed:
355
+ cum.append(cum[-1] + d)
356
+ panel_renders.append(
357
+ {
358
+ "img_np": img_np,
359
+ "img_w": img_w,
360
+ "img_h": img_h,
361
+ "phases": parsed,
362
+ "cum_starts": cum,
363
+ "duration": panel_dur,
364
+ "film_offset": film_duration,
365
+ "size": (img_w, img_h),
366
+ }
367
+ )
368
+ film_duration += panel_dur
369
+
370
+ if film_duration <= 0:
371
+ raise ValueError("ken_burns_film: panels must be non-empty")
372
+
373
+ # Common output frame size = first panel's size. When all panels share the
374
+ # same size (common for storyboard demos), every panel is rendered at its
375
+ # native size and no letter/pillar-boxing happens.
376
+ out_w, out_h = panel_renders[0]["size"]
377
+
378
+ def _lerp(a, b, t):
379
+ return a + (b - a) * t
380
+
381
+ def make_frame(t):
382
+ """Render the film's frame at global time ``t``.
383
+
384
+ Walks the panel list once per frame (typical film: < 50 panels —
385
+ cheap). Within a panel, linearly interpolates between the active
386
+ phase's start and end rect; clamps to the last phase's end-rect
387
+ when ``t`` lands exactly on a boundary (avoids a 1-frame flicker
388
+ at panel transitions).
389
+ """
390
+ if t >= film_duration:
391
+ pr = panel_renders[-1]
392
+ cx, cy, s = pr["phases"][-1][1]
393
+ else:
394
+ pr = None
395
+ for candidate in panel_renders:
396
+ if t < candidate["film_offset"] + candidate["duration"]:
397
+ pr = candidate
398
+ break
399
+ assert pr is not None # guarded by t < film_duration
400
+ local_t = t - pr["film_offset"]
401
+ cx = cy = s = None
402
+ for idx, (start, end, dur) in enumerate(pr["phases"]):
403
+ if local_t < pr["cum_starts"][idx + 1]:
404
+ inner = (local_t - pr["cum_starts"][idx]) / dur
405
+ cx = _lerp(start[0], end[0], inner)
406
+ cy = _lerp(start[1], end[1], inner)
407
+ s = _lerp(start[2], end[2], inner)
408
+ break
409
+ if cx is None: # exactly on a boundary
410
+ start, end, _ = pr["phases"][-1]
411
+ cx, cy, s = end
412
+
413
+ img_w, img_h = pr["img_w"], pr["img_h"]
414
+ xmin, ymin, xmax, ymax = _rect_to_box(cx, cy, s, img_w, img_h)
415
+ crop = pr["img_np"][ymin:ymax, xmin:xmax]
416
+ # Resize the crop back to the film's output frame size. When panels
417
+ # are all the same size (common case), this equals the crop's own
418
+ # (img_w, img_h) and the aspect is preserved exactly.
419
+ crop_img = PIL_Image.fromarray(crop).resize(
420
+ (out_w, out_h), resample=PIL_Image.BICUBIC
421
+ )
422
+ return np.asarray(crop_img)
423
+
424
+ output_path = _ensure_output_path(saveas)
425
+ if not output_path.suffix or output_path.suffix.lower() not in _VIDEO_EXTS:
426
+ output_path = output_path.with_suffix(".mp4")
427
+
428
+ clip = mp.VideoClip(make_frame, duration=film_duration).with_fps(fps)
429
+ if audio_path is not None:
430
+ audio_clip = mp.AudioFileClip(str(audio_path))
431
+ clip = clip.with_audio(audio_clip)
432
+
433
+ write_kwargs.setdefault("bitrate", "5000k")
434
+ write_kwargs.setdefault("preset", "medium")
435
+ write_kwargs.setdefault("logger", None)
436
+
437
+ clip.write_videofile(
438
+ str(output_path), codec=codec, audio_codec=audio_codec, **write_kwargs
439
+ )
440
+ clip.close()
441
+
442
+ print(
443
+ f"Saved Ken Burns film ({len(panel_renders)} panels, "
444
+ f"{film_duration:.1f}s) to: {output_path}"
445
+ )
446
+ return output_path
@@ -0,0 +1,168 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ ]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "burns"
9
+ version = "0.0.0"
10
+ description = "Ken Burns pan/zoom video effects: turn a still image (or a sequence of stills) into a cinematic pan/zoom film."
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ keywords = [
14
+ "ken-burns",
15
+ "video",
16
+ "pan-zoom",
17
+ "moviepy",
18
+ "animation",
19
+ "slideshow",
20
+ ]
21
+ authors = [
22
+ { name = "Thor Whalen" },
23
+ ]
24
+ dependencies = [
25
+ "numpy",
26
+ "moviepy",
27
+ "pillow",
28
+ ]
29
+
30
+ [project.license]
31
+ text = "mit"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/thorwhalen/burns"
35
+ Repository = "https://github.com/thorwhalen/burns"
36
+ Documentation = "https://thorwhalen.github.io/burns"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=7.0",
41
+ "pytest-cov>=4.0",
42
+ "ruff>=0.1.0",
43
+ ]
44
+ docs = [
45
+ "sphinx>=6.0",
46
+ "sphinx-rtd-theme>=1.0",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ line-length = 88
51
+ target-version = "py310"
52
+ exclude = [
53
+ "**/*.ipynb",
54
+ ".git",
55
+ ".venv",
56
+ "build",
57
+ "dist",
58
+ "tests",
59
+ "examples",
60
+ "scrap",
61
+ ]
62
+
63
+ [tool.ruff.lint]
64
+ select = [
65
+ "D100",
66
+ ]
67
+ ignore = [
68
+ "D203",
69
+ "E501",
70
+ "B905",
71
+ ]
72
+
73
+ [tool.ruff.lint.pydocstyle]
74
+ convention = "google"
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "**/tests/*" = [
78
+ "D",
79
+ ]
80
+ "**/examples/*" = [
81
+ "D",
82
+ ]
83
+ "**/scrap/*" = [
84
+ "D",
85
+ ]
86
+
87
+ [tool.pytest.ini_options]
88
+ minversion = "6.0"
89
+ testpaths = [
90
+ "tests",
91
+ ]
92
+ doctest_optionflags = [
93
+ "NORMALIZE_WHITESPACE",
94
+ "ELLIPSIS",
95
+ ]
96
+
97
+ [tool.wads.ci]
98
+ project_name = ""
99
+
100
+ [tool.wads.ci.commands]
101
+ pre_test = []
102
+ test = []
103
+ post_test = []
104
+ lint = []
105
+ format = []
106
+
107
+ [tool.wads.ci.env]
108
+ required_envvars = []
109
+ test_envvars = []
110
+ extra_envvars = []
111
+
112
+ [tool.wads.ci.env.defaults]
113
+
114
+ [tool.wads.ci.quality.ruff]
115
+ enabled = true
116
+
117
+ [tool.wads.ci.quality.black]
118
+ enabled = false
119
+
120
+ [tool.wads.ci.quality.mypy]
121
+ enabled = false
122
+
123
+ [tool.wads.ci.testing]
124
+ enabled = true
125
+ python_versions = [
126
+ "3.10",
127
+ "3.12",
128
+ ]
129
+ pytest_args = [
130
+ "-v",
131
+ "--tb=short",
132
+ ]
133
+ coverage_enabled = true
134
+ coverage_threshold = 0
135
+ coverage_report_format = [
136
+ "term",
137
+ "xml",
138
+ ]
139
+ exclude_paths = [
140
+ "examples",
141
+ "scrap",
142
+ ]
143
+ test_on_windows = true
144
+
145
+ [tool.wads.ci.metrics]
146
+ enabled = true
147
+ config_path = ".github/umpyre-config.yml"
148
+ storage_branch = "code-metrics"
149
+ python_version = "3.10"
150
+ force_run = false
151
+
152
+ [tool.wads.ci.build]
153
+ sdist = true
154
+ wheel = true
155
+
156
+ [tool.wads.ci.publish]
157
+ enabled = true
158
+ skip_ci_marker = "[skip ci]"
159
+ publish_marker = "[publish]"
160
+
161
+ [tool.wads.ci.docs]
162
+ enabled = true
163
+ builder = "epythet"
164
+ ignore_paths = [
165
+ "tests/",
166
+ "scrap/",
167
+ "examples/",
168
+ ]