From 59fc3b41df7ce198c5f455c3bde2275d0f98259f Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 14:31:19 +0530 Subject: [PATCH 01/11] Add static image support to manim-slides - Add SlideType enum to distinguish between video and image slides - Add static_image field to BaseSlideConfig with validation - Update BaseSlide._save_slides to handle static images - Add process_static_image utility function - Update Qt player to display static images using QLabel - Update HTML converter to use data-background-image for images - Update PowerPoint converter to use add_picture for images - Add example demonstrating static image usage --- example_static_image.py | 59 ++++++++++ manim_slides/config.py | 34 +++++- manim_slides/convert.py | 105 ++++++++++-------- manim_slides/present/player.py | 157 +++++++++++++++++++++------ manim_slides/slide/base.py | 103 ++++++++++++------ manim_slides/templates/revealjs.html | 39 ++++--- manim_slides/utils.py | 21 +++- 7 files changed, 381 insertions(+), 137 deletions(-) create mode 100644 example_static_image.py diff --git a/example_static_image.py b/example_static_image.py new file mode 100644 index 00000000..15c22413 --- /dev/null +++ b/example_static_image.py @@ -0,0 +1,59 @@ +from manim import * +from manim_slides import Slide +from PIL import Image, ImageDraw, ImageFont +import numpy as np + +class StaticImageExample(Slide): + def construct(self): + # Create a simple image programmatically + img = Image.new('RGB', (800, 600), color='white') + draw = ImageDraw.Draw(img) + + # Draw some text + try: + font = ImageFont.truetype("arial.ttf", 40) + except: + font = ImageFont.load_default() + + draw.text((400, 300), "Static Image Slide", fill='black', anchor='mm', font=font) + + # Draw a simple shape + draw.rectangle([200, 200, 600, 400], outline='blue', width=5) + + # Save the image + img_path = "static_image.png" + img.save(img_path) + + # 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=img_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) + img2 = Image.new('RGB', (800, 600), color='lightblue') + draw2 = ImageDraw.Draw(img2) + draw2.text((400, 300), "Another Static Image", fill='darkblue', anchor='mm', font=font) + draw2.ellipse([250, 200, 550, 400], outline='red', width=5) + + img2_path = "static_image2.png" + img2.save(img2_path) + + self.next_slide(static_image=img2_path) + + # Final slide + self.next_slide() + final_text = Text("Static images work seamlessly with animations!", font_size=36) + self.play(Write(final_text)) \ No newline at end of file diff --git a/manim_slides/config.py b/manim_slides/config.py index dfe889ea..8e8727bf 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,12 @@ 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) @@ -162,6 +169,25 @@ class BaseSlideConfig(BaseModel): # type: ignore dedent_notes: bool = True skip_animations: bool = False src: Optional[FilePath] = None + static_image: Optional[Union[FilePath, "PILImage"]] = None + + @model_validator(mode="after") + def validate_src_and_static_image( + self, + ) -> "BaseSlideConfig": + """Validate that src and static_image are not both set.""" + if self.src is not None and self.static_image is not None: + raise ValueError( + "A slide cannot have both 'src' and 'static_image' set at the same time." + ) + return self + + @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]: @@ -215,6 +241,10 @@ def apply_dedent_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 +289,7 @@ 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..f20ff1e6 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -42,41 +42,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 +Image = 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 +85,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: @@ -782,40 +784,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 is_image_file(file): + # Handle static image + slide.shapes.add_picture( + str(file), + self.left, + self.top, + self.width * 9525, + self.height * 9525, + ) + else: + # 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) - 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 + 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, ) - image.save(poster_frame_image) - frame_number += 1 - else: - poster_frame_image = str(self.poster_frame_image) + 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..0a5425b1 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -3,18 +3,19 @@ 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, ) -from ..config import Config, PresentationConfig, SlideConfig +from ..config import Config, PresentationConfig, SlideConfig, SlideType from ..logger import logger from ..resources import * # noqa: F403 @@ -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) - - right_layout.addWidget(next_video_widget) + self.next_content_stack.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,44 @@ 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 +538,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 +584,10 @@ 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 +604,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 +615,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..5c36df83 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -14,11 +14,12 @@ import numpy as np from tqdm import tqdm +import hashlib -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig +from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig, SlideType 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, merge_basenames, process_static_image, reverse_video_file from . import MANIM if TYPE_CHECKING: @@ -353,6 +354,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 +494,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 @@ -549,7 +567,7 @@ def _save_slides( # noqa: C901 for pre_slide_config in tqdm( self._slides, - desc=f"Concatenating animations to '{scene_files_folder}' and generating reversed animations", + desc=f"Processing slides to '{scene_files_folder}'", leave=self._leave_progress_bar, ascii=True if platform.system() == "Windows" else None, disable=not self._show_progress_bar, @@ -557,38 +575,57 @@ def _save_slides( # noqa: C901 ): if pre_slide_config.skip_animations: continue - if pre_slide_config.src: - slide_files = [pre_slide_config.src] + + slide_type = pre_slide_config.slide_type + + if slide_type == SlideType.Image: + # Handle static image slides + if pre_slide_config.static_image is None: + continue + + # Generate a filename for the image + image_hash = hashlib.sha256(str(pre_slide_config.static_image).encode()).hexdigest() + dst_file = scene_files_folder / f"{image_hash}.png" + rev_file = dst_file # For images, reversed file is the same + + # Process the static image if not cached + 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 + # Handle video slides (original logic) + if pre_slide_config.src: + slide_files = [pre_slide_config.src] 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, - ) + 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( 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..68b343c3 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,25 @@ def _filter(files: list[Path]) -> Iterator[Path]: os.unlink(tmp_file) # https://stackoverflow.com/a/54768241 +def process_static_image(image_source: Union[Path, Any], dest: Path) -> None: + """ + Process a static image for slides. + + :param image_source: Either a Path to an image file or a PIL Image object + :param dest: Destination path for the processed image + """ + try: + if isinstance(image_source, Path): + # If it's a file path, just copy it + shutil.copy(image_source, dest) + else: + # If it's a PIL Image object, save it + 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: From a7e8a605bc3fea754b359afb8628a7506351c483 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:06:38 +0530 Subject: [PATCH 02/11] Add static image support to manim-slides --- manim_slides/config.py | 51 ++++++++------ manim_slides/slide/base.py | 136 ++++++++++--------------------------- manim_slides/utils.py | 34 +++++++--- static_image.png | Bin 0 -> 7961 bytes static_image2.png | Bin 0 -> 11178 bytes 5 files changed, 90 insertions(+), 131 deletions(-) create mode 100644 static_image.png create mode 100644 static_image2.png diff --git a/manim_slides/config.py b/manim_slides/config.py index 8e8727bf..366c656d 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -158,29 +158,38 @@ def merge_with(self, other: "Config") -> "Config": return self -class BaseSlideConfig(BaseModel): # type: ignore +class BaseSlideConfig(BaseModel): """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 - static_image: Optional[Union[FilePath, "PILImage"]] = None - - @model_validator(mode="after") - def validate_src_and_static_image( - self, - ) -> "BaseSlideConfig": - """Validate that src and static_image are not both set.""" - if self.src is not None and self.static_image is not None: - raise ValueError( - "A slide cannot have both 'src' and 'static_image' set at the same time." - ) - return self + 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): + 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: diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index 5c36df83..c1b44d5c 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -15,6 +15,7 @@ import numpy as np from tqdm import tqdm import hashlib +import json from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig, SlideType from ..defaults import FOLDER_PATH @@ -528,126 +529,61 @@ 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 ManimSlide - files_folder = self._output_folder / "files" + if not isinstance(self, ManimSlide): + 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"Processing slides to '{scene_files_folder}'", - 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 - - slide_type = pre_slide_config.slide_type - - if slide_type == SlideType.Image: - # Handle static image slides - if pre_slide_config.static_image is None: - continue - - # Generate a filename for the image - image_hash = hashlib.sha256(str(pre_slide_config.static_image).encode()).hexdigest() - dst_file = scene_files_folder / f"{image_hash}.png" - rev_file = dst_file # For images, reversed file is the same - - # Process the static image if not cached if not use_cache or not dst_file.exists(): process_static_image(pre_slide_config.static_image, dst_file) - else: - # Handle video slides (original logic) - if pre_slide_config.src: - slide_files = [pre_slide_config.src] - 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/utils.py b/manim_slides/utils.py index 68b343c3..ee110e46 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -69,19 +69,11 @@ def _filter(files: list[Path]) -> Iterator[Path]: os.unlink(tmp_file) # https://stackoverflow.com/a/54768241 -def process_static_image(image_source: Union[Path, Any], dest: Path) -> None: - """ - Process a static image for slides. - - :param image_source: Either a Path to an image file or a PIL Image object - :param dest: Destination path for the processed image - """ +def process_static_image(image_source, dest): try: - if isinstance(image_source, Path): - # If it's a file path, just copy it + if isinstance(image_source, str): shutil.copy(image_source, dest) else: - # If it's a PIL Image object, save it image_source.save(dest) except Exception as e: logger.error(f"Failed to process static image: {e}") @@ -214,3 +206,25 @@ 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): + import os + import sys + import subprocess + if sys.platform.startswith('darwin'): + subprocess.call(('open', path)) + elif os.name == 'nt': + os.startfile(path) + elif os.name == 'posix': + subprocess.call(('xdg-open', path)) diff --git a/static_image.png b/static_image.png new file mode 100644 index 0000000000000000000000000000000000000000..acc428b5a18b13f784c5e74aee6b439150f6c615 GIT binary patch literal 7961 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 Ht)Tw{g3pxX literal 0 HcmV?d00001 diff --git a/static_image2.png b/static_image2.png new file mode 100644 index 0000000000000000000000000000000000000000..b695b173b5c554dac86aa6e461e6ddb4861e6719 GIT binary patch literal 11178 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 literal 0 HcmV?d00001 From 61ae2252515494034c0c9905438537b6f1847148 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 09:41:47 +0000 Subject: [PATCH 03/11] chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- example_static_image.py | 57 ++++++++++++++++++++-------------- manim_slides/config.py | 21 ++++++++++--- manim_slides/convert.py | 6 ++-- manim_slides/present/player.py | 35 +++++++++++---------- manim_slides/slide/base.py | 20 ++++++------ manim_slides/utils.py | 19 ++++++------ 6 files changed, 91 insertions(+), 67 deletions(-) diff --git a/example_static_image.py b/example_static_image.py index 15c22413..0420be8f 100644 --- a/example_static_image.py +++ b/example_static_image.py @@ -1,59 +1,68 @@ from manim import * -from manim_slides import Slide from PIL import Image, ImageDraw, ImageFont -import numpy as np + +from manim_slides import Slide + class StaticImageExample(Slide): def construct(self): # Create a simple image programmatically - img = Image.new('RGB', (800, 600), color='white') + img = Image.new("RGB", (800, 600), color="white") draw = ImageDraw.Draw(img) - + # Draw some text try: font = ImageFont.truetype("arial.ttf", 40) except: font = ImageFont.load_default() - - draw.text((400, 300), "Static Image Slide", fill='black', anchor='mm', font=font) - + + draw.text( + (400, 300), "Static Image Slide", fill="black", anchor="mm", font=font + ) + # Draw a simple shape - draw.rectangle([200, 200, 600, 400], outline='blue', width=5) - + draw.rectangle([200, 200, 600, 400], outline="blue", width=5) + # Save the image img_path = "static_image.png" img.save(img_path) - + # 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) - + 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=img_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) - img2 = Image.new('RGB', (800, 600), color='lightblue') + img2 = Image.new("RGB", (800, 600), color="lightblue") draw2 = ImageDraw.Draw(img2) - draw2.text((400, 300), "Another Static Image", fill='darkblue', anchor='mm', font=font) - draw2.ellipse([250, 200, 550, 400], outline='red', width=5) - + draw2.text( + (400, 300), "Another Static Image", fill="darkblue", anchor="mm", font=font + ) + draw2.ellipse([250, 200, 550, 400], outline="red", width=5) + img2_path = "static_image2.png" img2.save(img2_path) - + self.next_slide(static_image=img2_path) - + # Final slide self.next_slide() - final_text = Text("Static images work seamlessly with animations!", font_size=36) - self.play(Write(final_text)) \ No newline at end of file + 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 366c656d..c7b30bde 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -28,6 +28,7 @@ class SlideType(Enum): """Enumeration of slide types.""" + Video = "video" Image = "image" @@ -162,7 +163,9 @@ class BaseSlideConfig(BaseModel): """Base class for slide config.""" 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") + 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") @@ -181,10 +184,14 @@ class BaseSlideConfig(BaseModel): 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") + 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') + @field_validator("static_image") @classmethod def validate_static_image(cls, v): if v is not None and cls.src is not None: @@ -298,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.static_image 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 f20ff1e6..f7022bb8 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 @@ -87,7 +85,7 @@ def get_duration_ms(file: Path) -> float: 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'} + image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} return file_path.suffix.lower() in image_extensions @@ -790,7 +788,7 @@ def xpath(el: etree.Element, query: str) -> etree.XPath: file = slide_config.file slide = prs.slides.add_slide(layout) - + if is_image_file(file): # Handle static image slide.shapes.add_picture( diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py index 0a5425b1..4cfa7b2a 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -15,7 +15,7 @@ QWidget, ) -from ..config import Config, PresentationConfig, SlideConfig, SlideType +from ..config import Config, PresentationConfig, SlideConfig from ..logger import logger from ..resources import * # noqa: F403 @@ -47,24 +47,24 @@ 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() 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 @@ -123,10 +123,10 @@ 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) @@ -135,14 +135,14 @@ def __init__( self.next_media_player.setVideoOutput(next_video_widget) self.next_media_player.setLoops(-1) self.next_content_stack.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 @@ -254,20 +254,20 @@ def __init__( # 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.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) @@ -337,12 +337,12 @@ def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: 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'} + 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'} + video_extensions = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm"} return file_path.suffix.lower() in video_extensions """ @@ -456,7 +456,8 @@ def load_current_media(self, start_paused: bool = False) -> None: if self.playing_reversed_slide: self.media_player.setPlaybackRate( - self.current_slide_config.reversed_playback_rate * self.playback_rate + self.current_slide_config.reversed_playback_rate + * self.playback_rate ) else: self.media_player.setPlaybackRate( @@ -587,7 +588,9 @@ def next(self) -> None: 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: + 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() diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index c1b44d5c..fc1da901 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,14 +13,12 @@ ) import numpy as np -from tqdm import tqdm -import hashlib -import json -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig, SlideType +from ..config import BaseSlideConfig, PreSlideConfig from ..defaults import FOLDER_PATH -from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, process_static_image, reverse_video_file +from ..utils import ( + process_static_image, +) from . import MANIM if TYPE_CHECKING: @@ -574,7 +572,9 @@ def _save_slides( slide_data["src"] = str(dst_file.relative_to(slides_dir)) if not use_cache or not dst_file.exists(): - self._process_video_slide(pre_slide_config, dst_file, skip_reversing) + self._process_video_slide( + pre_slide_config, dst_file, skip_reversing + ) slides_data.append(slide_data) @@ -583,7 +583,9 @@ def _save_slides( json.dump(slides_data, f, indent=2) 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}'") + self.logger.info( + f"Slide '{self.__class__.__name__}' configuration written in '{config_file}'" + ) def start_skip_animations(self) -> None: """ diff --git a/manim_slides/utils.py b/manim_slides/utils.py index ee110e46..9b571e65 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, Union +from typing import Any, Optional import av from tqdm import tqdm @@ -209,22 +209,23 @@ def reverse_video_file( def is_image_file(file_path: str) -> bool: - image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'} + 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'} + video_extensions = {".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv"} return Path(file_path).suffix.lower() in video_extensions def open_with_default(path): import os - import sys import subprocess - if sys.platform.startswith('darwin'): - subprocess.call(('open', path)) - elif os.name == 'nt': + import sys + + if sys.platform.startswith("darwin"): + subprocess.call(("open", path)) + elif os.name == "nt": os.startfile(path) - elif os.name == 'posix': - subprocess.call(('xdg-open', path)) + elif os.name == "posix": + subprocess.call(("xdg-open", path)) From f05e1533a8fdaa538e9f77a5e78b00fad55d1529 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:18:41 +0530 Subject: [PATCH 04/11] Fix linting issues and improve code quality --- example_static_image.py | 101 +++++++++++++++++++-------------- manim_slides/config.py | 25 +++++--- manim_slides/convert.py | 10 ++-- manim_slides/present/player.py | 35 ++++++------ manim_slides/slide/base.py | 23 ++++---- manim_slides/utils.py | 21 +++---- 6 files changed, 123 insertions(+), 92 deletions(-) diff --git a/example_static_image.py b/example_static_image.py index 15c22413..51618590 100644 --- a/example_static_image.py +++ b/example_static_image.py @@ -1,59 +1,76 @@ -from manim import * -from manim_slides import Slide +from manim import ( + BLUE, + DOWN, + RED, + Circle, + Create, + FadeIn, + Square, + Text, + Write, +) from PIL import Image, ImageDraw, ImageFont -import numpy as np + +from manim_slides.slide.manim import Slide + class StaticImageExample(Slide): - def construct(self): - # Create a simple image programmatically - img = Image.new('RGB', (800, 600), color='white') - draw = ImageDraw.Draw(img) - - # Draw some text - try: - font = ImageFont.truetype("arial.ttf", 40) - except: - font = ImageFont.load_default() - - draw.text((400, 300), "Static Image Slide", fill='black', anchor='mm', font=font) - - # Draw a simple shape - draw.rectangle([200, 200, 600, 400], outline='blue', width=5) - - # Save the image - img_path = "static_image.png" - img.save(img_path) - + 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) - + 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=img_path) - + 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) - img2 = Image.new('RGB', (800, 600), color='lightblue') - draw2 = ImageDraw.Draw(img2) - draw2.text((400, 300), "Another Static Image", fill='darkblue', anchor='mm', font=font) - draw2.ellipse([250, 200, 550, 400], outline='red', width=5) - - img2_path = "static_image2.png" - img2.save(img2_path) - - self.next_slide(static_image=img2_path) - + 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)) \ No newline at end of file + 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 366c656d..71772ed4 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -28,6 +28,7 @@ class SlideType(Enum): """Enumeration of slide types.""" + Video = "video" Image = "image" @@ -162,7 +163,9 @@ class BaseSlideConfig(BaseModel): """Base class for slide config.""" 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") + 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") @@ -181,12 +184,16 @@ class BaseSlideConfig(BaseModel): 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") + 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') + @field_validator("static_image") @classmethod - def validate_static_image(cls, v): + 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 @@ -244,7 +251,7 @@ 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 @@ -298,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.static_image 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 f20ff1e6..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 @@ -45,7 +43,7 @@ from .utils import open_with_default # Type alias for PIL Image -Image = Any # Will be properly typed when PIL is imported +PILImage = Any # Will be properly typed when PIL is imported def validate_config_option( @@ -87,7 +85,7 @@ def get_duration_ms(file: Path) -> float: 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'} + image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} return file_path.suffix.lower() in image_extensions @@ -126,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"]: @@ -790,7 +788,7 @@ def xpath(el: etree.Element, query: str) -> etree.XPath: file = slide_config.file slide = prs.slides.add_slide(layout) - + if is_image_file(file): # Handle static image slide.shapes.add_picture( diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py index 0a5425b1..4cfa7b2a 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -15,7 +15,7 @@ QWidget, ) -from ..config import Config, PresentationConfig, SlideConfig, SlideType +from ..config import Config, PresentationConfig, SlideConfig from ..logger import logger from ..resources import * # noqa: F403 @@ -47,24 +47,24 @@ 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() 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 @@ -123,10 +123,10 @@ 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) @@ -135,14 +135,14 @@ def __init__( self.next_media_player.setVideoOutput(next_video_widget) self.next_media_player.setLoops(-1) self.next_content_stack.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 @@ -254,20 +254,20 @@ def __init__( # 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.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) @@ -337,12 +337,12 @@ def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: 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'} + 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'} + video_extensions = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm"} return file_path.suffix.lower() in video_extensions """ @@ -456,7 +456,8 @@ def load_current_media(self, start_paused: bool = False) -> None: if self.playing_reversed_slide: self.media_player.setPlaybackRate( - self.current_slide_config.reversed_playback_rate * self.playback_rate + self.current_slide_config.reversed_playback_rate + * self.playback_rate ) else: self.media_player.setPlaybackRate( @@ -587,7 +588,9 @@ def next(self) -> None: 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: + 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() diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index c1b44d5c..566ec218 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,14 +13,9 @@ ) import numpy as np -from tqdm import tqdm -import hashlib -import json -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig, SlideType +from ..config import BaseSlideConfig, PreSlideConfig from ..defaults import FOLDER_PATH -from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, process_static_image, reverse_video_file from . import MANIM if TYPE_CHECKING: @@ -537,9 +532,9 @@ def _save_slides( max_files_cached: int = 100, ) -> None: """Save slides to disk.""" - from .manim import ManimSlide + from .manim import Slide - if not isinstance(self, ManimSlide): + if not isinstance(self, Slide): return slides_dir = Path("slides") @@ -568,13 +563,17 @@ def _save_slides( slide_data["src"] = str(dst_file.relative_to(slides_dir)) if not use_cache or not dst_file.exists(): + from ..utils import process_static_image + process_static_image(pre_slide_config.static_image, dst_file) else: dst_file = files_dir / f"slide_{i:03d}.mp4" slide_data["src"] = str(dst_file.relative_to(slides_dir)) if not use_cache or not dst_file.exists(): - self._process_video_slide(pre_slide_config, dst_file, skip_reversing) + self._process_video_slide( + pre_slide_config, dst_file, skip_reversing + ) slides_data.append(slide_data) @@ -583,7 +582,9 @@ def _save_slides( json.dump(slides_data, f, indent=2) 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}'") + self.logger.info( + f"Slide '{self.__class__.__name__}' configuration written in '{config_file}'" + ) def start_skip_animations(self) -> None: """ diff --git a/manim_slides/utils.py b/manim_slides/utils.py index ee110e46..4cb84041 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -69,7 +69,7 @@ def _filter(files: list[Path]) -> Iterator[Path]: os.unlink(tmp_file) # https://stackoverflow.com/a/54768241 -def process_static_image(image_source, dest): +def process_static_image(image_source: Union[str, Any], dest: Path) -> None: try: if isinstance(image_source, str): shutil.copy(image_source, dest) @@ -209,22 +209,23 @@ def reverse_video_file( def is_image_file(file_path: str) -> bool: - image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'} + 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'} + video_extensions = {".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv"} return Path(file_path).suffix.lower() in video_extensions -def open_with_default(path): +def open_with_default(path: str) -> None: import os - import sys import subprocess - if sys.platform.startswith('darwin'): - subprocess.call(('open', path)) - elif os.name == 'nt': + import sys + + if sys.platform.startswith("darwin"): + subprocess.call(("open", path)) + elif os.name == "nt": os.startfile(path) - elif os.name == 'posix': - subprocess.call(('xdg-open', path)) + elif os.name == "posix": + subprocess.call(("xdg-open", path)) From 1e7bca01267b6e2f90ee40a4a8dfcd70ec7fcd60 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:20:01 +0530 Subject: [PATCH 05/11] Fix missing Union import --- manim_slides/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 1b0b4966..4cb84041 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 From a9c7609999ffd8620394300e08e2c14ed3d78822 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:21:53 +0530 Subject: [PATCH 06/11] Fix final mypy errors with type ignore comments --- manim_slides/config.py | 2 +- manim_slides/utils.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/manim_slides/config.py b/manim_slides/config.py index 71772ed4..31e03226 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -159,7 +159,7 @@ def merge_with(self, other: "Config") -> "Config": return self -class BaseSlideConfig(BaseModel): +class BaseSlideConfig(BaseModel): # type: ignore[misc] """Base class for slide config.""" src: Optional[str] = Field(None, description="Source video file path") diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 4cb84041..477e8730 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -220,12 +220,11 @@ def is_video_file(file_path: str) -> bool: def open_with_default(path: str) -> None: import os - import subprocess import sys - - if sys.platform.startswith("darwin"): - subprocess.call(("open", path)) - elif os.name == "nt": - os.startfile(path) - elif os.name == "posix": - subprocess.call(("xdg-open", path)) + import subprocess + if sys.platform.startswith('darwin'): + subprocess.call(('open', path)) + elif os.name == 'nt': + os.startfile(path) # type: ignore[attr-defined] + elif os.name == 'posix': + subprocess.call(('xdg-open', path)) From c6d0dc8390d3917ab2d22f5efcea5b24f0b1a77d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 09:52:07 +0000 Subject: [PATCH 07/11] chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim_slides/utils.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 477e8730..bc190705 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -220,11 +220,12 @@ def is_video_file(file_path: str) -> bool: def open_with_default(path: str) -> None: import os - import sys import subprocess - if sys.platform.startswith('darwin'): - subprocess.call(('open', path)) - elif os.name == 'nt': + import sys + + if sys.platform.startswith("darwin"): + subprocess.call(("open", path)) + elif os.name == "nt": os.startfile(path) # type: ignore[attr-defined] - elif os.name == 'posix': - subprocess.call(('xdg-open', path)) + elif os.name == "posix": + subprocess.call(("xdg-open", path)) From 563cc57fa005ad3a42f48ad28e1102d208ab6d1c Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:22:24 +0530 Subject: [PATCH 08/11] Remove unused type ignore comment --- manim_slides/utils.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 477e8730..4cb84041 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -220,11 +220,12 @@ def is_video_file(file_path: str) -> bool: def open_with_default(path: str) -> None: import os - import sys import subprocess - if sys.platform.startswith('darwin'): - subprocess.call(('open', path)) - elif os.name == 'nt': - os.startfile(path) # type: ignore[attr-defined] - elif os.name == 'posix': - subprocess.call(('xdg-open', path)) + import sys + + if sys.platform.startswith("darwin"): + subprocess.call(("open", path)) + elif os.name == "nt": + os.startfile(path) + elif os.name == "posix": + subprocess.call(("xdg-open", path)) From c4ef6353f9e54a39c641e70d0a51c2bebf36e1e9 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:25:30 +0530 Subject: [PATCH 09/11] Silence mypy warning for os.startfile with type ignore comment --- manim_slides/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 4cb84041..bc190705 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -226,6 +226,6 @@ def open_with_default(path: str) -> None: if sys.platform.startswith("darwin"): subprocess.call(("open", path)) elif os.name == "nt": - os.startfile(path) + os.startfile(path) # type: ignore[attr-defined] elif os.name == "posix": subprocess.call(("xdg-open", path)) From e8a476aa43f5666e5c24f465f56bedd481df6f55 Mon Sep 17 00:00:00 2001 From: Soham Saha Date: Sun, 29 Jun 2025 15:32:00 +0530 Subject: [PATCH 10/11] Fix security issues in open_with_default function: validate paths, use subprocess.run with shell=False, and add proper error handling --- manim_slides/utils.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index bc190705..eda19cb7 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -219,13 +219,32 @@ def is_video_file(file_path: str) -> bool: def open_with_default(path: str) -> None: - import os + """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.call(("open", path)) + subprocess.run(["open", abs_path], check=True, shell=False) elif os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] + os.startfile(abs_path) # type: ignore[attr-defined] elif os.name == "posix": - subprocess.call(("xdg-open", path)) + subprocess.run(["xdg-open", abs_path], check=True, shell=False) + else: + raise OSError(f"Unsupported operating system: {sys.platform}") From 02eb2ca29cb1d70a503ec5c4f9242c2f0472364d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 10:02:21 +0000 Subject: [PATCH 11/11] chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim_slides/utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index eda19cb7..2410c75f 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -219,27 +219,29 @@ def is_video_file(file_path: str) -> bool: def open_with_default(path: str) -> None: - """Open a file with the system's default application. - + """ + 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":