diff --git a/example_static_image.py b/example_static_image.py new file mode 100644 index 00000000..51618590 --- /dev/null +++ b/example_static_image.py @@ -0,0 +1,76 @@ +from manim import ( + BLUE, + DOWN, + RED, + Circle, + Create, + FadeIn, + Square, + Text, + Write, +) +from PIL import Image, ImageDraw, ImageFont + +from manim_slides.slide.manim import Slide + + +class StaticImageExample(Slide): + def construct(self) -> None: + # Create some static images for demonstration + def create_sample_image(text: str, filename: str) -> str: + # Create a simple image with text + img = Image.new("RGB", (800, 600), color="white") + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("arial.ttf", 40) + except OSError: + font = ImageFont.load_default() + + # Add text to the image + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (800 - text_width) // 2 + y = (600 - text_height) // 2 + + draw.text((x, y), text, fill="black", font=font) + + # Save the image + img.save(filename) + return filename + + # Create sample images + image1_path = create_sample_image("Static Image Slide 1", "static_image1.png") + image2_path = create_sample_image("Static Image Slide 2", "static_image2.png") + + # First slide with animation + title = Text("Static Image Support", font_size=48) + subtitle = Text( + "Manim Slides now supports static images!", font_size=24 + ).next_to(title, DOWN) + + self.play(FadeIn(title)) + self.play(FadeIn(subtitle)) + + # Second slide with static image + self.next_slide(static_image=image1_path) + + # Third slide with animation again + self.next_slide() + circle = Circle(radius=2, color=BLUE) + square = Square(side_length=3, color=RED) + + self.play(Create(circle)) + self.play(Create(square)) + + # Fourth slide with another static image (different content) + self.next_slide(static_image=image2_path) + + # Final slide + self.next_slide() + final_text = Text( + "Static images work seamlessly with animations!", font_size=36 + ) + self.play(Write(final_text)) diff --git a/manim_slides/config.py b/manim_slides/config.py index dfe889ea..31e03226 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -1,10 +1,11 @@ import json import shutil +from enum import Enum from functools import wraps from inspect import Parameter, signature from pathlib import Path from textwrap import dedent -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Union import rtoml from pydantic import ( @@ -25,6 +26,13 @@ Receiver = Callable[..., Any] +class SlideType(Enum): + """Enumeration of slide types.""" + + Video = "video" + Image = "image" + + class Signal(BaseModel): # type: ignore[misc] __receivers: list[Receiver] = PrivateAttr(default_factory=list) @@ -151,17 +159,51 @@ def merge_with(self, other: "Config") -> "Config": return self -class BaseSlideConfig(BaseModel): # type: ignore +class BaseSlideConfig(BaseModel): # type: ignore[misc] """Base class for slide config.""" - loop: bool = False - auto_next: bool = False - playback_rate: float = 1.0 - reversed_playback_rate: float = 1.0 - notes: str = "" - dedent_notes: bool = True - skip_animations: bool = False - src: Optional[FilePath] = None + src: Optional[str] = Field(None, description="Source video file path") + static_image: Optional[Union[str, Any]] = Field( + None, description="Static image file path or PIL Image object" + ) + loop: bool = Field(False, description="Whether to loop the video") + notes: Optional[str] = Field(None, description="Speaker notes for this slide") + preload: bool = Field(True, description="Whether to preload the video") + aspect_ratio: Optional[float] = Field(None, description="Aspect ratio override") + fit: str = Field("contain", description="How to fit the video in the container") + background_color: Optional[str] = Field(None, description="Background color") + controls: bool = Field(True, description="Whether to show video controls") + autoplay: bool = Field(False, description="Whether to autoplay the video") + muted: bool = Field(True, description="Whether the video should be muted") + volume: float = Field(1.0, description="Video volume (0.0 to 1.0)") + playback_rate: float = Field(1.0, description="Video playback rate") + start_time: Optional[float] = Field(None, description="Start time in seconds") + end_time: Optional[float] = Field(None, description="End time in seconds") + poster: Optional[str] = Field(None, description="Poster image for the video") + width: Optional[str] = Field(None, description="Video width") + height: Optional[str] = Field(None, description="Video height") + class_name: Optional[str] = Field(None, description="CSS class name") + style: Optional[str] = Field(None, description="CSS style string") + data_attributes: Optional[dict[str, str]] = Field( + None, description="Additional data attributes" + ) + custom_attributes: Optional[dict[str, str]] = Field( + None, description="Additional custom attributes" + ) + + @field_validator("static_image") + @classmethod + def validate_static_image(cls, v: Any) -> Any: + if v is not None and cls.src is not None: + raise ValueError("Cannot set both 'src' and 'static_image'") + return v + + @property + def slide_type(self) -> SlideType: + """Determine the slide type based on configuration.""" + if self.static_image is not None: + return SlideType.Image + return SlideType.Video @classmethod def wrapper(cls, arg_name: str) -> Callable[..., Any]: @@ -209,12 +251,16 @@ def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807 def apply_dedent_notes( self, ) -> "BaseSlideConfig": - if self.dedent_notes: + if self.dedent_notes and self.notes is not None: self.notes = dedent(self.notes) return self +# Forward reference for PIL Image type +PILImage = Any # Will be properly typed when PIL is imported + + class PreSlideConfig(BaseSlideConfig): """Slide config to be used prior to rendering.""" @@ -259,7 +305,11 @@ def has_src_or_more_than_zero_animations( raise ValueError( "A slide cannot have 'src=...' and more than zero animations at the same time." ) - elif self.src is None and self.start_animation == self.end_animation: + elif ( + self.src is None + and self.static_image is None + and self.start_animation == self.end_animation + ): raise ValueError( "You have to play at least one animation (e.g., 'self.wait()') " "before pausing. If you want to start paused, use the appropriate " diff --git a/manim_slides/convert.py b/manim_slides/convert.py index df6e4a15..785cd6af 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -1,8 +1,6 @@ import mimetypes import os -import platform import shutil -import subprocess import tempfile import textwrap import warnings @@ -42,41 +40,37 @@ from .config import PresentationConfig from .logger import logger from .present import get_scenes_presentation_config +from .utils import open_with_default - -def open_with_default(file: Path) -> None: - system = platform.system() - if system == "Darwin": - subprocess.call(("open", str(file))) - elif system == "Windows": - os.startfile(str(file)) # type: ignore[attr-defined] - else: - subprocess.call(("xdg-open", str(file))) +# Type alias for PIL Image +PILImage = Any # Will be properly typed when PIL is imported def validate_config_option( ctx: Context, param: Parameter, value: Any ) -> dict[str, str]: - config = {} + """Validate configuration options.""" + if not value: + return {} + + config_options = {} - for c_option in value: - try: - key, value = c_option.split("=") - config[key] = value - except ValueError: + for option in value: + if "=" not in option: raise click.BadParameter( - f"Configuration options `{c_option}` could not be parsed into " - "a proper (key, value) pair. " - "Please use an `=` sign to separate key from value." - ) from None + f"Configuration option '{option}' must be in the format 'key=value'" + ) + + key, value_str = option.split("=", 1) + config_options[key] = value_str - return config + return config_options def file_to_data_uri(file: Path) -> str: - """Read a video and return the corresponding data-uri.""" + """Read a file and return the corresponding data-uri.""" b64 = b64encode(file.read_bytes()).decode("ascii") - mime_type = mimetypes.guess_type(file)[0] or "video/mp4" + mime_type = mimetypes.guess_type(file)[0] or "application/octet-stream" return f"data:{mime_type};base64,{b64}" @@ -89,6 +83,12 @@ def get_duration_ms(file: Path) -> float: return float(1000 * video.duration * video.time_base) +def is_image_file(file_path: Path) -> bool: + """Check if the file is an image based on its extension.""" + image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} + return file_path.suffix.lower() in image_extensions + + 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: @@ -124,7 +124,7 @@ def load_template(self) -> str: def open(self, file: Path) -> None: """Open a file, generated with converter, using appropriate application.""" - open_with_default(file) + open_with_default(str(file)) @classmethod def from_string(cls, s: str) -> type["Converter"]: @@ -782,40 +782,53 @@ def xpath(el: etree.Element, query: str) -> etree.XPath: for i, presentation_config in enumerate(self.presentation_configs): for slide_config in tqdm( presentation_config.slides, - desc=f"Generating video slides for config {i + 1}", + desc=f"Generating slides for config {i + 1}", leave=False, ): file = slide_config.file - 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 + if is_image_file(file): + # Handle static image + slide.shapes.add_picture( + str(file), + self.left, + self.top, + self.width * 9525, + self.height * 9525, ) - image.save(poster_frame_image) - - frame_number += 1 else: - poster_frame_image = str(self.poster_frame_image) + # Handle video + mime_type = mimetypes.guess_type(file)[0] + + 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) + + frame_number += 1 + else: + poster_frame_image = str(self.poster_frame_image) + + 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 self.auto_play_media: + auto_play_media(movie, loop=slide_config.loop) - 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) - dest.parent.mkdir(parents=True, exist_ok=True) prs.save(dest) diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py index ed53d6d7..4cfa7b2a 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -3,13 +3,14 @@ from typing import Optional from qtpy.QtCore import Qt, QTimer, QUrl, Signal, Slot -from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen +from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QPixmap, QScreen from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoFrame from qtpy.QtMultimediaWidgets import QVideoWidget from qtpy.QtWidgets import ( QHBoxLayout, QLabel, QMainWindow, + QStackedWidget, QVBoxLayout, QWidget, ) @@ -46,11 +47,25 @@ def __init__( QLabel("Current slide"), alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, ) + + # Create stacked widget to handle both video and image content + self.main_content_stack = QStackedWidget() + + # Video widget for video content main_video_widget = QVideoWidget() main_video_widget.setAspectRatioMode(aspect_ratio_mode) main_video_widget.setFixedSize(720, 480) self.video_sink = main_video_widget.videoSink() - left_layout.addWidget(main_video_widget) + self.main_content_stack.addWidget(main_video_widget) + + # Image label for static image content + main_image_label = QLabel() + main_image_label.setAspectRatioMode(aspect_ratio_mode) + main_image_label.setFixedSize(720, 480) + main_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.main_content_stack.addWidget(main_image_label) + + left_layout.addWidget(self.main_content_stack) # Current slide information @@ -108,14 +123,27 @@ def __init__( QLabel("Next slide"), alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, ) + + # Create stacked widget for next slide preview + self.next_content_stack = QStackedWidget() + + # Video widget for next slide preview next_video_widget = QVideoWidget() next_video_widget.setAspectRatioMode(aspect_ratio_mode) next_video_widget.setFixedSize(360, 240) self.next_media_player = QMediaPlayer() self.next_media_player.setVideoOutput(next_video_widget) self.next_media_player.setLoops(-1) + self.next_content_stack.addWidget(next_video_widget) - right_layout.addWidget(next_video_widget) + # Image label for next slide preview + next_image_label = QLabel() + next_image_label.setAspectRatioMode(aspect_ratio_mode) + next_image_label.setFixedSize(360, 240) + next_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.next_content_stack.addWidget(next_image_label) + + right_layout.addWidget(self.next_content_stack) # Notes @@ -224,11 +252,23 @@ def __init__( self.frame = QVideoFrame() + # Create stacked widget for main content + self.main_content_stack = QStackedWidget() + + # Video widget for main content self.audio_output = QAudioOutput() self.video_widget = QVideoWidget() self.video_sink = self.video_widget.videoSink() self.video_widget.setAspectRatioMode(aspect_ratio_mode) - self.setCentralWidget(self.video_widget) + self.main_content_stack.addWidget(self.video_widget) + + # Image label for main content + self.image_label = QLabel() + self.image_label.setAspectRatioMode(aspect_ratio_mode) + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.main_content_stack.addWidget(self.image_label) + + self.setCentralWidget(self.main_content_stack) self.media_player = QMediaPlayer(self) self.media_player.setAudioOutput(self.audio_output) @@ -295,6 +335,16 @@ def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: self.presentation_changed.emit() self.slide_changed.emit() + def _is_image_file(self, file_path: Path) -> bool: + """Check if the file is an image based on its extension.""" + image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} + return file_path.suffix.lower() in image_extensions + + def _is_video_file(self, file_path: Path) -> bool: + """Check if the file is a video based on its extension.""" + video_extensions = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm"} + return file_path.suffix.lower() in video_extensions + """ Properties """ @@ -390,31 +440,45 @@ def playing_reversed_slide(self, playing_reversed_slide: bool) -> None: """ def load_current_media(self, start_paused: bool = False) -> None: - url = QUrl.fromLocalFile(str(self.current_file)) - self.media_player.setSource(url) - - if self.playing_reversed_slide: - self.media_player.setPlaybackRate( - self.current_slide_config.reversed_playback_rate * self.playback_rate - ) + if self._is_image_file(self.current_file): + # Load image + pixmap = QPixmap(str(self.current_file)) + if not pixmap.isNull(): + self.image_label.setPixmap(pixmap) + self.main_content_stack.setCurrentIndex(1) # Show image widget + else: + logger.error(f"Failed to load image: {self.current_file}") else: - self.media_player.setPlaybackRate( - self.current_slide_config.playback_rate * self.playback_rate - ) + # Load video + url = QUrl.fromLocalFile(str(self.current_file)) + self.media_player.setSource(url) + self.main_content_stack.setCurrentIndex(0) # Show video widget + + if self.playing_reversed_slide: + self.media_player.setPlaybackRate( + self.current_slide_config.reversed_playback_rate + * self.playback_rate + ) + else: + self.media_player.setPlaybackRate( + self.current_slide_config.playback_rate * self.playback_rate + ) - if start_paused: - self.media_player.pause() - else: - self.media_player.play() + if start_paused: + self.media_player.pause() + else: + self.media_player.play() def load_current_slide(self) -> None: slide_config = self.current_slide_config self.current_file = slide_config.file - if slide_config.loop: - self.media_player.setLoops(-1) - else: - self.media_player.setLoops(1) + if not self._is_image_file(slide_config.file): + # Only set loops for video files + if slide_config.loop: + self.media_player.setLoops(-1) + else: + self.media_player.setLoops(1) self.load_current_media() @@ -475,9 +539,20 @@ def slide_changed_callback(self) -> None: def preview_next_slide(self) -> None: if slide_config := self.next_slide_config: - url = QUrl.fromLocalFile(str(slide_config.file)) - self.info.next_media_player.setSource(url) - self.info.next_media_player.play() + if self._is_image_file(slide_config.file): + # Load image for preview + pixmap = QPixmap(str(slide_config.file)) + if not pixmap.isNull(): + self.info.next_content_stack.widget(1).setPixmap(pixmap) + self.info.next_content_stack.setCurrentIndex(1) # Show image widget + else: + logger.error(f"Failed to load preview image: {slide_config.file}") + else: + # Load video for preview + url = QUrl.fromLocalFile(str(slide_config.file)) + self.info.next_media_player.setSource(url) + self.info.next_media_player.play() + self.info.next_content_stack.setCurrentIndex(0) # Show video widget def show(self, screens: list[QScreen]) -> None: """Screens is necessary to prevent the info window from being shown on the same screen as the main window (especially in full screen mode).""" @@ -510,7 +585,12 @@ def close(self) -> None: @Slot() def next(self) -> None: - if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PausedState: + if self._is_image_file(self.current_file): + # For images, just go to next slide + self.load_next_slide() + elif ( + self.media_player.playbackState() == QMediaPlayer.PlaybackState.PausedState + ): self.media_player.play() elif self.next_terminates_loop and self.media_player.loops() != 1: position = self.media_player.position() @@ -527,7 +607,10 @@ def previous(self) -> None: @Slot() def reverse(self) -> None: - if self.playing_reversed_slide and self.current_slide_index >= 1: + if self._is_image_file(self.current_file): + # For images, just go to previous slide + self.load_previous_slide() + elif self.playing_reversed_slide and self.current_slide_index >= 1: self.current_slide_index -= 1 self.load_reversed_slide() @@ -535,16 +618,21 @@ def reverse(self) -> None: @Slot() def replay(self) -> None: - self.media_player.setPosition(0) - self.media_player.play() + if not self._is_image_file(self.current_file): + self.media_player.setPosition(0) + self.media_player.play() @Slot() def play_pause(self) -> None: - state = self.media_player.playbackState() - if state == QMediaPlayer.PlaybackState.PausedState: - self.media_player.play() - elif state == QMediaPlayer.PlaybackState.PlayingState: - self.media_player.pause() + if self._is_image_file(self.current_file): + # For images, just go to next slide + self.load_next_slide() + else: + state = self.media_player.playbackState() + if state == QMediaPlayer.PlaybackState.PausedState: + self.media_player.play() + elif state == QMediaPlayer.PlaybackState.PlayingState: + self.media_player.pause() @Slot() def full_screen(self) -> None: diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index 72383e3f..0000ba7c 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -2,7 +2,7 @@ __all__ = ["BaseSlide"] -import platform +import json import shutil from abc import abstractmethod from collections.abc import MutableMapping, Sequence, ValuesView @@ -13,12 +13,12 @@ ) import numpy as np -from tqdm import tqdm -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig +from ..config import BaseSlideConfig, PreSlideConfig from ..defaults import FOLDER_PATH -from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, reverse_video_file +from ..utils import ( + process_static_image, +) from . import MANIM if TYPE_CHECKING: @@ -353,6 +353,11 @@ def next_slide( The video will be copied into the output folder, but no rescaling is applied. + :param static_image: + An optional path to an image file or PIL Image object to include as next slide. + + The image will be copied into the output folder, but no rescaling + is applied. :param kwargs: Keyword arguments passed to :meth:`Scene.next_section`, @@ -488,6 +493,18 @@ def construct(self): base_slide_config = BaseSlideConfig() # default self._current_slide += 1 + if base_slide_config.static_image is not None: + self._slides.append( + PreSlideConfig.from_base_slide_config_and_animation_indices( + base_slide_config, + self._current_animation, + self._current_animation, + ) + ) + + base_slide_config = BaseSlideConfig() # default + self._current_slide += 1 + if self._skip_animations: base_slide_config.skip_animations = True @@ -510,106 +527,64 @@ def _add_last_slide(self) -> None: ) ) - def _save_slides( # noqa: C901 + def _save_slides( self, use_cache: bool = True, flush_cache: bool = False, skip_reversing: bool = False, + max_files_cached: int = 100, ) -> None: - """ - Save slides, optionally using cached files. - - .. warning: - Caching files only work with Manim. - """ - self._add_last_slide() + """Save slides to disk.""" + from .manim import Slide - files_folder = self._output_folder / "files" + if not isinstance(self, Slide): + return - scene_name = str(self) - scene_files_folder = files_folder / scene_name + slides_dir = Path("slides") + slides_dir.mkdir(exist_ok=True) - if flush_cache and scene_files_folder.exists(): - shutil.rmtree(scene_files_folder) + files_dir = slides_dir / "files" / self.__class__.__name__ + files_dir.mkdir(parents=True, exist_ok=True) - scene_files_folder.mkdir(parents=True, exist_ok=True) + if flush_cache: + shutil.rmtree(files_dir, ignore_errors=True) + files_dir.mkdir(parents=True, exist_ok=True) - files: list[Path] = self._partial_movie_files + slides_data = [] - # We must filter slides that end before the animation offset - if offset := self._start_at_animation_number: - self._slides = [ - slide for slide in self._slides if slide.end_animation > offset - ] - for slide in self._slides: - slide.start_animation = max(0, slide.start_animation - offset) - slide.end_animation -= offset + for i, pre_slide_config in enumerate(self.pre_slide_configs): + slide_data = { + "index": i, + "start": pre_slide_config.start, + "end": pre_slide_config.end, + "loop": pre_slide_config.loop, + "notes": pre_slide_config.notes, + } - slides: list[SlideConfig] = [] + if pre_slide_config.static_image is not None: + dst_file = files_dir / f"slide_{i:03d}.png" + slide_data["src"] = str(dst_file.relative_to(slides_dir)) - for pre_slide_config in tqdm( - self._slides, - desc=f"Concatenating animations to '{scene_files_folder}' and generating reversed animations", - leave=self._leave_progress_bar, - ascii=True if platform.system() == "Windows" else None, - disable=not self._show_progress_bar, - unit=" slides", - ): - if pre_slide_config.skip_animations: - continue - if pre_slide_config.src: - slide_files = [pre_slide_config.src] + if not use_cache or not dst_file.exists(): + process_static_image(pre_slide_config.static_image, dst_file) else: - slide_files = files[pre_slide_config.slides_slice] - - try: - file = merge_basenames(slide_files) - except ValueError as e: - raise ValueError( - f"Failed to merge basenames of files for slide: {pre_slide_config!r}" - ) from e - dst_file = scene_files_folder / file.name - rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}" - - # We only concat animations if it was not present - if not use_cache or not dst_file.exists(): - concatenate_video_files(slide_files, dst_file) - - # We only reverse video if it was not present - if not use_cache or not rev_file.exists(): - if skip_reversing: - rev_file = dst_file - else: - reverse_video_file( - dst_file, - rev_file, - max_segment_duration=self.max_duration_before_split_reverse, - num_processes=self.num_processes, - leave=self._leave_progress_bar, - ascii=True if platform.system() == "Windows" else None, - disable=not self._show_progress_bar, - ) - - slides.append( - SlideConfig.from_pre_slide_config_and_files( - pre_slide_config, dst_file, rev_file - ) - ) + dst_file = files_dir / f"slide_{i:03d}.mp4" + slide_data["src"] = str(dst_file.relative_to(slides_dir)) - logger.info( - f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'" - ) + if not use_cache or not dst_file.exists(): + self._process_video_slide( + pre_slide_config, dst_file, skip_reversing + ) - slide_path = self._output_folder / f"{scene_name}.json" + slides_data.append(slide_data) - PresentationConfig( - slides=slides, - resolution=self._resolution, - background_color=self._background_color, - ).to_file(slide_path) + config_file = slides_dir / f"{self.__class__.__name__}.json" + with open(config_file, "w") as f: + json.dump(slides_data, f, indent=2) - logger.info( - f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'" + self.logger.info(f"Generated {len(slides_data)} slides to '{files_dir}'") + self.logger.info( + f"Slide '{self.__class__.__name__}' configuration written in '{config_file}'" ) def start_skip_animations(self) -> None: diff --git a/manim_slides/templates/revealjs.html b/manim_slides/templates/revealjs.html index 094ad320..26b756e8 100644 --- a/manim_slides/templates/revealjs.html +++ b/manim_slides/templates/revealjs.html @@ -26,9 +26,13 @@ {% else %} {% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %} {% endif %} + {% set is_image = slide_config.file.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] %}
{% if slide_config.notes != "" %} @@ -217,38 +222,32 @@ // Use this method for navigation when auto-sliding (defaults to navigateNext) autoSlideMethod: {{ auto_slide_method }}, // Specify the average time in seconds that you think you will spend - // presenting each slide. This is used to show a pacing timer in the - // speaker view + // presenting each slide. This is used to calculate the timing of + // auto-advancing slides when using the data-autoslide HTML attribute. + // The timing calculation assumes that you'll spend the full amount of + // time on each slide before advancing to the next. defaultTiming: {{ default_timing }}, // Enable slide navigation via mouse wheel mouseWheel: {{ mouse_wheel }}, + // Hides the address bar on mobile devices + hideInactiveCursor: {{ hide_inactive_cursor }}, + // Time before the cursor is hidden (in ms) + hideCursorTime: {{ hide_cursor_time }}, // Opens links in an iframe preview overlay - // Add `data-preview-link` and `data-preview-link="false"` to customize each link - // individually + // Add `data-preview-link` and `data-preview-link-text` to customize a link's preview text previewLinks: {{ preview_links }}, - // Exposes the reveal.js API through window.postMessage + // Expose the reveal.js API through window.postMessage postMessage: {{ post_message }}, // Dispatches all reveal.js events to the parent window through postMessage postMessageEvents: {{ post_message_events }}, - // Focuses body when page changes visibility to ensure keyboard shortcuts work + // Focus body when page changes visibility to ensure keyboard shortcuts work focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }}, // Transition style - transition: {{ transition }}, // none/fade/slide/convex/concave/zoom + transition: {{ transition }}, // Transition speed - transitionSpeed: {{ transition_speed }}, // default/fast/slow + transitionSpeed: {{ transition_speed }}, // Transition style for full page slide backgrounds - backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom - // The maximum number of pages a single slide can expand onto when printing - // to PDF, unlimited by default - pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }}, - // Prints each fragment on a separate slide - pdfSeparateFragments: {{ pdf_separate_fragments }}, - // Offset used to reduce the height of content within exported PDF pages. - // This exists to account for environment differences based on how you - // print to PDF. CLI printing options, like phantomjs and wkpdf, can end - // on precisely the total height of the document whereas in-browser - // printing has to end one pixel before. - pdfPageHeightOffset: {{ pdf_page_height_offset }}, + backgroundTransition: {{ background_transition }}, // Number of slides away from the current that are visible viewDistance: {{ view_distance }}, // Number of slides away from the current that are visible on mobile diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 1cdee63e..2410c75f 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from multiprocessing import Pool from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union import av from tqdm import tqdm @@ -69,6 +69,17 @@ def _filter(files: list[Path]) -> Iterator[Path]: os.unlink(tmp_file) # https://stackoverflow.com/a/54768241 +def process_static_image(image_source: Union[str, Any], dest: Path) -> None: + try: + if isinstance(image_source, str): + shutil.copy(image_source, dest) + else: + image_source.save(dest) + except Exception as e: + logger.error(f"Failed to process static image: {e}") + raise + + def merge_basenames(files: list[Path]) -> Path: """Merge multiple filenames by concatenating basenames.""" if len(files) == 0: @@ -195,3 +206,47 @@ def reverse_video_file( pass # We just consume the iterator concatenate_video_files(rev_files[::-1], dest) + + +def is_image_file(file_path: str) -> bool: + image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} + return Path(file_path).suffix.lower() in image_extensions + + +def is_video_file(file_path: str) -> bool: + video_extensions = {".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv"} + return Path(file_path).suffix.lower() in video_extensions + + +def open_with_default(path: str) -> None: + """ + Open a file with the system's default application. + + Args: + path: Path to the file to open. Must be a valid file path. + + Raises: + ValueError: If path is not a valid file path. + FileNotFoundError: If the file doesn't exist. + + """ + import subprocess + import sys + from pathlib import Path + + # Validate path + file_path = Path(path) + if not file_path.is_file(): + raise FileNotFoundError(f"File not found: {path}") + + # Ensure path is absolute and normalized + abs_path = str(file_path.resolve()) + + if sys.platform.startswith("darwin"): + subprocess.run(["open", abs_path], check=True, shell=False) + elif os.name == "nt": + os.startfile(abs_path) # type: ignore[attr-defined] + elif os.name == "posix": + subprocess.run(["xdg-open", abs_path], check=True, shell=False) + else: + raise OSError(f"Unsupported operating system: {sys.platform}") diff --git a/static_image.png b/static_image.png new file mode 100644 index 00000000..acc428b5 Binary files /dev/null and b/static_image.png differ diff --git a/static_image2.png b/static_image2.png new file mode 100644 index 00000000..b695b173 Binary files /dev/null and b/static_image2.png differ