Skip to content

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

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions example_static_image.py
Copy link
Owner

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).

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))
74 changes: 62 additions & 12 deletions manim_slides/config.py
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 (
Expand All @@ -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)

Expand Down Expand Up @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The 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., BaseSlideconfig would have three new fields: html_config: HTMLConfig, qt_config: QtConfig and pptx_config: PPTXConfig`. Those class will then be able to hold more fined-grained config options for each type of presentation format.


@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
Copy link
Owner

Choose a reason for hiding this comment

The 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]:
Expand Down Expand Up @@ -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:
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use the TYPE_CHECKING + try import trick to have static type checker work with this.



class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering."""

Expand Down Expand Up @@ -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 "
Expand Down
109 changes: 61 additions & 48 deletions manim_slides/convert.py
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
Expand Down Expand Up @@ -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"
Copy link
Owner

Choose a reason for hiding this comment

The 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}"

Expand All @@ -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:
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading