-
-
Notifications
You must be signed in to change notification settings - Fork 61
feat(lib): static image support #554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
59fc3b4
a7e8a60
61ae225
f05e153
96cdee6
1e7bca0
a9c7609
c6d0dc8
563cc57
5fcecca
c4ef635
e8a476a
02eb2ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
) | ||
Comment on lines
+165
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Much of those are HTML-specific variables, which I am not a great fan of to over-complexity the config file format. Maybe it would be better to create subclasses for format-specific configs. E.g., |
||
|
||
@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 | ||
Comment on lines
+194
to
+199
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! |
||
|
||
@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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! |
||
self.notes = dedent(self.notes) | ||
|
||
return self | ||
|
||
|
||
# Forward reference for PIL Image type | ||
PILImage = Any # Will be properly typed when PIL is imported | ||
Comment on lines
+260
to
+261
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can use the |
||
|
||
|
||
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 " | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the motivation to change this? |
||
|
||
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) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice to have examples, but I would rather have them put inside the documentation (so they do not flood the main folder, and can be rendered inside the docs).