mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-08-06 14:19:52 +08:00
@ -6,13 +6,14 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
from collections import deque
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||||
|
|
||||||
|
import av
|
||||||
import click
|
import click
|
||||||
import cv2
|
|
||||||
import pptx
|
import pptx
|
||||||
from click import Context, Parameter
|
from click import Context, Parameter
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@ -79,11 +80,23 @@ def file_to_data_uri(file: Path) -> str:
|
|||||||
|
|
||||||
def get_duration_ms(file: Path) -> float:
|
def get_duration_ms(file: Path) -> float:
|
||||||
"""Read a video and return its duration in milliseconds."""
|
"""Read a video and return its duration in milliseconds."""
|
||||||
cap = cv2.VideoCapture(str(file))
|
with av.open(str(file)) as container:
|
||||||
fps: int = cap.get(cv2.CAP_PROP_FPS)
|
video = container.streams.video[0]
|
||||||
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
||||||
|
|
||||||
return 1000 * frame_count / fps
|
return float(1000 * video.duration * video.time_base)
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image:
|
||||||
|
"""Read a image from a video file at a given index."""
|
||||||
|
with av.open(str(file)) as container:
|
||||||
|
frames = container.decode(video=0)
|
||||||
|
|
||||||
|
if frame_index == FrameIndex.last:
|
||||||
|
(frame,) = deque(frames, 1)
|
||||||
|
else:
|
||||||
|
frame = next(frames)
|
||||||
|
|
||||||
|
return frame.to_image()
|
||||||
|
|
||||||
|
|
||||||
class Converter(BaseModel): # type: ignore
|
class Converter(BaseModel): # type: ignore
|
||||||
@ -438,23 +451,6 @@ class PDF(Converter):
|
|||||||
|
|
||||||
def convert_to(self, dest: Path) -> None:
|
def convert_to(self, dest: Path) -> None:
|
||||||
"""Convert this configuration into a PDF presentation, saved to DEST."""
|
"""Convert this configuration into a PDF presentation, saved to DEST."""
|
||||||
|
|
||||||
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
|
|
||||||
cap = cv2.VideoCapture(str(file))
|
|
||||||
|
|
||||||
if frame_index == FrameIndex.last:
|
|
||||||
index = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
||||||
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
|
|
||||||
|
|
||||||
ret, frame = cap.read()
|
|
||||||
cap.release()
|
|
||||||
|
|
||||||
if ret:
|
|
||||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
||||||
return Image.fromarray(frame)
|
|
||||||
else:
|
|
||||||
raise ValueError("Failed to read {image_index} image from video file")
|
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
|
|
||||||
for i, presentation_config in enumerate(self.presentation_configs):
|
for i, presentation_config in enumerate(self.presentation_configs):
|
||||||
@ -490,7 +486,7 @@ class PowerPoint(Converter):
|
|||||||
def open(self, file: Path) -> None:
|
def open(self, file: Path) -> None:
|
||||||
return open_with_default(file)
|
return open_with_default(file)
|
||||||
|
|
||||||
def convert_to(self, dest: Path) -> None: # noqa: C901
|
def convert_to(self, dest: Path) -> None:
|
||||||
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
|
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
|
||||||
prs = pptx.Presentation()
|
prs = pptx.Presentation()
|
||||||
prs.slide_width = self.width * 9525
|
prs.slide_width = self.width * 9525
|
||||||
@ -519,53 +515,48 @@ class PowerPoint(Converter):
|
|||||||
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
|
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
|
||||||
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
||||||
|
|
||||||
def save_first_image_from_video_file(file: Path) -> Optional[str]:
|
with tempfile.TemporaryDirectory() as directory_name:
|
||||||
cap = cv2.VideoCapture(file.as_posix())
|
directory = Path(directory_name)
|
||||||
ret, frame = cap.read()
|
frame_number = 0
|
||||||
cap.release()
|
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}",
|
||||||
|
leave=False,
|
||||||
|
):
|
||||||
|
file = slide_config.file
|
||||||
|
|
||||||
if ret:
|
mime_type = mimetypes.guess_type(file)[0]
|
||||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
|
|
||||||
cv2.imwrite(f.name, frame)
|
|
||||||
f.close()
|
|
||||||
return f.name
|
|
||||||
else:
|
|
||||||
logger.warn("Failed to read first image from video file")
|
|
||||||
return None
|
|
||||||
|
|
||||||
for i, presentation_config in enumerate(self.presentation_configs):
|
if self.poster_frame_image is None:
|
||||||
for slide_config in tqdm(
|
poster_frame_image = str(directory / f"{frame_number}.png")
|
||||||
presentation_config.slides,
|
image = read_image_from_video_file(
|
||||||
desc=f"Generating video slides for config {i + 1}",
|
file, frame_index=FrameIndex.first
|
||||||
leave=False,
|
)
|
||||||
):
|
image.save(poster_frame_image)
|
||||||
file = slide_config.file
|
|
||||||
|
|
||||||
mime_type = mimetypes.guess_type(file)[0]
|
frame_number += 1
|
||||||
|
else:
|
||||||
|
poster_frame_image = str(self.poster_frame_image)
|
||||||
|
|
||||||
if self.poster_frame_image is None:
|
slide = prs.slides.add_slide(layout)
|
||||||
poster_frame_image = save_first_image_from_video_file(file)
|
movie = slide.shapes.add_movie(
|
||||||
else:
|
str(file),
|
||||||
poster_frame_image = str(self.poster_frame_image)
|
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
|
||||||
|
|
||||||
slide = prs.slides.add_slide(layout)
|
if self.auto_play_media:
|
||||||
movie = slide.shapes.add_movie(
|
auto_play_media(movie, loop=slide_config.loop)
|
||||||
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:
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
auto_play_media(movie, loop=slide_config.loop)
|
prs.save(dest)
|
||||||
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
prs.save(dest)
|
|
||||||
|
|
||||||
|
|
||||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
@ -23,7 +23,6 @@ dependencies = [
|
|||||||
"jinja2>=3.1.2",
|
"jinja2>=3.1.2",
|
||||||
"lxml>=4.9.2",
|
"lxml>=4.9.2",
|
||||||
"numpy>=1.19",
|
"numpy>=1.19",
|
||||||
"opencv-python>=4.6.0.66",
|
|
||||||
"pillow>=9.5.0",
|
"pillow>=9.5.0",
|
||||||
"pydantic>=2.0.1",
|
"pydantic>=2.0.1",
|
||||||
"pydantic-extra-types>=2.0.0",
|
"pydantic-extra-types>=2.0.0",
|
||||||
|
@ -153,11 +153,14 @@ class TestConverter:
|
|||||||
file_contents = Path(out_file).read_text()
|
file_contents = Path(out_file).read_text()
|
||||||
assert "manim" in file_contents.casefold()
|
assert "manim" in file_contents.casefold()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("frame_index", ("first", "last"))
|
||||||
def test_pdf_converter(
|
def test_pdf_converter(
|
||||||
self, tmp_path: Path, presentation_config: PresentationConfig
|
self, frame_index: str, tmp_path: Path, presentation_config: PresentationConfig
|
||||||
) -> None:
|
) -> None:
|
||||||
out_file = tmp_path / "slides.pdf"
|
out_file = tmp_path / "slides.pdf"
|
||||||
PDF(presentation_configs=[presentation_config]).convert_to(out_file)
|
PDF(
|
||||||
|
presentation_configs=[presentation_config], frame_index=frame_index
|
||||||
|
).convert_to(out_file)
|
||||||
assert out_file.exists()
|
assert out_file.exists()
|
||||||
|
|
||||||
def test_converter_no_presentation_config(self) -> None:
|
def test_converter_no_presentation_config(self) -> None:
|
||||||
|
Reference in New Issue
Block a user