Skip to content

Commit e911ec3

Browse files
authored
feat(lib): smarter files reversing (#439)
* feat(lib): smarter files reversing Implement a smarter generation of reversed files by splitting the video into smaller segments. Closes #434 * chore(lib): change default length * chore: use suffix * chore(docs): update * chore(docs): fix docs and add changelog entry * chore(tests): coverage * chore(docs): typo
1 parent daf5474 commit e911ec3

File tree

6 files changed

+129
-8
lines changed

6 files changed

+129
-8
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
(unreleased)=
1111
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.4.2...HEAD)
1212

13+
(unreleased-added)=
14+
### Added
15+
16+
- Added `max_duration_before_split_reverse` and `num_processes` class variables.
17+
[#439](https://github.com/jeertmans/manim-slides/pull/439)
18+
19+
(unreleased-changed)=
20+
### Changed
21+
22+
- Automatically split large video animations into smaller chunks
23+
for lightweight (and potentially faster) reversed animations generation.
24+
[#439](https://github.com/jeertmans/manim-slides/pull/439)
25+
1326
(v5.4.2)=
1427
## [v5.4.2](https://github.com/jeertmans/manim-slides/compare/v5.4.1...v5.4.2)
1528

manim_slides/slide/base.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class BaseSlide:
3636
disable_caching: bool = False
3737
flush_cache: bool = False
3838
skip_reversing: bool = False
39+
max_duration_before_split_reverse: float | None = 4.0
40+
num_processes: int | None = None
3941

4042
def __init__(
4143
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
@@ -530,10 +532,11 @@ def _save_slides(
530532

531533
for pre_slide_config in tqdm(
532534
self._slides,
533-
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
535+
desc=f"Concatenating animations to '{scene_files_folder}' and generating reversed animations",
534536
leave=self._leave_progress_bar,
535537
ascii=True if platform.system() == "Windows" else None,
536538
disable=not self._show_progress_bar,
539+
unit=" slides",
537540
):
538541
if pre_slide_config.skip_animations:
539542
continue
@@ -557,7 +560,15 @@ def _save_slides(
557560
if skip_reversing:
558561
rev_file = dst_file
559562
else:
560-
reverse_video_file(dst_file, rev_file)
563+
reverse_video_file(
564+
dst_file,
565+
rev_file,
566+
max_segment_duration=self.max_duration_before_split_reverse,
567+
num_processes=self.num_processes,
568+
leave=self._leave_progress_bar,
569+
ascii=True if platform.system() == "Windows" else None,
570+
disable=not self._show_progress_bar,
571+
)
561572

562573
slides.append(
563574
SlideConfig.from_pre_slide_config_and_files(

manim_slides/slide/manim.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
class Slide(BaseSlide, Scene): # type: ignore[misc]
1313
"""
14-
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
14+
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provides necessary tools
1515
for slides rendering.
1616
1717
:param args: Positional arguments passed to scene object.
@@ -20,15 +20,26 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
2020
:cvar bool disable_caching: :data:`False`: Whether to disable the use of
2121
cached animation files.
2222
:cvar bool flush_cache: :data:`False`: Whether to flush the cache.
23-
2423
Unlike with Manim, flushing is performed before rendering.
2524
:cvar bool skip_reversing: :data:`False`: Whether to generate reversed animations.
26-
2725
If set to :data:`False`, and no cached reversed animation
2826
exists (or caching is disabled) for a given slide,
2927
then the reversed animation will be simply the same
3028
as the original one, i.e., ``rev_file = file``,
3129
for the current slide config.
30+
:cvar typing.Optional[float] max_duration_before_split_reverse: :data:`4.0`: Maximum duration
31+
before of a video animation before it is reversed by splitting the file into smaller chunks.
32+
Generating reversed animations can require an important amount of
33+
memory (because the whole video needs to be kept in memory),
34+
and splitting the video into multiple chunks usually speeds
35+
up the process (because it can be done in parallel) while taking
36+
less memory.
37+
Set this to :data:`None` to disable splitting the file into chunks.
38+
:cvar typing.Optional[int] num_processes: :data:`None`: Number of processes
39+
to use for parallelizable operations.
40+
If :data:`None`, defaults to :func:`os.process_cpu_count`.
41+
This is currently used when generating reversed animations, and can
42+
increase memory consumption.
3243
"""
3344

3445
def __init__(self, *args: Any, **kwargs: Any) -> None:

manim_slides/utils.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
import os
33
import tempfile
44
from collections.abc import Iterator
5+
from multiprocessing import Pool
56
from pathlib import Path
7+
from typing import Any, Optional
68

79
import av
10+
from tqdm import tqdm
811

912
from .logger import logger
1013

@@ -89,8 +92,9 @@ def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
8992
c.link_to(n)
9093

9194

92-
def reverse_video_file(src: Path, dest: Path) -> None:
95+
def reverse_video_file_in_one_chunk(src_and_dest: tuple[Path, Path]) -> None:
9396
"""Reverses a video file, writing the result to `dest`."""
97+
src, dest = src_and_dest
9498
with (
9599
av.open(str(src)) as input_container,
96100
av.open(str(dest), mode="w") as output_container,
@@ -120,8 +124,70 @@ def reverse_video_file(src: Path, dest: Path) -> None:
120124

121125
for _ in range(frames_count):
122126
frame = graph.pull()
123-
frame.pict_type = 5 # Otherwise we get a warning saying it is changed
127+
frame.pict_type = "NONE" # Otherwise we get a warning saying it is changed
124128
output_container.mux(output_stream.encode(frame))
125129

126130
for packet in output_stream.encode():
127131
output_container.mux(packet)
132+
133+
134+
def reverse_video_file(
135+
src: Path,
136+
dest: Path,
137+
max_segment_duration: Optional[float] = 4.0,
138+
num_processes: Optional[int] = None,
139+
**tqdm_kwargs: Any,
140+
) -> None:
141+
"""Reverses a video file, writing the result to `dest`."""
142+
with av.open(str(src)) as input_container: # Fast path if file is short enough
143+
input_stream = input_container.streams.video[0]
144+
if max_segment_duration is None:
145+
return reverse_video_file_in_one_chunk((src, dest))
146+
elif input_stream.duration:
147+
if (
148+
float(input_stream.duration * input_stream.time_base)
149+
<= max_segment_duration
150+
):
151+
return reverse_video_file_in_one_chunk((src, dest))
152+
else: # pragma: no cover
153+
logger.debug(
154+
f"Could not determine duration of {src}, falling back to segmentation."
155+
)
156+
157+
with tempfile.TemporaryDirectory() as tmpdirname:
158+
tmpdir = Path(tmpdirname)
159+
with av.open(
160+
str(tmpdir / f"%04d.{src.suffix}"),
161+
"w",
162+
format="segment",
163+
options={"segment_time": str(max_segment_duration)},
164+
) as output_container:
165+
output_stream = output_container.add_stream(
166+
template=input_stream,
167+
)
168+
169+
for packet in input_container.demux(input_stream):
170+
if packet.dts is None:
171+
continue
172+
173+
packet.stream = output_stream
174+
output_container.mux(packet)
175+
176+
src_files = list(tmpdir.iterdir())
177+
rev_files = [
178+
src_file.with_stem("rev_" + src_file.stem) for src_file in src_files
179+
]
180+
181+
with Pool(num_processes, maxtasksperchild=1) as pool:
182+
for _ in tqdm(
183+
pool.imap_unordered(
184+
reverse_video_file_in_one_chunk, zip(src_files, rev_files)
185+
),
186+
desc="Reversing large file by cutting it in segments",
187+
total=len(src_files),
188+
unit=" files",
189+
**tqdm_kwargs,
190+
):
191+
pass # We just consume the iterator
192+
193+
concatenate_video_files(rev_files[::-1], dest)

tests/test_slide.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,26 @@ def construct(self) -> None:
314314
self.play(dot.animate.move_to(LEFT))
315315
self.play(dot.animate.move_to(DOWN))
316316

317+
def test_split_reverse(self) -> None:
318+
@assert_renders
319+
class _(CESlide):
320+
max_duration_before_split_reverse = 3.0
321+
322+
def construct(self) -> None:
323+
self.wait(2.0)
324+
for _ in range(3):
325+
self.next_slide()
326+
self.wait(10.0)
327+
328+
@assert_renders
329+
class __(CESlide):
330+
max_duration_before_split_reverse = None
331+
332+
def construct(self) -> None:
333+
self.wait(5.0)
334+
self.next_slide()
335+
self.wait(5.0)
336+
317337
def test_file_too_long(self) -> None:
318338
@assert_renders
319339
class _(CESlide):

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)