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 @@