iterframes 0.5.0__tar.gz → 0.6.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.
Files changed (50) hide show
  1. iterframes-0.6.0/.github/scripts/check_site.py +50 -0
  2. {iterframes-0.5.0 → iterframes-0.6.0}/.github/workflows/ci.yaml +9 -0
  3. iterframes-0.6.0/.github/workflows/pages.yaml +61 -0
  4. iterframes-0.6.0/.release-please-manifest.json +3 -0
  5. {iterframes-0.5.0 → iterframes-0.6.0}/AGENTS.md +36 -0
  6. {iterframes-0.5.0 → iterframes-0.6.0}/CHANGELOG.md +7 -0
  7. {iterframes-0.5.0 → iterframes-0.6.0}/Cargo.lock +1 -1
  8. {iterframes-0.5.0 → iterframes-0.6.0}/Cargo.toml +1 -1
  9. {iterframes-0.5.0 → iterframes-0.6.0}/PKG-INFO +15 -2
  10. {iterframes-0.5.0 → iterframes-0.6.0}/README.md +14 -1
  11. {iterframes-0.5.0 → iterframes-0.6.0}/docs/index.md +7 -0
  12. {iterframes-0.5.0 → iterframes-0.6.0}/docs/reference.md +30 -3
  13. {iterframes-0.5.0 → iterframes-0.6.0}/iterframes/__init__.py +15 -1
  14. {iterframes-0.5.0 → iterframes-0.6.0}/src/decoder.rs +38 -4
  15. {iterframes-0.5.0 → iterframes-0.6.0}/src/lib.rs +34 -5
  16. {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_benchmark.py +26 -0
  17. {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_select.py +101 -0
  18. iterframes-0.6.0/web/README.md +123 -0
  19. iterframes-0.6.0/web/assets/favicon.svg +5 -0
  20. iterframes-0.6.0/web/assets/fonts/jakarta-latin.woff2 +0 -0
  21. iterframes-0.6.0/web/assets/fonts/jetbrains-mono-latin.woff2 +0 -0
  22. iterframes-0.6.0/web/assets/frames.webp +0 -0
  23. iterframes-0.6.0/web/assets/og.png +0 -0
  24. iterframes-0.6.0/web/assets/scenes.webp +0 -0
  25. iterframes-0.6.0/web/index.html +532 -0
  26. iterframes-0.6.0/web/main.js +467 -0
  27. iterframes-0.6.0/web/og-card.html +51 -0
  28. iterframes-0.6.0/web/styles.css +730 -0
  29. iterframes-0.5.0/.release-please-manifest.json +0 -3
  30. {iterframes-0.5.0 → iterframes-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  31. {iterframes-0.5.0 → iterframes-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {iterframes-0.5.0 → iterframes-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  33. {iterframes-0.5.0 → iterframes-0.6.0}/.gitignore +0 -0
  34. {iterframes-0.5.0 → iterframes-0.6.0}/.pre-commit-config.yaml +0 -0
  35. {iterframes-0.5.0 → iterframes-0.6.0}/.readthedocs.yaml +0 -0
  36. {iterframes-0.5.0 → iterframes-0.6.0}/CLAUDE.md +0 -0
  37. {iterframes-0.5.0 → iterframes-0.6.0}/LICENSE +0 -0
  38. {iterframes-0.5.0 → iterframes-0.6.0}/build.rs +0 -0
  39. {iterframes-0.5.0 → iterframes-0.6.0}/docs/development.md +0 -0
  40. {iterframes-0.5.0 → iterframes-0.6.0}/mkdocs.yml +0 -0
  41. {iterframes-0.5.0 → iterframes-0.6.0}/pyproject.toml +0 -0
  42. {iterframes-0.5.0 → iterframes-0.6.0}/release-please-config.json +0 -0
  43. {iterframes-0.5.0 → iterframes-0.6.0}/scripts/build-ffmpeg.sh +0 -0
  44. {iterframes-0.5.0 → iterframes-0.6.0}/src/dlpack.rs +0 -0
  45. {iterframes-0.5.0 → iterframes-0.6.0}/src/ffmpeg.rs +0 -0
  46. {iterframes-0.5.0 → iterframes-0.6.0}/tests/conftest.py +0 -0
  47. {iterframes-0.5.0 → iterframes-0.6.0}/tests/data/video_480x270.mp4 +0 -0
  48. {iterframes-0.5.0 → iterframes-0.6.0}/tests/data/video_av1_480x270.mp4 +0 -0
  49. {iterframes-0.5.0 → iterframes-0.6.0}/tests/test_read.py +0 -0
  50. {iterframes-0.5.0 → iterframes-0.6.0}/uv.lock +0 -0
@@ -0,0 +1,50 @@
1
+ """Check that every local reference in the landing page resolves to a file.
2
+
3
+ The page has no build step, so nothing else would catch a renamed asset or a
4
+ typo in a path until the deployed site showed a broken image.
5
+ """
6
+
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ # href="...", src="..." and url(...) in CSS.
13
+ REFERENCES = re.compile(
14
+ r"""(?:href|src)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')]+)["']?\s*\)"""
15
+ )
16
+
17
+
18
+ def local(reference: str) -> bool:
19
+ parsed = urlparse(reference)
20
+ return not parsed.scheme and not parsed.netloc and bool(parsed.path)
21
+
22
+
23
+ def main(root: Path) -> int:
24
+ missing = []
25
+ for source in sorted(root.rglob("*")):
26
+ if source.suffix not in {".html", ".css", ".js"}:
27
+ continue
28
+ text = source.read_text(encoding="utf-8")
29
+ for match in REFERENCES.finditer(text):
30
+ reference = match.group(1) or match.group(2)
31
+ if not local(reference):
32
+ continue
33
+ path = unquote(urlparse(reference).path)
34
+ if path in {"./", "."}:
35
+ continue
36
+ target = (source.parent / path).resolve()
37
+ if not target.exists():
38
+ line = text.count("\n", 0, match.start()) + 1
39
+ missing.append(f"{source}:{line}: {reference}")
40
+
41
+ for problem in missing:
42
+ print(f"missing: {problem}", file=sys.stderr)
43
+ if missing:
44
+ return 1
45
+ print(f"every local reference under {root} resolves")
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else "web")))
@@ -8,11 +8,20 @@ name: CI
8
8
  # updates its release pull request, and merging that pull request creates
9
9
  # the tag and a draft GitHub release, then the jobs below attach the
10
10
  # wheels, publish them to PyPI, and publish the release.
11
+ # The landing page has its own workflow, and nothing here builds it, so a
12
+ # commit that only touches web/ skips the wheel matrix. release-please skips
13
+ # with it, and picks those commits up on the next push that does build
14
+ # something.
11
15
  on:
12
16
  push:
13
17
  branches:
14
18
  - main
19
+ paths-ignore: &page
20
+ - 'web/**'
21
+ - '.github/workflows/pages.yaml'
22
+ - '.github/scripts/check_site.py'
15
23
  pull_request:
24
+ paths-ignore: *page
16
25
  workflow_dispatch:
17
26
 
18
27
  permissions:
@@ -0,0 +1,61 @@
1
+ # Publishes web/ to https://alesanfra.github.io/iterframes/.
2
+ #
3
+ # The page is plain HTML, CSS and JavaScript, so there is nothing to build:
4
+ # the job uploads the directory and hands it to Pages. It runs only when
5
+ # web/ changes, and ci.yaml ignores web/ for the same reason, so a change
6
+ # to one never spends minutes on the other.
7
+ #
8
+ # Pages must be set to "GitHub Actions" under Settings > Pages for the
9
+ # deployment to be accepted.
10
+ name: Pages
11
+
12
+ on:
13
+ push:
14
+ branches:
15
+ - main
16
+ paths: &page
17
+ - 'web/**'
18
+ - '.github/workflows/pages.yaml'
19
+ - '.github/scripts/check_site.py'
20
+ pull_request:
21
+ paths: *page
22
+ workflow_dispatch:
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ # One deployment at a time, and never cancel one halfway: a cancelled
28
+ # deployment leaves the live site on the previous version.
29
+ concurrency:
30
+ group: pages
31
+ cancel-in-progress: false
32
+
33
+ jobs:
34
+ check:
35
+ name: Check the page
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v7
39
+ - name: Check every local link and asset resolves
40
+ run: python3 .github/scripts/check_site.py web
41
+
42
+ deploy:
43
+ name: Deploy to GitHub Pages
44
+ # Pull requests get the check above; only main is published.
45
+ if: ${{ github.event_name != 'pull_request' }}
46
+ needs: check
47
+ runs-on: ubuntu-latest
48
+ permissions:
49
+ pages: write
50
+ id-token: write
51
+ environment:
52
+ name: github-pages
53
+ url: ${{ steps.deployment.outputs.page_url }}
54
+ steps:
55
+ - uses: actions/checkout@v7
56
+ - uses: actions/configure-pages@v6
57
+ - uses: actions/upload-pages-artifact@v5
58
+ with:
59
+ path: web
60
+ - id: deployment
61
+ uses: actions/deploy-pages@v5
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.0"
3
+ }
@@ -54,6 +54,13 @@ as `iterframes.iterframes`, and `iterframes/__init__.py` wraps its
54
54
  frame, which decoding cannot return either, so frame numbers are those of
55
55
  a plain `read`. A stream that cannot seek, such as raw H.264, is opened
56
56
  again and read from the start (`Source::open`).
57
+ - `approximate` goes with `frames` only, and carries a tolerance in frames
58
+ (`True` is `usize::MAX`), in `Selection::Frames`. `selected` then maps
59
+ each index through `Seeker::nearest_key`, which snaps it to the key frame
60
+ closest to it when that is within the tolerance, and leaves it alone
61
+ otherwise. Nothing else changes: the index pass still runs, and two
62
+ indices that snap to the same key frame decode it once each, so the caller
63
+ gets one frame per number asked for.
57
64
  - Errors travel through the channel and become Python exceptions in
58
65
  `impl From<Error> for PyErr`. A closed channel means the end of the video.
59
66
  - Dropping the reader closes the channel; the thread notices on its next
@@ -103,6 +110,7 @@ as `iterframes.iterframes`, and `iterframes/__init__.py` wraps its
103
110
  | `scripts/build-ffmpeg.sh` | Static FFmpeg and dav1d, run by `build.rs` |
104
111
  | `tests/` | pytest suite; frames are compared with PyAV |
105
112
  | `docs/` | MkDocs site published on Read the Docs |
113
+ | `web/` | Landing page published on GitHub Pages |
106
114
 
107
115
  ## Environment
108
116
 
@@ -145,6 +153,34 @@ built module; check them instead of writing them from memory. Keep the
145
153
  text short, in American English, with no performance claims that have not
146
154
  been measured.
147
155
 
156
+ The landing page in `web/` is part of the docs: whenever an argument, a
157
+ default, or the behavior of a feature changes, update it in the same
158
+ change as the code, and say so in the pull request. Its diagrams are
159
+ drawn from the numbers declared in `web/main.js`, so change the number
160
+ and let the drawing and the captions follow; never write a figure into
161
+ the HTML that the script also computes. `web/README.md` lists those
162
+ numbers and the files. Check the result in a browser before proposing the
163
+ change: open `web/index.html`, or serve the directory with
164
+ `python3 -m http.server --directory web`. The page has no build step and no
165
+ third-party request: its two typefaces are self-hosted woff2 files in
166
+ `web/assets/fonts/`, and everything else is plain HTML, CSS, and JavaScript.
167
+ `web/README.md` describes the look and the colour rules; keep them rather
168
+ than restyling one component on its own.
169
+
170
+ ## Landing page
171
+
172
+ `web/` is the page at
173
+ [alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/):
174
+ plain HTML, CSS and JavaScript, no build step, no framework, no web fonts and
175
+ no third-party script. `.github/workflows/pages.yaml` uploads the directory
176
+ and runs only when it changes; `ci.yaml` ignores it, so neither spends minutes
177
+ on the other. Its two animated diagrams are built from constants at the top of
178
+ `main.js`, and their captions are computed from the same constants, so a
179
+ number cannot appear in the drawing and the prose with two different values.
180
+ `web/README.md` has the `ffmpeg` commands that made the images.
181
+
182
+ Keep its claims to what the benchmarks measured, as in the docs.
183
+
148
184
  ## Conventions
149
185
 
150
186
  - Commits follow [Conventional Commits](https://www.conventionalcommits.org/),
@@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
6
6
  From 0.4.0 on, [release-please](https://github.com/googleapis/release-please)
7
7
  writes each entry from the Conventional Commits since the previous release.
8
8
 
9
+ ## [0.6.0](https://github.com/alesanfra/iterframes/compare/v0.5.0...v0.6.0) (2026-09-20)
10
+
11
+
12
+ ### Features
13
+
14
+ * read approximate frames ([#11](https://github.com/alesanfra/iterframes/issues/11)) ([2158899](https://github.com/alesanfra/iterframes/commit/2158899f72f73d69c0020236181e340bb8648fc1))
15
+
9
16
  ## [0.5.0](https://github.com/alesanfra/iterframes/compare/v0.4.0...v0.5.0) (2026-09-20)
10
17
 
11
18
 
@@ -96,7 +96,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
96
96
 
97
97
  [[package]]
98
98
  name = "iterframes"
99
- version = "0.5.0"
99
+ version = "0.6.0"
100
100
  dependencies = [
101
101
  "bindgen",
102
102
  "crossbeam-channel",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "iterframes"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.89"
6
6
  description = "Iterate over the frames of a video as NumPy arrays, decoded on a background thread while you process them"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iterframes
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Classifier: Intended Audience :: Science/Research
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -43,7 +43,9 @@ adding to it, even when your code is pure Python. `pip install iterframes`
43
43
  brings FFmpeg with it: nothing else to install, no system packages, no
44
44
  `ffmpeg` binary to call.
45
45
 
46
- The documentation is at
46
+ The landing page at
47
+ [alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/)
48
+ shows what the decoder does while your loop runs. The documentation is at
47
49
  [iterframes.readthedocs.io](https://iterframes.readthedocs.io): the
48
50
  [reference](https://iterframes.readthedocs.io/en/latest/reference/)
49
51
  documents every argument and error, and the
@@ -113,6 +115,17 @@ before it; frames asked for in order cost no more than reading the video
113
115
  straight through. See
114
116
  [Reading frames by number](https://iterframes.readthedocs.io/en/latest/reference/#reading-frames-by-number).
115
117
 
118
+ When a frame nearby will do, `approximate` reads the key frame closest to
119
+ each of `frames`, which costs one decoded frame instead of the frames from
120
+ the key frame on:
121
+
122
+ ```python
123
+ # The nearest key frame within 5 frames, else the frame itself.
124
+ frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
125
+ ```
126
+
127
+ See [Approximate frames](https://iterframes.readthedocs.io/en/latest/reference/#approximate-frames).
128
+
116
129
  ## Hardware decoding
117
130
 
118
131
  Pass `device` to decode on a GPU instead of the CPU, which then stays free
@@ -20,7 +20,9 @@ adding to it, even when your code is pure Python. `pip install iterframes`
20
20
  brings FFmpeg with it: nothing else to install, no system packages, no
21
21
  `ffmpeg` binary to call.
22
22
 
23
- The documentation is at
23
+ The landing page at
24
+ [alesanfra.github.io/iterframes](https://alesanfra.github.io/iterframes/)
25
+ shows what the decoder does while your loop runs. The documentation is at
24
26
  [iterframes.readthedocs.io](https://iterframes.readthedocs.io): the
25
27
  [reference](https://iterframes.readthedocs.io/en/latest/reference/)
26
28
  documents every argument and error, and the
@@ -90,6 +92,17 @@ before it; frames asked for in order cost no more than reading the video
90
92
  straight through. See
91
93
  [Reading frames by number](https://iterframes.readthedocs.io/en/latest/reference/#reading-frames-by-number).
92
94
 
95
+ When a frame nearby will do, `approximate` reads the key frame closest to
96
+ each of `frames`, which costs one decoded frame instead of the frames from
97
+ the key frame on:
98
+
99
+ ```python
100
+ # The nearest key frame within 5 frames, else the frame itself.
101
+ frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
102
+ ```
103
+
104
+ See [Approximate frames](https://iterframes.readthedocs.io/en/latest/reference/#approximate-frames).
105
+
93
106
  ## Hardware decoding
94
107
 
95
108
  Pass `device` to decode on a GPU instead of the CPU, which then stays free
@@ -64,6 +64,13 @@ clip = list(iterframes.read("video.mp4", frames=[0, 30, 60]))
64
64
  every_fifth = iterframes.read("video.mp4", start=100, stop=200, step=5)
65
65
  ```
66
66
 
67
+ Or, when a frame nearby will do, read the nearest key frame to each of
68
+ them and skip the decoding in between:
69
+
70
+ ```python
71
+ clip = list(iterframes.read("video.mp4", frames=[0, 30, 60], approximate=5))
72
+ ```
73
+
67
74
  Stop whenever you like; the decoder stops with the loop:
68
75
 
69
76
  ```python
@@ -5,7 +5,7 @@ Everything lives in the top-level `iterframes` module.
5
5
  ## read
6
6
 
7
7
  ```python
8
- read(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, frames=None, start=0, stop=None, step=1) -> Iterator[numpy.ndarray]
8
+ read(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, frames=None, start=0, stop=None, step=1, approximate=None) -> Iterator[numpy.ndarray]
9
9
  ```
10
10
 
11
11
  Yields the frames of the video at `path`, in order, or with `frames`, or
@@ -23,6 +23,7 @@ Each frame is a C-contiguous, writable `numpy.ndarray` of shape
23
23
  | `on_device` | With `device="cuda"`, yield `CudaFrame` objects left on the GPU instead of arrays. See [Frames on the GPU](#frames-on-the-gpu) |
24
24
  | `frames` | The numbers of the frames to read, in the order to read them. See [Reading frames by number](#reading-frames-by-number) |
25
25
  | `start`, `stop`, `step` | The frames to read, as a slice of the video |
26
+ | `approximate` | With `frames`, read the key frame nearest to each of them instead, when it is within this many frames. `True` is any distance. See [Approximate frames](#approximate-frames) |
26
27
 
27
28
  Frames are resized with bilinear interpolation. When only one of `height`
28
29
  and `width` is given, the other keeps the size of the video, so the aspect
@@ -48,7 +49,7 @@ for frame in iterframes.read("video.mp4", height=270, width=480):
48
49
  ## read_batches
49
50
 
50
51
  ```python
51
- read_batches(path, batch_size, height=None, width=None, prefetch_frames=1, device="cpu", drop_last=False, frames=None, start=0, stop=None, step=1) -> Iterator[numpy.ndarray]
52
+ read_batches(path, batch_size, height=None, width=None, prefetch_frames=1, device="cpu", drop_last=False, frames=None, start=0, stop=None, step=1, approximate=None) -> Iterator[numpy.ndarray]
52
53
  ```
53
54
 
54
55
  Yields the frames of the video in batches, in order, for models that take
@@ -108,6 +109,32 @@ Frame numbers are those of [`read`](#read): frame `n` is the one `read`
108
109
  yields `n`-th. A number the video does not have raises `IndexError`,
109
110
  while a slice past the end stops at the last frame, as a list does.
110
111
 
112
+ ## Approximate frames
113
+
114
+ Decoding a frame in the middle of a group of pictures costs every frame
115
+ from the key frame on. `approximate` spends one decoded frame instead, by
116
+ reading the key frame nearest to each frame asked for. It goes with
117
+ `frames` only; with `start`, `stop`, and `step` it raises `ValueError`.
118
+
119
+ ```python
120
+ # The nearest key frame to each of these, however far away it is.
121
+ frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=True))
122
+
123
+ # The nearest key frame within 5 frames, else the frame itself.
124
+ frames = list(iterframes.read("video.mp4", frames=[100, 200, 300], approximate=5))
125
+ ```
126
+
127
+ A number bounds the error: a frame moves by at most that many frames, and
128
+ one with no key frame that close is decoded exactly. `True` accepts any
129
+ distance, which in a video with a key frame every 10 seconds means a frame
130
+ up to 5 seconds away from the one asked for. `False`, `0`, and `None`
131
+ read every frame exactly.
132
+
133
+ The video is still indexed, so its packets are read either way, and the
134
+ saving is in decoding alone. Two frames near the same key frame become
135
+ the same frame, which is decoded once for each of them: the iterator
136
+ yields as many frames as `frames` asks for, in the same order.
137
+
111
138
  ## Errors
112
139
 
113
140
  Errors are raised by the first `next()` on the iterator, not by the call
@@ -147,7 +174,7 @@ FFmpeg also spreads the decoding of each video over several threads.
147
174
  ## FrameReader
148
175
 
149
176
  ```python
150
- FrameReader(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, batch_size=None, drop_last=False, frames=None, start=0, stop=None, step=1)
177
+ FrameReader(path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=False, batch_size=None, drop_last=False, frames=None, start=0, stop=None, step=1, approximate=None)
151
178
  ```
152
179
 
153
180
  The iterator behind `read` and `read_batches`. It yields `Frame` objects,
@@ -47,6 +47,7 @@ def read(
47
47
  start: int = 0,
48
48
  stop: Optional[int] = None,
49
49
  step: int = 1,
50
+ approximate: Union[bool, int, None] = None,
50
51
  ) -> Iterator[Union[np.ndarray, CudaFrame]]:
51
52
  """Yield the frames of the video at ``path``, in order.
52
53
 
@@ -81,6 +82,15 @@ def read(
81
82
  the file first, by reading its packets without decoding them, and then
82
83
  decodes each frame from the key frame before it, so frames asked for
83
84
  in order cost no more than reading the video straight through.
85
+
86
+ ``approximate`` trades accuracy for speed, and goes with ``frames``
87
+ only: each frame asked for is read as the key frame nearest to it, so
88
+ it costs one decoded frame instead of the frames from the key frame
89
+ on. ``approximate=n`` moves a frame by at most ``n`` frames and reads
90
+ the rest exactly, which bounds the error; ``approximate=True`` moves
91
+ it by any distance. The video still has to be indexed, so the packets
92
+ are read either way. Two frames near the same key frame are then the
93
+ same frame, and are decoded once each.
84
94
  """
85
95
  reader = FrameReader(
86
96
  path,
@@ -93,6 +103,7 @@ def read(
93
103
  start=start,
94
104
  stop=stop,
95
105
  step=step,
106
+ approximate=approximate,
96
107
  )
97
108
  if on_device:
98
109
  yield from reader
@@ -114,6 +125,7 @@ def read_batches(
114
125
  start: int = 0,
115
126
  stop: Optional[int] = None,
116
127
  step: int = 1,
128
+ approximate: Union[bool, int, None] = None,
117
129
  ) -> Iterator[np.ndarray]:
118
130
  """Yield the frames of the video at ``path`` in batches, in order.
119
131
 
@@ -125,7 +137,8 @@ def read_batches(
125
137
 
126
138
  Takes the same arguments as :func:`read`, except ``on_device``, so
127
139
  ``frames``, or ``start``, ``stop``, and ``step``, batch the frames
128
- with those numbers instead of the whole video. The background thread
140
+ with those numbers instead of the whole video, and ``approximate``
141
+ batches the key frames nearest to ``frames``. The background thread
129
142
  decodes up to ``prefetch_frames`` frames ahead, rounded up to whole
130
143
  batches.
131
144
  """
@@ -143,6 +156,7 @@ def read_batches(
143
156
  start=start,
144
157
  stop=stop,
145
158
  step=step,
159
+ approximate=approximate,
146
160
  )
147
161
  # The arrays share memory with the batches, which they keep alive.
148
162
  for batch in reader:
@@ -38,8 +38,13 @@ pub enum Selection {
38
38
  /// straight through as well, and stops early.
39
39
  First(usize),
40
40
  /// These frames, in this order; a negative number counts from the end
41
- /// of the video.
42
- Frames(Vec<isize>),
41
+ /// of the video. With `approximate`, each of them is read as the
42
+ /// nearest key frame within that many frames of it, which costs one
43
+ /// decoded frame instead of the ones from the key frame on.
44
+ Frames {
45
+ wanted: Vec<isize>,
46
+ approximate: Option<usize>,
47
+ },
43
48
  /// The frames from `start` to `stop`, every `step` of them, as a
44
49
  /// Python slice: negative bounds count from the end, and `stop` of
45
50
  /// `None` is the end of the video.
@@ -67,7 +72,7 @@ impl Selection {
67
72
  match self {
68
73
  Selection::All => Ok((0..frames).collect()),
69
74
  Selection::First(count) => Ok((0..frames.min(*count)).collect()),
70
- Selection::Frames(wanted) => wanted.iter().copied().map(resolve).collect(),
75
+ Selection::Frames { wanted, .. } => wanted.iter().copied().map(resolve).collect(),
71
76
  // Out of range bounds clamp, as a slice of a list does.
72
77
  Selection::Range { start, stop, step } => {
73
78
  let clamp = |index: isize| {
@@ -250,8 +255,18 @@ fn selected(
250
255
  tx: &Sender<Message>,
251
256
  ) -> Result<bool, Error> {
252
257
  let mut seeker = Seeker::new(source, input, decoder)?;
258
+ let mut indices = selection.indices(seeker.len())?;
259
+ if let Selection::Frames {
260
+ approximate: Some(tolerance),
261
+ ..
262
+ } = selection
263
+ {
264
+ for index in &mut indices {
265
+ *index = seeker.nearest_key(*index, tolerance);
266
+ }
267
+ }
253
268
  let mut frame = Frame::new();
254
- for index in selection.indices(seeker.len())? {
269
+ for index in indices {
255
270
  seeker.frame(index, &mut frame)?;
256
271
  if !converter.send(&mut frame, tx)? {
257
272
  return Ok(false);
@@ -518,6 +533,25 @@ impl Seeker<'_> {
518
533
  }
519
534
  }
520
535
 
536
+ /// The key frame nearest to `index`, when no more than `tolerance`
537
+ /// frames away from it, else `index` itself. A tie goes to the key
538
+ /// frame before, which decoding is more likely to reach by going on.
539
+ fn nearest_key(&self, index: usize, tolerance: usize) -> usize {
540
+ let before = self.key_frame_before(index);
541
+ let nearest = match self
542
+ .keys
543
+ .get(self.keys.partition_point(|key| *key <= index))
544
+ {
545
+ Some(after) if after - index < index - before => *after,
546
+ _ => before,
547
+ };
548
+ if nearest.abs_diff(index) <= tolerance {
549
+ nearest
550
+ } else {
551
+ index
552
+ }
553
+ }
554
+
521
555
  /// The last key frame at or before `index`. The numbers rise, so a
522
556
  /// long video costs a binary search rather than a scan.
523
557
  fn key_frame_before(&self, index: usize) -> usize {
@@ -332,7 +332,8 @@ impl Plane {
332
332
  /// `batch_size`, a `Batch` of that many frames (fewer in the last one,
333
333
  /// unless `drop_last`), whose `prefetch_frames` rounds up to whole batches.
334
334
  /// `frames`, or `start`, `stop`, and `step`, pick the frames to decode by
335
- /// number instead of reading the whole video. Use `iterframes.read` and
335
+ /// number instead of reading the whole video, and `approximate` reads each
336
+ /// of `frames` as the key frame nearest to it. Use `iterframes.read` and
336
337
  /// `iterframes.read_batches`, which wrap them in NumPy arrays.
337
338
  #[pyclass(module = "iterframes")]
338
339
  struct FrameReader {
@@ -344,7 +345,8 @@ impl FrameReader {
344
345
  #[new]
345
346
  #[pyo3(signature = (
346
347
  path, height=None, width=None, prefetch_frames=1, device="cpu", on_device=false,
347
- batch_size=None, drop_last=false, frames=None, start=0, stop=None, step=1
348
+ batch_size=None, drop_last=false, frames=None, start=0, stop=None, step=1,
349
+ approximate=None
348
350
  ))]
349
351
  #[allow(clippy::too_many_arguments)]
350
352
  fn new(
@@ -360,8 +362,9 @@ impl FrameReader {
360
362
  start: isize,
361
363
  stop: Option<isize>,
362
364
  step: isize,
365
+ approximate: Option<&Bound<'_, PyAny>>,
363
366
  ) -> PyResult<Self> {
364
- let selection = selection(frames, start, stop, step)?;
367
+ let selection = selection(frames, start, stop, step, approximate)?;
365
368
  let batching = match batch_size {
366
369
  None => None,
367
370
  Some(0) => return Err(PyValueError::new_err("batch_size must be at least 1")),
@@ -432,19 +435,28 @@ impl FrameReader {
432
435
 
433
436
  /// Which frames to decode, from the arguments Python passes: `frames`
434
437
  /// lists them, while `start`, `stop`, and `step` slice them as a list is
435
- /// sliced. The two ways cannot be mixed.
438
+ /// sliced. The two ways cannot be mixed, and only `frames` can be
439
+ /// approximate.
436
440
  fn selection(
437
441
  frames: Option<Vec<isize>>,
438
442
  start: isize,
439
443
  stop: Option<isize>,
440
444
  step: isize,
445
+ approximate: Option<&Bound<'_, PyAny>>,
441
446
  ) -> PyResult<Selection> {
442
447
  let sliced = start != 0 || stop.is_some() || step != 1;
448
+ let approximate = tolerance(approximate)?;
443
449
  match frames {
444
450
  Some(_) if sliced => Err(PyValueError::new_err(
445
451
  "frames does not go with start, stop, or step",
446
452
  )),
447
- Some(frames) => Ok(Selection::Frames(frames)),
453
+ Some(wanted) => Ok(Selection::Frames {
454
+ wanted,
455
+ approximate,
456
+ }),
457
+ None if approximate.is_some() => Err(PyValueError::new_err(
458
+ "approximate needs frames: a slice cannot be approximate",
459
+ )),
448
460
  None if step < 1 => Err(PyValueError::new_err("step must be at least 1")),
449
461
  // Frames counted from the first are read straight through, with
450
462
  // no index and no seeking.
@@ -461,6 +473,23 @@ fn selection(
461
473
  }
462
474
  }
463
475
 
476
+ /// How far a frame may move to the nearest key frame, in frames, from
477
+ /// what Python passes as `approximate`: `True` is any distance, a number
478
+ /// is at most that many frames, and `False` or `None` is no move at all.
479
+ fn tolerance(approximate: Option<&Bound<'_, PyAny>>) -> PyResult<Option<usize>> {
480
+ let Some(approximate) = approximate else {
481
+ return Ok(None);
482
+ };
483
+ // A bool is an int in Python, so it has to be read first.
484
+ if let Ok(any_distance) = approximate.extract::<bool>() {
485
+ return Ok(any_distance.then_some(usize::MAX));
486
+ }
487
+ let frames = approximate.extract::<usize>().map_err(|_| {
488
+ PyValueError::new_err("approximate must be True, False, or a number of frames")
489
+ })?;
490
+ Ok(Some(frames))
491
+ }
492
+
464
493
  /// The hardware devices of this build, by the names PyTorch gives them,
465
494
  /// which Python users know better than FFmpeg's.
466
495
  fn hardware_devices() -> impl Iterator<Item = (&'static str, ffmpeg::DeviceType)> {
@@ -108,3 +108,29 @@ def test_slice_from_the_middle_seeks_once(video_path):
108
108
  whole, second_half = read(), read(start=450)
109
109
  print(f"\nwhole {whole:.3f}s, start=450 {second_half:.3f}s")
110
110
  assert second_half < 0.75 * whole
111
+
112
+
113
+ def test_approximate_frames_decode_less(video_path):
114
+ """Approximate frames must cost one decoded frame each.
115
+
116
+ Reading frames in the middle of their group of pictures decodes every
117
+ frame from the key frame on, while the nearest key frames decode one
118
+ frame each. Both pay for the pass that indexes the file.
119
+ """
120
+ with av.open(str(video_path)) as container:
121
+ frames = list(container.decode(video=0))
122
+ keys = [index for index, frame in enumerate(frames) if frame.key_frame]
123
+ # Frames far enough into their group of pictures to cost several
124
+ # decoded frames each, which approximate reading skips.
125
+ wanted = [key + 20 for key in keys if key + 20 < len(frames)][:20]
126
+
127
+ def read(**arguments):
128
+ def decode():
129
+ for _ in iterframes.read(video_path, frames=wanted, **arguments):
130
+ pass
131
+
132
+ return best_of(decode)
133
+
134
+ exact, approximate = read(), read(approximate=True)
135
+ print(f"\nexact {exact:.3f}s, approximate {approximate:.3f}s")
136
+ assert approximate < exact