diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a5c104..d0dff85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (unreleased)= ## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.2...HEAD) +(unreleased-added)= +### Added + +- Introduced `Slide.next_subsection()` to record intra-slide checkpoints within slides. + Subsections create vertical navigation in HTML/RevealJS presentations and pause points + in the Qt presenter and other formats. +- Added `--subsections [none|all]` flag to `manim-slides present` and + `manim-slides convert` commands. Default is `all`, which enables subsection + handling. Use `none` to disable subsections and treat them as regular slides. +- HTML/RevealJS exports now create nested vertical slides for subsections, providing + hierarchical 2D navigation (horizontal for slides, vertical for subsections). + (unreleased-changed)= ### Changed - - Sort the scenes alphabetically when listing scenes (e.g., when prompting for scenes with `manim-slides present`). [@msaadsbr](https://github.com/msaadsbr) [#573](https://github.com/jeertmans/manim-slides/pull/573) diff --git a/README.md b/README.md index 25163e59..de791352 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,11 @@ The documentation is available [online](https://eertmans.be/manim-slides/). Call `self.next_slide()` every time you want to create a pause between animations, and `self.next_slide(loop=True)` if you want the next slide to loop -over animations until the user presses continue: +over animations until the user presses continue. +Use `self.next_subsection()` when you need multiple presenter-controlled pause +points inside a single slide (similar to Beamer overlays). Subsections +automatically work in the Qt/HTML presenters and can be exported to PDF or +PowerPoint with additional flags: ```python from manim import * # or: from manimlib import * diff --git a/docs/source/reference/api.md b/docs/source/reference/api.md index 25d2ae1f..147a40dc 100644 --- a/docs/source/reference/api.md +++ b/docs/source/reference/api.md @@ -15,6 +15,7 @@ use, not the methods used internally when rendering. canvas, canvas_mobjects, mobjects_without_canvas, + next_subsection, next_section, next_slide, remove_from_canvas, diff --git a/docs/source/reference/examples.md b/docs/source/reference/examples.md index d8dbcaca..183378ab 100644 --- a/docs/source/reference/examples.md +++ b/docs/source/reference/examples.md @@ -156,6 +156,43 @@ directly write the `construct` method in the body of `MovingCameraSlide`. self.wait() ``` +## Subsection Example + +Example demonstrating the use of subsections to create vertical slides in HTML presentations. + +**Key concept**: Subsections create **vertical navigation** in RevealJS presentations. +Use `next_slide()` to move horizontally between main topics, and `next_subsection()` +to create vertical steps within a single topic. This provides hierarchical structure +to your presentations. + +```{eval-rst} +.. manim-slides:: ../../../example.py:SubsectionExample + :hide_source: + :quality: high + +.. literalinclude:: ../../../example.py + :language: python + :linenos: + :pyobject: SubsectionExample +``` + +This example creates **three horizontal slides**: +- **Slide 1**: Title slide (use RIGHT arrow to advance) +- **Slide 2**: Building a Diagram topic with **four vertical subsections** (use DOWN arrow): + 1. Shows the title + 2. Adds a circle + 3. Adds a square + 4. Adds labels with `auto_next=True` to auto-advance + 5. Final state after all animations +- **Slide 3**: Transformations topic with **three vertical subsections** + +In HTML export, subsections become **vertical slides** that you navigate with UP/DOWN +arrows, while regular slides remain horizontal (LEFT/RIGHT arrows). This creates a +2D presentation structure where subsections group related content under a main slide. + +Note: In the Qt presenter and other formats, subsections create pause points instead +of vertical navigation. + ## Advanced Example A more advanced example is `ConvertExample`, which is used as demo slide and tutorial. diff --git a/docs/source/reference/sharing.md b/docs/source/reference/sharing.md index 9b5e093d..6d3b04c3 100644 --- a/docs/source/reference/sharing.md +++ b/docs/source/reference/sharing.md @@ -14,12 +14,40 @@ In the next sections, we will assume your animations are described in `example.py`, and you have one presentation called `BasicExample`. ::: +## Understanding Subsections + +Before sharing slides, it's important to understand **subsections**. Subsections +allow multiple pause points within a single slide, useful for step-by-step reveals: + +- `next_subsection()`: **Accumulates** content (keeps building on what's there) +- `next_slide()`: **Clears** content (starts fresh) + +All presentation modes support subsections with the `--subsections` flag: + +- `--subsections=all` (default): Enable subsections (pause at each subsection/create separate pages) +- `--subsections=none`: Ignore subsections (play through/show final state only) + ## With Manim Slides installed on the target machine If Manim Slides, Manim (or ManimGL), and their dependencies are installed, then using `manim-slides present` allows for the best presentations, with the most options available. +### Controlling Subsection Behavior + +When presenting with the Qt GUI, you can control how subsections behave: + +```bash +# Default: pause at each subsection, press key to advance +manim-slides present BasicExample + +# Explicit (same as default) +manim-slides present BasicExample --subsections=all + +# Play through entire slide without pausing at subsections +manim-slides present BasicExample --subsections=none +``` + ### Sharing your Python file(s) The lightest way to share your presentation is with the Python files that @@ -85,9 +113,18 @@ First, you need to create the HTML file and its assets directory. Example: ```bash +# Default: subsections create pause points (press arrow keys to advance) manim-slides convert BasicExample basic_example.html + +# Without subsections: play through entire slide +manim-slides convert BasicExample basic_example.html --subsections=none ``` +**Limitation:** When you skim backwards through subsections in HTML/RevealJS, +each subsection clip restarts from its beginning. Unlike the Qt presenter, the +final accumulated state is not frozen when rewinding, so expect a short replay +when stepping back. + Then, you need to copy the HTML files and its assets directory to target location, while keeping the relative path between the HTML and the assets the same. The easiest solution is to compress both the file and the directory into one ZIP, @@ -172,10 +209,14 @@ A convenient conversion feature is to the PowerPoint format, thanks to the it is still considered in an *EXPERIMENTAL* status because we do not exactly know what versions of PowerPoint (or LibreOffice Impress) are supported. -Basically, you can create a PowerPoint in a single command: +You can create a PowerPoint in a single command: ```bash +# PowerPoint currently exports one slide per manim slide (subsections disabled) manim-slides convert --to=pptx BasicExample basic_example.pptx + +# Explicitly silence the warning by disabling subsections yourself +manim-slides convert --to=pptx BasicExample basic_example.pptx --subsections=none ``` All the videos and necessary files will be contained inside the `.pptx` file, so @@ -183,6 +224,11 @@ you can safely share it with anyone. By default, the `poster_frame_image`, i.e., what is displayed by PowerPoint when the video is not playing, is the first frame of each slide. This allows for smooth transitions. +**Subsection handling:** PowerPoint export currently forces `--subsections=none`. +Passing `--subsections=all` logs a warning and behaves the same. The HTML/RevealJS +and PDF exporters still honor `--subsections=all`, so use those formats when you +need to step through subsections. + In the future, we hope to provide more features to this format, so feel free to suggest new features too! @@ -192,10 +238,18 @@ If you ever need backup slides, that are only made of PDF pages with static images, you can generate such a PDF with the following command: ```bash +# Default: subsections become separate PDF pages manim-slides convert --to=pdf BasicExample basic_example.pdf + +# Without subsections: one page per manim slide, final state only +manim-slides convert --to=pdf BasicExample basic_example.pdf --subsections=none ``` Note that you will lose all the benefits from animated slides. Therefore, -this is only recommended to be used as a backup plan. By default, the last frame -of each slide will be printed. This can be changed to be the first one with -`-cframe_index=first`. +this is only recommended to be used as a backup plan. + +**Subsection handling:** By default (`--subsections=all`), each subsection becomes +a separate PDF page, showing the progressive build-up. Use `--subsections=none` +to create one page per manim slide showing only the final state. The frame index +option (`-cframe_index=first` or `last`) controls which frame is captured when +subsections are not used. diff --git a/example.py b/example.py index c980b85f..db8c8df5 100644 --- a/example.py +++ b/example.py @@ -224,6 +224,54 @@ def construct(self): self.play(Transform(square, learn_more_text)) +class SubsectionExample(Slide): + def construct(self): + self.wait_time_between_slides = 0.1 # Show completed frames + + title = Text("Subsections Demo", color=YELLOW) + self.play(Write(title)) + self.next_slide() + + self.clear() + title = Text("Building a Diagram", font_size=36, color=YELLOW).to_edge(UP) + self.play(Write(title)) + self.next_subsection(name="Show title") + + circle = Circle(radius=1, color=BLUE).shift(LEFT * 2) + self.play(Create(circle)) + self.next_subsection(name="Add circle") + + square = Square(side_length=2, color=RED).shift(RIGHT * 2) + self.play(Create(square)) + self.next_subsection(name="Add square") + + circle_label = Text("Circle", font_size=20).next_to(circle, DOWN) + square_label = Text("Square", font_size=20).next_to(square, DOWN) + self.play(Write(circle_label), Write(square_label)) + self.next_subsection(name="Add labels", auto_next=True) + + arrow = Arrow(circle.get_right(), square.get_left(), buff=0.1, color=GREEN) + arrow_label = Text("Connection", font_size=16).next_to(arrow, UP) + self.play(Create(arrow), Write(arrow_label)) + self.next_slide() + + self.clear() + title = Text("Transformations", font_size=36, color=YELLOW).to_edge(UP) + self.play(Write(title)) + self.next_subsection(name="Show title") + + dot = Dot(color=ORANGE).shift(DOWN) + self.play(FadeIn(dot)) + self.next_subsection(name="Add dot") + + triangle = Triangle(color=PURPLE).shift(UP) + self.play(Create(triangle)) + self.next_subsection(name="Add triangle") + + self.play(dot.animate.move_to(triangle.get_center())) + self.next_slide() + + # For ThreeDExample, things are different if not MANIMGL: diff --git a/manim_slides/config.py b/manim_slides/config.py index dfe889ea..35d1495d 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -1,5 +1,6 @@ import json import shutil +from collections.abc import Sequence from functools import wraps from inspect import Parameter, signature from pathlib import Path @@ -11,6 +12,7 @@ BaseModel, Field, FilePath, + NonNegativeInt, PositiveInt, PrivateAttr, conset, @@ -151,6 +153,40 @@ def merge_with(self, other: "Config") -> "Config": return self +class SubsectionMarker(BaseModel): # type: ignore[misc] + """User-authored subsection boundary recorded before rendering.""" + + animation_index: NonNegativeInt + name: str = "" + auto_next: bool = False + + +class SubsectionConfig(BaseModel): # type: ignore[misc] + """Fully resolved subsection configuration stored after rendering.""" + + name: str = "" + auto_next: bool = False + start_animation: NonNegativeInt + end_animation: NonNegativeInt + start_time: float = Field(0.0, ge=0.0) + end_time: float = Field(0.0, ge=0.0) + file: Optional[FilePath] = Field( + None, + description="Path to the partial animation file for this subsection. " + "Only set when subsection contains exactly one animation.", + ) + + @model_validator(mode="after") + def animations_are_monotone(self) -> "SubsectionConfig": + if self.end_animation < self.start_animation: + raise ValueError( + "end_animation must be greater or equal to start_animation" + ) + if self.end_time < self.start_time: + raise ValueError("end_time must be greater or equal to start_time") + return self + + class BaseSlideConfig(BaseModel): # type: ignore """Base class for slide config.""" @@ -220,6 +256,7 @@ class PreSlideConfig(BaseSlideConfig): start_animation: int end_animation: int + subsection_markers: tuple[SubsectionMarker, ...] = Field(default_factory=tuple) @classmethod def from_base_slide_config_and_animation_indices( @@ -227,10 +264,13 @@ def from_base_slide_config_and_animation_indices( base_slide_config: BaseSlideConfig, start_animation: int, end_animation: int, + *, + subsection_markers: Sequence[SubsectionMarker] = (), ) -> "PreSlideConfig": return cls( start_animation=start_animation, end_animation=end_animation, + subsection_markers=tuple(subsection_markers), **base_slide_config.model_dump(), ) @@ -280,12 +320,23 @@ class SlideConfig(BaseSlideConfig): file: FilePath rev_file: FilePath + subsections: tuple[SubsectionConfig, ...] = Field(default_factory=tuple) @classmethod def from_pre_slide_config_and_files( - cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path + cls, + pre_slide_config: PreSlideConfig, + file: Path, + rev_file: Path, + *, + subsections: Sequence[SubsectionConfig] = (), ) -> "SlideConfig": - return cls(file=file, rev_file=rev_file, **pre_slide_config.model_dump()) + return cls( + file=file, + rev_file=rev_file, + subsections=tuple(subsections), + **pre_slide_config.model_dump(exclude={"subsection_markers"}), + ) class PresentationConfig(BaseModel): # type: ignore[misc] diff --git a/manim_slides/convert.py b/manim_slides/convert.py index df6e4a15..b852c602 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -39,9 +39,10 @@ from . import templates from .commons import folder_path_option, verbosity_option -from .config import PresentationConfig +from .config import PresentationConfig, SlideConfig from .logger import logger from .present import get_scenes_presentation_config +from .utils import get_duration_ms, get_duration_seconds def open_with_default(file: Path) -> None: @@ -81,14 +82,6 @@ def file_to_data_uri(file: Path) -> str: return f"data:{mime_type};base64,{b64}" -def get_duration_ms(file: Path) -> float: - """Read a video and return its duration in milliseconds.""" - with av.open(str(file)) as container: - video = container.streams.video[0] - - return float(1000 * video.duration * video.time_base) - - def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image: """Read a image from a video file at a given index.""" with av.open(str(file)) as container: @@ -102,6 +95,19 @@ def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image: return frame.to_image() +def read_image_from_video_timestamp(file: Path, timestamp: float) -> Image: + """Read an image from a video file at a given timestamp (in seconds).""" + with av.open(str(file)) as container: + stream = container.streams.video[0] + seek_pts = int(max(timestamp, 0.0) / stream.time_base) + container.seek(seek_pts, stream=stream, any_frame=True, backward=True) + for frame in container.decode(video=0): + frame_time = frame.time or 0.0 + if frame_time >= timestamp or timestamp == 0.0: + return frame.to_image() + return read_image_from_video_file(file, FrameIndex.last) + + class Converter(BaseModel): # type: ignore presentation_configs: list[PresentationConfig] assets_dir: str = Field( @@ -165,6 +171,19 @@ def __str__(self) -> str: Function = str # Basically, anything +class FrameIndex(str, Enum): + first = "first" + last = "last" + + def __repr__(self) -> str: + return self.value + + +class SubsectionMode(str, Enum): + none = "none" + all = "all" + + class JsTrue(str, StrEnum): true = "true" @@ -536,6 +555,10 @@ class RevealJS(Converter): RevealTheme.black, description="RevealJS version." ) title: str = Field("Manim Slides", description="Presentation title.") + subsection_mode: SubsectionMode = Field( + SubsectionMode.all, + description="How subsections should be handled: 'none' or 'all'.", + ) # Pydantic options model_config = ConfigDict(use_enum_values=True, extra="forbid") @@ -549,6 +572,55 @@ def load_template(self) -> str: def open(self, file: Path) -> None: webbrowser.open(file.absolute().as_uri()) + def _iter_slide_sections(self, slide_config: SlideConfig) -> list[dict[str, Any]]: + """Generate section data for template rendering.""" + if self.subsection_mode == SubsectionMode.none or not slide_config.subsections: + return [ + { + "file": slide_config.file, + "loop": slide_config.loop, + "auto_next": slide_config.auto_next, + "notes": slide_config.notes, + "start_time": None, + "end_time": None, + } + ] + + sections = [] + last_end = 0.0 + for index, subsection in enumerate(slide_config.subsections): + fragment_file = Path( + f"{slide_config.file.stem}_sub_{index}{slide_config.file.suffix}" + ) + sections.append( + { + "file": fragment_file, + "loop": False, + "auto_next": subsection.auto_next, + "notes": f"{slide_config.notes}\n\n{subsection.name}" + if slide_config.notes and subsection.name + else subsection.name or slide_config.notes, + "start_time": None, + "end_time": None, + } + ) + last_end = subsection.end_time + + video_duration = get_duration_seconds(slide_config.file) + if video_duration - last_end > 1e-3: + tail_file = Path(f"{slide_config.file.stem}_tail{slide_config.file.suffix}") + sections.append( + { + "file": tail_file, + "loop": slide_config.loop, + "auto_next": False, + "notes": slide_config.notes, + "start_time": None, + "end_time": None, + } + ) + return sections + def convert_to(self, dest: Path) -> None: # noqa: C901 """ Convert this configuration into a RevealJS HTML presentation, saved to @@ -586,10 +658,62 @@ def prefix(i: int) -> str: return "" full_assets_dir.mkdir(parents=True, exist_ok=True) - for i, presentation_config in enumerate(self.presentation_configs): - presentation_config.copy_to( - full_assets_dir, include_reversed=False, prefix=prefix(i) - ) + + if self.subsection_mode == SubsectionMode.all: + for i, presentation_config in enumerate(self.presentation_configs): + for slide_config in presentation_config.slides: + if slide_config.subsections: + for index, subsection in enumerate( + slide_config.subsections + ): + if subsection.file: + dest_file = full_assets_dir / ( + prefix(i) + + f"{slide_config.file.stem}_sub_{index}{subsection.file.suffix}" + ) + if not dest_file.exists(): + shutil.copy(subsection.file, dest_file) + + # Extract tail segment (remaining content after last subsection) + last_subsection = slide_config.subsections[-1] + video_duration = get_duration_seconds(slide_config.file) + if video_duration - last_subsection.end_time > 1e-3: + tail_file = full_assets_dir / ( + prefix(i) + + f"{slide_config.file.stem}_tail{slide_config.file.suffix}" + ) + if not tail_file.exists(): + # Use ffmpeg to extract tail segment (re-encode for accurate timing) + subprocess.run( + [ + "ffmpeg", + "-i", + str(slide_config.file), + "-ss", + str(last_subsection.end_time), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "23", + "-y", + str(tail_file), + ], + check=True, + capture_output=True, + ) + else: + dest_file = full_assets_dir / ( + prefix(i) + slide_config.file.name + ) + if not dest_file.exists(): + shutil.copy(slide_config.file, dest_file) + else: + for i, presentation_config in enumerate(self.presentation_configs): + presentation_config.copy_to( + full_assets_dir, include_reversed=False, prefix=prefix(i) + ) dest.parent.mkdir(parents=True, exist_ok=True) @@ -603,16 +727,34 @@ def prefix(i: int) -> str: if assets_dir is not None: options["assets_dir"] = assets_dir + # Build enriched presentation data with subsection expansion + enriched_presentations = [] + for presentation_config in self.presentation_configs: + enriched_slides = [] + for slide_config in presentation_config.slides: + sections = self._iter_slide_sections(slide_config) + enriched_slides.append( + {"slide_config": slide_config, "sections": sections} + ) + enriched_presentations.append( + { + "presentation_config": presentation_config, + "enriched_slides": enriched_slides, + } + ) + has_notes = any( - slide_config.notes != "" - for presentation_config in self.presentation_configs - for slide_config in presentation_config.slides + section["notes"] + for pres in enriched_presentations + for slide in pres["enriched_slides"] + for section in slide["sections"] ) content = revealjs_template.render( file_to_data_uri=file_to_data_uri, get_duration_ms=get_duration_ms, has_notes=has_notes, + enriched_presentations=enriched_presentations, env=os.environ, prefix=prefix if not self.one_file else None, **options, @@ -679,14 +821,6 @@ def convert_to(self, dest: Path) -> None: shutil.make_archive(str(dest.with_suffix("")), "zip", directory_name) -class FrameIndex(str, Enum): - first = "first" - last = "last" - - def __repr__(self) -> str: - return self.value - - class PDF(Converter): frame_index: FrameIndex = Field( FrameIndex.last, @@ -695,6 +829,10 @@ class PDF(Converter): resolution: PositiveFloat = Field( 100.0, description="Image resolution use for saving frames." ) + subsection_mode: SubsectionMode = Field( + SubsectionMode.all, + description="How subsections should be exported: 'none' or 'all'.", + ) model_config = ConfigDict(use_enum_values=True, extra="forbid") def convert_to(self, dest: Path) -> None: @@ -707,9 +845,7 @@ def convert_to(self, dest: Path) -> None: desc=f"Generating video slides for config {i + 1}", leave=False, ): - images.append( - read_image_from_video_file(slide_config.file, self.frame_index) - ) + images.extend(self._images_for_slide(slide_config)) dest.parent.mkdir(parents=True, exist_ok=True) @@ -721,6 +857,37 @@ def convert_to(self, dest: Path) -> None: append_images=images[1:], ) + def _images_for_slide(self, slide_config: SlideConfig) -> list[Image]: + if self.subsection_mode == SubsectionMode.all and slide_config.subsections: + frames = [] + for index, subsection in enumerate(slide_config.subsections): + if subsection.file: + # WORKAROUND: Use first frame of NEXT subsection to show completion. + # Manim animations stop at ~93% completion, so last frame shows incomplete state. + # The wait() call in next_subsection() creates the completion frame as the next file. + if ( + index + 1 < len(slide_config.subsections) + and slide_config.subsections[index + 1].file + ): + # Use first frame of next subsection (completion frame from wait()) + frames.append( + read_image_from_video_file( + slide_config.subsections[index + 1].file, + FrameIndex.first, + ) + ) + else: + # Last subsection: use slide's first/last frame per user preference + frames.append(self._frame_for_slide(slide_config)) + if not frames: + frames.append(self._frame_for_slide(slide_config)) + return frames + + return [self._frame_for_slide(slide_config)] + + def _frame_for_slide(self, slide_config: SlideConfig) -> Image: + return read_image_from_video_file(slide_config.file, self.frame_index) + class PowerPoint(Converter): left: PositiveInt = Field( @@ -745,8 +912,22 @@ class PowerPoint(Converter): description="Optional image to use when animations are not playing.\n" "By default, the first frame of each animation is used.\nThis is important to avoid blinking effects between slides.", ) + subsection_mode: SubsectionMode = Field( + SubsectionMode.all, + description="How subsections translate to PowerPoint slides: 'none' or 'all'.", + ) model_config = ConfigDict(use_enum_values=True, extra="forbid") + def model_post_init( + self, __context: Any + ) -> None: # pragma: no cover - pydantic hook + """Force subsection_mode to 'none' until subsections are supported for PPTX.""" + if self.subsection_mode == SubsectionMode.all: + logger.warning( + "PowerPoint export does not yet support subsection_mode='all'; falling back to 'none'." + ) + object.__setattr__(self, "subsection_mode", SubsectionMode.none) + def convert_to(self, dest: Path) -> None: """Convert this configuration into a PowerPoint presentation, saved to DEST.""" prs = pptx.Presentation() @@ -776,6 +957,109 @@ def xpath(el: etree.Element, query: str) -> etree.XPath: nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} return etree.ElementBase.xpath(el, query, namespaces=nsmap) + def add_click_effect_to_video( + slide_element: etree.Element, video_id: str, next_ctn_id: int + ) -> int: + nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} + p_ns = "{%s}" % nsmap["p"] + + timing = xpath(slide_element, ".//p:timing")[0] + tnLst = xpath(timing, ".//p:tnLst")[0] + par = xpath(tnLst, ".//p:par")[0] + root_cTn = xpath(par, ".//p:cTn[@nodeType='tmRoot']")[0] + childTnLst = xpath(root_cTn, ".//p:childTnLst")[0] + + seq = xpath(childTnLst, ".//p:seq") + if not seq: + seq_elem = etree.Element(f"{p_ns}seq") + mainSeq_cTn = etree.SubElement(seq_elem, f"{p_ns}cTn") + mainSeq_cTn.set("id", str(next_ctn_id)) + mainSeq_cTn.set("dur", "indefinite") + mainSeq_cTn.set("nodeType", "mainSeq") + next_ctn_id += 1 + + mainSeq_childTnLst = etree.SubElement(mainSeq_cTn, f"{p_ns}childTnLst") + + prevCondLst = etree.SubElement(seq_elem, f"{p_ns}prevCondLst") + cond_prev = etree.SubElement(prevCondLst, f"{p_ns}cond") + cond_prev.set("evt", "onPrev") + tgtEl_prev = etree.SubElement(cond_prev, f"{p_ns}tgtEl") + etree.SubElement(tgtEl_prev, f"{p_ns}sldTgt") + + nextCondLst = etree.SubElement(seq_elem, f"{p_ns}nextCondLst") + cond_next = etree.SubElement(nextCondLst, f"{p_ns}cond") + cond_next.set("evt", "onNext") + tgtEl_next = etree.SubElement(cond_next, f"{p_ns}tgtEl") + etree.SubElement(tgtEl_next, f"{p_ns}sldTgt") + + childTnLst.append(seq_elem) + else: + mainSeq_cTn = xpath(seq[0], ".//p:cTn[@nodeType='mainSeq']")[0] + mainSeq_childTnLst = xpath(mainSeq_cTn, ".//p:childTnLst")[0] + + par_wrapper = etree.Element(f"{p_ns}par") + cTn_wrapper = etree.SubElement(par_wrapper, f"{p_ns}cTn") + cTn_wrapper.set("id", str(next_ctn_id)) + cTn_wrapper.set("fill", "hold") + next_ctn_id += 1 + + stCondLst = etree.SubElement(cTn_wrapper, f"{p_ns}stCondLst") + cond = etree.SubElement(stCondLst, f"{p_ns}cond") + cond.set("delay", "indefinite") + + childTnLst_inner = etree.SubElement(cTn_wrapper, f"{p_ns}childTnLst") + par_inner = etree.SubElement(childTnLst_inner, f"{p_ns}par") + cTn_inner = etree.SubElement(par_inner, f"{p_ns}cTn") + cTn_inner.set("id", str(next_ctn_id)) + cTn_inner.set("fill", "hold") + next_ctn_id += 1 + + stCondLst_inner = etree.SubElement(cTn_inner, f"{p_ns}stCondLst") + cond_inner = etree.SubElement(stCondLst_inner, f"{p_ns}cond") + cond_inner.set("delay", "0") + + childTnLst_effect = etree.SubElement(cTn_inner, f"{p_ns}childTnLst") + par_effect = etree.SubElement(childTnLst_effect, f"{p_ns}par") + cTn_effect = etree.SubElement(par_effect, f"{p_ns}cTn") + cTn_effect.set("id", str(next_ctn_id)) + cTn_effect.set("nodeType", "clickEffect") + cTn_effect.set("fill", "hold") + cTn_effect.set("presetClass", "entr") + cTn_effect.set("presetID", "1") + next_ctn_id += 1 + + stCondLst_effect = etree.SubElement(cTn_effect, f"{p_ns}stCondLst") + cond_effect = etree.SubElement(stCondLst_effect, f"{p_ns}cond") + cond_effect.set("delay", "0") + + childTnLst_set = etree.SubElement(cTn_effect, f"{p_ns}childTnLst") + set_elem = etree.SubElement(childTnLst_set, f"{p_ns}set") + cBhvr = etree.SubElement(set_elem, f"{p_ns}cBhvr") + cTn_bhvr = etree.SubElement(cBhvr, f"{p_ns}cTn") + cTn_bhvr.set("id", str(next_ctn_id)) + cTn_bhvr.set("dur", "1") + cTn_bhvr.set("fill", "hold") + next_ctn_id += 1 + + stCondLst_bhvr = etree.SubElement(cTn_bhvr, f"{p_ns}stCondLst") + cond_bhvr = etree.SubElement(stCondLst_bhvr, f"{p_ns}cond") + cond_bhvr.set("delay", "0") + + tgtEl = etree.SubElement(cBhvr, f"{p_ns}tgtEl") + spTgt = etree.SubElement(tgtEl, f"{p_ns}spTgt") + spTgt.set("spid", video_id) + + attrNameLst = etree.SubElement(cBhvr, f"{p_ns}attrNameLst") + attrName = etree.SubElement(attrNameLst, f"{p_ns}attrName") + attrName.text = "style.visibility" + + to_elem = etree.SubElement(set_elem, f"{p_ns}to") + strVal = etree.SubElement(to_elem, f"{p_ns}strVal") + strVal.set("val", "visible") + + mainSeq_childTnLst.append(par_wrapper) + return next_ctn_id + with tempfile.TemporaryDirectory() as directory_name: directory = Path(directory_name) frame_number = 0 @@ -785,40 +1069,123 @@ def xpath(el: etree.Element, query: str) -> etree.XPath: desc=f"Generating video slides for config {i + 1}", leave=False, ): - file = slide_config.file + fragments = self._iter_slide_fragments(slide_config, directory) - mime_type = mimetypes.guess_type(file)[0] + slide = prs.slides.add_slide(layout) - if self.poster_frame_image is None: - poster_frame_image = str(directory / f"{frame_number}.png") - image = read_image_from_video_file( - file, frame_index=FrameIndex.first - ) - image.save(poster_frame_image) + # Disable slide transitions to avoid black flashes + nsmap = { + "p": "http://schemas.openxmlformats.org/presentationml/2006/main" + } + transition = etree.SubElement( + slide.element, "{%s}transition" % nsmap["p"] + ) + etree.SubElement(transition, "{%s}cut" % nsmap["p"]) + movies = [] + for fragment_file, notes, loop_flag in fragments: + mime_type = mimetypes.guess_type(fragment_file)[0] + poster_frame_image = self._poster_frame_image_path( + fragment_file, directory, frame_number + ) frame_number += 1 - else: - poster_frame_image = str(self.poster_frame_image) - - slide = prs.slides.add_slide(layout) - movie = slide.shapes.add_movie( - str(file), - self.left, - self.top, - self.width * 9525, - self.height * 9525, - poster_frame_image=poster_frame_image, - mime_type=mime_type, - ) - if slide_config.notes != "": - slide.notes_slide.notes_text_frame.text = slide_config.notes - if self.auto_play_media: - auto_play_media(movie, loop=slide_config.loop) + movie = slide.shapes.add_movie( + str(fragment_file), + self.left, + self.top, + self.width * 9525, + self.height * 9525, + poster_frame_image=poster_frame_image, + mime_type=mime_type, + ) + movies.append((movie, notes, loop_flag)) + + if movies: + notes_parts = [n for _, n, _ in movies if n] + if notes_parts: + slide.notes_slide.notes_text_frame.text = "\n\n".join( + notes_parts + ) + + if len(movies) == 1: + if self.auto_play_media: + auto_play_media(movies[0][0], loop=movies[0][2]) + else: + if self.auto_play_media: + auto_play_media(movies[0][0], loop=movies[0][2]) + + for movie, _, _ in movies[1:]: + video_id = xpath(movie.element, ".//p:cNvPr")[0].attrib[ + "id" + ] + timing = xpath(slide.element, ".//p:timing")[0] + childTnLst = xpath(timing, ".//p:childTnLst")[0] + video_nodes = xpath( + childTnLst, + f'.//p:video//p:spTgt[@spid="{video_id}"]/..', + ) + for video_node in video_nodes: + parent = video_node.getparent() + if parent is not None: + grandparent = parent.getparent() + if grandparent is not None: + grandparent.remove(parent) + + next_ctn_id = 3 + for movie, _, _ in movies[1:]: + video_id = xpath(movie.element, ".//p:cNvPr")[0].attrib[ + "id" + ] + next_ctn_id = add_click_effect_to_video( + slide.element, video_id, next_ctn_id + ) dest.parent.mkdir(parents=True, exist_ok=True) prs.save(dest) + def _poster_frame_image_path( + self, file: Path, directory: Path, frame_number: int + ) -> str: + if self.poster_frame_image is not None: + return str(self.poster_frame_image) + + poster_frame_image = str(directory / f"{frame_number}.png") + image = read_image_from_video_file(file, frame_index=FrameIndex.first) + image.save(poster_frame_image) + return poster_frame_image + + def _iter_slide_fragments( + self, slide_config: SlideConfig, directory: Path + ) -> list[tuple[Path, str, bool]]: + if self.subsection_mode == SubsectionMode.none or not slide_config.subsections: + return [(slide_config.file, slide_config.notes, slide_config.loop)] + + fragments: list[tuple[Path, str, bool]] = [] + base_notes = slide_config.notes.strip() + + for index, subsection in enumerate(slide_config.subsections): + if subsection.end_time <= 0: + continue + + if subsection.file: + fragment_file = ( + directory + / f"{slide_config.file.stem}_sub_{index}{subsection.file.suffix}" + ) + if not fragment_file.exists(): + shutil.copy(subsection.file, fragment_file) + + label = subsection.name or f"Subsection {index + 1}" + notes_parts = [part for part in (base_notes, label) if part] + notes_text = "\n\n".join(notes_parts) + fragments.append((fragment_file, notes_text, False)) + + if not fragments: + fragments.append((slide_config.file, slide_config.notes, slide_config.loop)) + + return fragments + def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: """Wrap a function to add a '--show-config' option.""" @@ -909,6 +1276,57 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: )(function) +def _determine_converter_class(to: str, dest: Path) -> type[Converter]: + """Determine the converter class from format string or destination path.""" + if to == "auto": + fmt = dest.suffix[1:].lower() + try: + return Converter.from_string(fmt) + except KeyError: + logger.warning( + f"Could not guess conversion format from {dest!s}, defaulting to HTML." + ) + return RevealJS + return Converter.from_string(to) + + +def _apply_config_options( + cls: type[Converter], + config_options: dict[str, str], + one_file: bool, + offline: bool, + subsections: str, +) -> None: + """Apply and validate configuration options for the converter.""" + if ( + one_file + and issubclass(cls, (RevealJS, HtmlZip)) + and "one_file" not in config_options + ): + config_options["one_file"] = "true" + + if "data_uri" in config_options: + warnings.warn( + "The 'data_uri' configuration option is deprecated and will be " + "removed in the next major version. Use 'one_file' instead.", + DeprecationWarning, + stacklevel=2, + ) + config_options["one_file"] = config_options.get( + "one_file" + ) or config_options.pop("data_uri") + + if ( + offline + and issubclass(cls, (RevealJS, HtmlZip)) + and "offline" not in config_options + ): + config_options["offline"] = "true" + + if issubclass(cls, (RevealJS, PDF, PowerPoint)): + config_options.setdefault("subsection_mode", subsections) + + @click.command() @click.argument("scenes", nargs=-1) @folder_path_option @@ -956,6 +1374,13 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: help="Download any remote content and store it in the assets folder. " "The is a convenient alias to '-coffline=true'.", ) +@click.option( + "--subsections", + type=click.Choice(["none", "all"], case_sensitive=False), + default="all", + show_default=True, + help="Control how subsections are rendered: 'none' ignores subsections, 'all' creates separate slides/pages per subsection.", +) @show_template_option @show_config_options @verbosity_option @@ -969,51 +1394,20 @@ def convert( template: Optional[Path], offline: bool, one_file: bool, + subsections: str, ) -> None: """Convert SCENE(s) into a given format and writes the result in DEST.""" presentation_configs = get_scenes_presentation_config(scenes, folder) try: - if to == "auto": - fmt = dest.suffix[1:].lower() - try: - cls = Converter.from_string(fmt) - except KeyError: - logger.warning( - f"Could not guess conversion format from {dest!s}, defaulting to HTML." - ) - cls = RevealJS - else: - cls = Converter.from_string(to) - - if ( - one_file - and issubclass(cls, (RevealJS, HtmlZip)) - and "one_file" not in config_options - ): - config_options["one_file"] = "true" - - # Change data_uri to one_file and print a warning if present - if "data_uri" in config_options: - warnings.warn( - "The 'data_uri' configuration option is deprecated and will be " - "removed in the next major version. " - "Use 'one_file' instead.", - DeprecationWarning, - stacklevel=2, - ) - config_options["one_file"] = ( - config_options["one_file"] - if "one_file" in config_options - else config_options.pop("data_uri") - ) - - if ( - offline - and issubclass(cls, (RevealJS, HtmlZip)) - and "offline" not in config_options - ): - config_options["offline"] = "true" + cls = _determine_converter_class(to, dest) + _apply_config_options( + cls, + config_options, + one_file, + offline, + subsections, + ) converter = cls( presentation_configs=presentation_configs, diff --git a/manim_slides/present/__init__.py b/manim_slides/present/__init__.py index f3a03466..680a84e9 100644 --- a/manim_slides/present/__init__.py +++ b/manim_slides/present/__init__.py @@ -241,6 +241,13 @@ def str_to_int_or_none(value: str) -> Optional[int]: "If there is more than one screen, it will by default put the info window " "on a different screen than the main player.", ) +@click.option( + "--subsections", + type=click.Choice(["none", "all"], case_sensitive=False), + default="all", + show_default=True, + help="Control subsections: 'none' disables, 'all' enables with pause at each subsection.", +) @click.help_option("-h", "--help") @verbosity_option def present( # noqa: C901 @@ -261,6 +268,7 @@ def present( # noqa: C901 next_terminates_loop: bool, hide_info_window: Optional[Literal["always", "never"]], info_window_screen_number: Optional[int], + subsections: str, ) -> None: """ Present SCENE(s), one at a time, in order. @@ -357,6 +365,7 @@ def get_screen(number: int) -> Optional[QScreen]: next_terminates_loop=next_terminates_loop, hide_info_window=should_hide_info_window, info_window_screen=info_window_screen, + subsection_mode=subsections, ) player.show(screens) diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py index 818b4120..2189b71d 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import Enum from pathlib import Path from typing import Optional @@ -14,13 +15,18 @@ QWidget, ) -from ..config import Config, PresentationConfig, SlideConfig +from ..config import Config, PresentationConfig, SlideConfig, SubsectionConfig from ..logger import logger from ..resources import * # noqa: F403 WINDOW_NAME = "Manim Slides" +class SubsectionMode(Enum): + none = "none" + all = "all" + + class Info(QWidget): # type: ignore[misc] key_press_event: Signal = Signal(QKeyEvent) close_event: Signal = Signal(QCloseEvent) @@ -180,12 +186,14 @@ def __init__( next_terminates_loop: bool = False, hide_info_window: bool = False, info_window_screen: Optional[QScreen] = None, + subsection_mode: str = SubsectionMode.all.value, ): super().__init__() # Wizard's config self.config = config + self.subsection_mode = SubsectionMode(subsection_mode) # Presentation configs @@ -197,6 +205,9 @@ def __init__( self.current_slide_index = slide_index self.__current_file: Path = self.current_slide_config.file + self._active_subsections: list[SubsectionConfig] = [] + self._current_subsection_index = -1 + self._pending_subsection_index: Optional[int] = None self.__playing_reversed_slide = False @@ -233,6 +244,7 @@ def __init__( self.media_player = QMediaPlayer(self) self.media_player.setAudioOutput(self.audio_output) self.media_player.setVideoOutput(self.video_widget) + self.media_player.positionChanged.connect(self._position_changed) self.playback_rate = playback_rate self.presentation_changed.connect(self.presentation_changed_callback) @@ -264,14 +276,16 @@ def __init__( self.exit_after_last_slide = exit_after_last_slide self.next_terminates_loop = next_terminates_loop + self.skip_all = skip_all # Setting-up everything if skip_all: def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: - self.media_player.setLoops(1) # Otherwise looping slides never end if status == QMediaPlayer.MediaStatus.EndOfMedia: + self._freeze_current_frame() + self.media_player.setLoops(1) # Otherwise looping slides never end self.load_next_slide() self.media_player.mediaStatusChanged.connect(media_status_changed) @@ -279,18 +293,16 @@ def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: else: def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: - if ( - status == QMediaPlayer.MediaStatus.EndOfMedia - and self.current_slide_config.auto_next - ): - self.load_next_slide() + if status == QMediaPlayer.MediaStatus.EndOfMedia: + self._freeze_current_frame() + if self.current_slide_config.auto_next: + self.load_next_slide() self.media_player.mediaStatusChanged.connect(media_status_changed) - if self.current_slide_config.loop: - self.media_player.setLoops(-1) - - self.load_current_media(start_paused=start_paused) + self.load_current_slide() + if start_paused: + self.media_player.pause() self.presentation_changed.emit() self.slide_changed.emit() @@ -409,6 +421,7 @@ def load_current_media(self, start_paused: bool = False) -> None: def load_current_slide(self) -> None: slide_config = self.current_slide_config + use_subsections = self._reset_subsections() self.current_file = slide_config.file if slide_config.loop: @@ -416,7 +429,11 @@ def load_current_slide(self) -> None: else: self.media_player.setLoops(1) - self.load_current_media() + # Always start playing to load the first frame, then pause if needed + self.load_current_media(start_paused=False) + if use_subsections and self.subsection_mode == SubsectionMode.all: + self.media_player.setPosition(0) + self.media_player.pause() def load_previous_slide(self) -> None: self.playing_reversed_slide = False @@ -455,6 +472,116 @@ def load_reversed_slide(self) -> None: self.current_file = self.current_slide_config.rev_file self.load_current_media() + def _should_use_subsections(self, slide_config: SlideConfig) -> bool: + return ( + self.subsection_mode == SubsectionMode.all + and not self.skip_all + and not self.playing_reversed_slide + and bool(slide_config.subsections) + ) + + def _reset_subsections(self) -> bool: + if self._should_use_subsections(self.current_slide_config): + self._active_subsections = list(self.current_slide_config.subsections) + self._current_subsection_index = -1 + self._pending_subsection_index = None + return True + + self._active_subsections = [] + self._current_subsection_index = -1 + self._pending_subsection_index = None + return False + + def _advance_subsection(self) -> bool: + if not self._should_use_subsections(self.current_slide_config): + return False + if not self._active_subsections: + return False + if self._pending_subsection_index is not None: + self._skip_pending_subsection() + return True + if self._current_subsection_index >= len(self._active_subsections) - 1: + # All subsections consumed; play remainder of slide to completion + self._clear_subsections() + last_subsection = self.current_slide_config.subsections[-1] + self.media_player.setPosition(int(last_subsection.end_time * 1000)) + self.media_player.play() + return True + + self._start_subsection(self._current_subsection_index + 1) + return True + + def _rewind_subsection(self) -> bool: + if not self._should_use_subsections(self.current_slide_config): + return False + + if self._pending_subsection_index is not None: + index = self._pending_subsection_index + self._pending_subsection_index = None + self._current_subsection_index = index - 1 + target_time = ( + self._active_subsections[index].start_time + if 0 <= index < len(self._active_subsections) + else 0.0 + ) + self.media_player.pause() + self.media_player.setPosition(int(target_time * 1000)) + return True + + if self._current_subsection_index >= 0: + index = self._current_subsection_index + self._current_subsection_index -= 1 + target_time = self._active_subsections[index].start_time + self.media_player.pause() + self.media_player.setPosition(int(target_time * 1000)) + return True + + return False + + def _start_subsection(self, index: int) -> None: + if not (0 <= index < len(self._active_subsections)): + return + subsection = self._active_subsections[index] + self._pending_subsection_index = index + self.media_player.setPosition(int(subsection.start_time * 1000)) + self.media_player.play() + + def _skip_pending_subsection(self) -> None: + if self._pending_subsection_index is None: + return + subsection = self._active_subsections[self._pending_subsection_index] + self.media_player.setPosition(int(subsection.end_time * 1000)) + self._finish_subsection(subsection) + + def _finish_subsection(self, subsection: SubsectionConfig) -> None: + if self._pending_subsection_index is not None: + self._current_subsection_index = self._pending_subsection_index + self._pending_subsection_index = None + + if subsection.auto_next: + if self._current_subsection_index < len(self._active_subsections) - 1: + self._start_subsection(self._current_subsection_index + 1) + else: + self.load_next_slide() + else: + self.media_player.pause() + self._freeze_current_frame() + + def _clear_subsections(self) -> None: + self._active_subsections = [] + self._current_subsection_index = -1 + self._pending_subsection_index = None + + def _position_changed(self, position: int) -> None: + if self._pending_subsection_index is None: + return + if not (0 <= self._pending_subsection_index < len(self._active_subsections)): + return + subsection = self._active_subsections[self._pending_subsection_index] + end_ms = int(subsection.end_time * 1000) + if position >= end_ms: + self._finish_subsection(subsection) + """ Key callbacks and slots """ @@ -510,6 +637,9 @@ def close(self) -> None: @Slot() def next(self) -> None: + if self._advance_subsection(): + return + if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PausedState: self.media_player.play() elif self.next_terminates_loop and self.media_player.loops() != 1: @@ -523,6 +653,9 @@ def next(self) -> None: @Slot() def previous(self) -> None: + if self._rewind_subsection(): + return + self.load_previous_slide() @Slot() @@ -597,3 +730,10 @@ def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 key = event.key() self.dispatch(key) event.accept() + + def _freeze_current_frame(self) -> None: + if not self.frame.isValid(): + return + + self.video_sink.setVideoFrame(self.frame) + self.info.video_sink.setVideoFrame(self.frame) diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index 72383e3f..21aab14c 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -15,10 +15,22 @@ import numpy as np from tqdm import tqdm -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig +from ..config import ( + BaseSlideConfig, + PresentationConfig, + PreSlideConfig, + SlideConfig, + SubsectionConfig, + SubsectionMarker, +) from ..defaults import FOLDER_PATH from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, reverse_video_file +from ..utils import ( + concatenate_video_files, + get_duration_seconds, + merge_basenames, + reverse_video_file, +) from . import MANIM if TYPE_CHECKING: @@ -52,6 +64,7 @@ def __init__( self._canvas: MutableMapping[str, Mobject] = {} self._wait_time_between_slides = 0.0 self._skip_animations = False + self._pending_subsection_markers: list[SubsectionMarker] = [] @property @abstractmethod @@ -284,6 +297,59 @@ def play(self, *args: Any, **kwargs: Any) -> None: super().play(*args, **kwargs) # type: ignore[misc] self._current_animation += 1 + def next_subsection( + self, + name: str = "", + *, + auto_next: bool = False, + ) -> None: + """ + Mark an intra-slide subsection boundary. + + Subsections do not create new slides; they are stored as metadata that can be + consumed by converters or presenters. + + :param name: Optional label for the subsection. + :param auto_next: + If set, compatible presenters will automatically continue to the next + subsection once this one completes. + + Example:: + + class Example(Slide): + def construct(self): + circle = Circle() + square = Square() + + self.play(Create(circle)) + self.next_subsection() + self.play(Transform(circle, square)) + self.next_slide() + + When presenting with ``--subsections``, the presenter will pause after + creating the circle, then continue to the transformation when you advance. + """ + relative_animation_index = self._current_animation - self._start_animation + if relative_animation_index < 0: + relative_animation_index = 0 + + # Only add wait() if there were animations before this subsection marker. + # This matches next_slide() logic and avoids adding unnecessary frames. + if relative_animation_index > 0 and self.wait_time_between_slides > 0.0: + self.wait(self.wait_time_between_slides) # type: ignore[attr-defined] + + marker = SubsectionMarker( + animation_index=relative_animation_index, + name=name, + auto_next=auto_next, + ) + self._pending_subsection_markers.append(marker) + + def _consume_subsection_markers(self) -> tuple[SubsectionMarker, ...]: + markers = tuple(self._pending_subsection_markers) + self._pending_subsection_markers.clear() + return markers + @BaseSlideConfig.wrapper("base_slide_config") def next_slide( self, @@ -471,6 +537,7 @@ def construct(self): self._base_slide_config, self._start_animation, self._current_animation, + subsection_markers=self._consume_subsection_markers(), ) ) @@ -482,6 +549,7 @@ def construct(self): base_slide_config, self._current_animation, self._current_animation, + subsection_markers=(), ) ) @@ -507,9 +575,60 @@ def _add_last_slide(self) -> None: self._base_slide_config, self._start_animation, self._current_animation, + subsection_markers=self._consume_subsection_markers(), ) ) + def _build_subsection_configs( + self, + pre_slide_config: PreSlideConfig, + animation_durations: Sequence[float], + partial_files: Sequence[Path], + ) -> tuple[SubsectionConfig, ...]: + """Return resolved subsection configs for a given slide.""" + subsections: list[SubsectionConfig] = [] + prefix_durations: list[float] = [0.0] + for duration in animation_durations: + prefix_durations.append(prefix_durations[-1] + duration) + + max_animations = len(animation_durations) + previous_boundary = 0 + + for marker in pre_slide_config.subsection_markers: + boundary = marker.animation_index + if boundary > max_animations: + raise ValueError( + "Subsection boundary exceeds animation count for slide " + f"(boundary={boundary}, animations={max_animations})." + ) + start_animation = previous_boundary + end_animation = boundary + start_time = prefix_durations[start_animation] + end_time = prefix_durations[end_animation] + + # Map subsection to its corresponding partial animation file(s). + # Only set file if subsection contains exactly one animation (most common case). + # Multiple animations or zero animations result in None (subsection spans multiple files). + subsection_files = partial_files[start_animation:end_animation] + subsection_file = ( + subsection_files[0] if len(subsection_files) == 1 else None + ) + + subsections.append( + SubsectionConfig( + name=marker.name, + auto_next=marker.auto_next, + start_animation=start_animation, + end_animation=end_animation, + start_time=start_time, + end_time=end_time, + file=subsection_file, + ) + ) + previous_boundary = boundary + + return tuple(subsections) + def _save_slides( # noqa: C901 self, use_cache: bool = True, @@ -562,6 +681,22 @@ def _save_slides( # noqa: C901 else: slide_files = files[pre_slide_config.slides_slice] + if pre_slide_config.src and pre_slide_config.subsection_markers: + raise ValueError( + "next_subsection cannot be used together with slides created via 'src'." + ) + + subsection_configs: tuple[SubsectionConfig, ...] = () + if pre_slide_config.subsection_markers: + animation_durations = [ + get_duration_seconds(file) for file in slide_files + ] + subsection_configs = self._build_subsection_configs( + pre_slide_config, + animation_durations, + slide_files, + ) + try: file = merge_basenames(slide_files) except ValueError as e: @@ -592,7 +727,10 @@ def _save_slides( # noqa: C901 slides.append( SlideConfig.from_pre_slide_config_and_files( - pre_slide_config, dst_file, rev_file + pre_slide_config, + dst_file, + rev_file, + subsections=subsection_configs, ) ) diff --git a/manim_slides/templates/revealjs.html b/manim_slides/templates/revealjs.html index 094ad320..478092c7 100644 --- a/manim_slides/templates/revealjs.html +++ b/manim_slides/templates/revealjs.html @@ -18,32 +18,50 @@
- {% for presentation_config in presentation_configs -%} + {% for pres in enriched_presentations -%} {%- set outer_loop = loop %} - {% for slide_config in presentation_config.slides %} - {% if one_file %} - {% set file = file_to_data_uri(slide_config.file) %} - {% else %} - {% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %} + {%- set presentation_config = pres.presentation_config %} + {% for enriched_slide in pres.enriched_slides %} + {%- set slide_config = enriched_slide.slide_config %} + {%- set sections = enriched_slide.sections %} + {% if sections|length > 1 %} +
{% endif %} -
- {% if slide_config.notes != "" %} - - {% endif %} + {% for section in sections %} + {%- set section_loop = loop %} + {% if one_file %} + {% set file = file_to_data_uri(section.file) %} + {% else %} + {% set file = assets_dir / (prefix(outer_loop.index0) + section.file.name) %} + {% endif %} +
+ {% if section.notes %} + + {% endif %} +
+ {% endfor %} + {% if sections|length > 1 %}
+ {% endif %} {% endfor %} {% endfor %}
@@ -279,6 +297,30 @@ } } ); + + // Handle video start/end times for subsections + Reveal.on('slidechanged', event => { + const section = event.currentSlide; + const video = section.slideBackgroundContentElement?.getElementsByTagName('video')[0]; + if (!video) return; + + const startTime = parseFloat(section.dataset.backgroundVideoStart || 0); + const endTime = section.dataset.backgroundVideoEnd; + + video.currentTime = startTime; + video.play(); + + if (endTime) { + const checkTime = () => { + if (video.currentTime >= parseFloat(endTime)) { + video.pause(); + video.removeEventListener('timeupdate', checkTime); + } + }; + video.addEventListener('timeupdate', checkTime); + } + }); + {% if one_file %} // Fix found by @t-fritsch and @Rapsssito on GitHub // see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-11733074. diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 1cdee63e..fa0e3139 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -13,6 +13,18 @@ from .logger import logger +def get_duration_ms(file: Path) -> float: + """Return video duration in milliseconds.""" + with av.open(str(file)) as container: + video = container.streams.video[0] + return float(1000 * video.duration * video.time_base) + + +def get_duration_seconds(file: Path) -> float: + """Return video duration in seconds.""" + return get_duration_ms(file) / 1000.0 + + def concatenate_video_files(files: list[Path], dest: Path) -> None: """Concatenate multiple video files into one.""" if len(files) == 1: diff --git a/tests/test_convert.py b/tests/test_convert.py index ef07cfd8..37d09ca1 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -5,8 +5,9 @@ import pytest import requests from bs4 import BeautifulSoup +from pptx import Presentation -from manim_slides.config import PresentationConfig +from manim_slides.config import PresentationConfig, SlideConfig from manim_slides.convert import ( PDF, AutoAnimateEasing, @@ -32,11 +33,12 @@ RevealTheme, ShowSlideNumber, SlideNumber, + SubsectionMode, Transition, TransitionSpeed, file_to_data_uri, - get_duration_ms, ) +from manim_slides.utils import get_duration_ms def test_get_duration_ms(video_file: Path) -> None: @@ -328,3 +330,113 @@ def test_pptx_converter( out_file = tmp_path / "slides.pptx" PowerPoint(presentation_configs=[presentation_config]).convert_to(out_file) assert out_file.exists() + + +def _make_slide_config(video_file: Path) -> SlideConfig: + return SlideConfig.model_validate( # type: ignore[no-any-return] + { + "loop": False, + "auto_next": False, + "playback_rate": 1.0, + "reversed_playback_rate": 1.0, + "notes": "", + "dedent_notes": True, + "skip_animations": False, + "src": None, + "file": video_file, + "rev_file": video_file, + "start_animation": 0, + "end_animation": 1, + "subsections": [ + { + "name": "Stage 1", + "auto_next": False, + "start_animation": 0, + "end_animation": 1, + "start_time": 0.0, + "end_time": 1.0, + } + ], + } + ) + + +def test_pdf_subsections_none(tmp_path: Path, video_file: Path) -> None: + """ + Test that with pdf_subsection_mode=none, slides with subsections + capture the final frame showing all subsections completed. + """ + slide_with_subsections = _make_slide_config(video_file) + slide_without_subsections = SlideConfig.model_validate( + { + "loop": False, + "auto_next": False, + "playback_rate": 1.0, + "reversed_playback_rate": 1.0, + "notes": "", + "dedent_notes": True, + "skip_animations": False, + "src": None, + "file": video_file, + "rev_file": video_file, + "start_animation": 0, + "end_animation": 1, + "subsections": [], + } + ) + presentation = PresentationConfig( + slides=[slide_without_subsections, slide_with_subsections] + ) + out_file = tmp_path / "subsections_none.pdf" + PDF( + presentation_configs=[presentation], + subsection_mode="none", + ).convert_to(out_file) + assert out_file.exists() + + +def test_pdf_subsections_all(tmp_path: Path, video_file: Path) -> None: + slide = _make_slide_config(video_file) + presentation = PresentationConfig(slides=[slide]) + out_file = tmp_path / "subsections.pdf" + PDF( + presentation_configs=[presentation], + subsection_mode="all", + ).convert_to(out_file) + assert out_file.exists() + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required") +def test_pptx_subsections_all_falls_back_to_none( + tmp_path: Path, video_file: Path +) -> None: + """PowerPoint ignores subsection_mode=all until subsections are implemented.""" + slide = _make_slide_config(video_file) + presentation = PresentationConfig(slides=[slide]) + out_file = tmp_path / "subsections.pptx" + converter = PowerPoint( + presentation_configs=[presentation], + subsection_mode="all", + width=640, + height=360, + ) + converter.convert_to(out_file) + assert out_file.exists() + assert converter.subsection_mode == SubsectionMode.none + prs = Presentation(out_file) + assert len(prs.slides) == len(presentation.slides) + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required") +def test_pptx_subsections_none(tmp_path: Path, video_file: Path) -> None: + """Test that subsection_mode=none ignores subsections and creates one PowerPoint slide.""" + slide = _make_slide_config(video_file) + presentation = PresentationConfig(slides=[slide]) + out_file = tmp_path / "subsections_none.pptx" + PowerPoint( + presentation_configs=[presentation], + subsection_mode="none", + width=640, + height=360, + ).convert_to(out_file) + assert out_file.exists() diff --git a/tests/test_subsections.py b/tests/test_subsections.py new file mode 100644 index 00000000..e3f5a017 --- /dev/null +++ b/tests/test_subsections.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from manim_slides.config import BaseSlideConfig, PreSlideConfig, SubsectionMarker +from manim_slides.slide.base import BaseSlide + + +class DummySlide(BaseSlide): + @property + def _frame_height(self) -> float: + return 1080.0 + + @property + def _frame_width(self) -> float: + return 1920.0 + + @property + def _background_color(self) -> str: + return "#000000" + + @property + def _resolution(self) -> tuple[int, int]: + return (1920, 1080) + + @property + def _partial_movie_files(self) -> list[Path]: + return [] + + @property + def _show_progress_bar(self) -> bool: + return False + + @property + def _leave_progress_bar(self) -> bool: + return False + + @property + def _start_at_animation_number(self) -> int | None: + return None + + def play(self, *args: object, **kwargs: object) -> None: + self._current_animation += 1 + + +def test_build_subsection_configs(tmp_path: Path) -> None: + slide = DummySlide(output_folder=tmp_path) + base = BaseSlideConfig() + pre_slide = PreSlideConfig.from_base_slide_config_and_animation_indices( + base, + start_animation=0, + end_animation=3, + subsection_markers=( + SubsectionMarker(animation_index=1, name="Intro"), + SubsectionMarker(animation_index=3, name="Wrap", auto_next=True), + ), + ) + + durations = [0.2, 0.3, 0.5] + # Create dummy animation files for validation + partial_files = [] + for i in range(3): + file_path = tmp_path / f"anim_{i}.mp4" + file_path.touch() + partial_files.append(file_path) + + subsections = slide._build_subsection_configs(pre_slide, durations, partial_files) + + assert len(subsections) == 2 + assert subsections[0].start_animation == 0 + assert subsections[0].end_animation == 1 + assert subsections[0].end_time == pytest.approx(0.2) + assert subsections[1].start_animation == 1 + assert subsections[1].end_animation == 3 + assert subsections[1].end_time == pytest.approx(1.0) + assert subsections[1].auto_next + # Verify file field is populated correctly + assert subsections[0].file == partial_files[0] # Single animation: file set + assert subsections[1].file is None # Multiple animations: file is None + + +def test_build_subsection_configs_bounds(tmp_path: Path) -> None: + slide = DummySlide(output_folder=tmp_path) + base = BaseSlideConfig() + pre_slide = PreSlideConfig.from_base_slide_config_and_animation_indices( + base, + start_animation=0, + end_animation=2, + subsection_markers=(SubsectionMarker(animation_index=5),), + ) + + # Create dummy animation files for validation + partial_files = [] + for i in range(2): + file_path = tmp_path / f"anim_{i}.mp4" + file_path.touch() + partial_files.append(file_path) + + with pytest.raises(ValueError): + slide._build_subsection_configs(pre_slide, [0.1, 0.2], partial_files)