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.
- burns-0.0.0/.gitattributes +1 -0
- burns-0.0.0/.github/workflows/ci.yml +48 -0
- burns-0.0.0/.gitignore +120 -0
- burns-0.0.0/LICENSE +21 -0
- burns-0.0.0/PKG-INFO +133 -0
- burns-0.0.0/README.md +109 -0
- burns-0.0.0/burns/__init__.py +48 -0
- burns-0.0.0/burns/_util.py +78 -0
- burns-0.0.0/burns/paths.py +180 -0
- burns-0.0.0/burns/render.py +446 -0
- burns-0.0.0/pyproject.toml +168 -0
|
@@ -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
|
+
]
|