Skip to content

Commit 5920a84

Browse files
committed
Merge remote-tracking branch 'origin/main' into main
2 parents 3e2e64b + 59dd365 commit 5920a84

File tree

4 files changed

+975
-942
lines changed

4 files changed

+975
-942
lines changed

manim_slides/convert.py

+61-70
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
import tempfile
77
import webbrowser
88
from base64 import b64encode
9+
from collections import deque
910
from enum import Enum
1011
from importlib import resources
1112
from pathlib import Path
1213
from typing import Any, Callable, Dict, List, Optional, Type, Union
1314

15+
import av
1416
import click
15-
import cv2
1617
import pptx
1718
from click import Context, Parameter
1819
from jinja2 import Template
@@ -79,11 +80,23 @@ def file_to_data_uri(file: Path) -> str:
7980

8081
def get_duration_ms(file: Path) -> float:
8182
"""Read a video and return its duration in milliseconds."""
82-
cap = cv2.VideoCapture(str(file))
83-
fps: int = cap.get(cv2.CAP_PROP_FPS)
84-
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
83+
with av.open(str(file)) as container:
84+
video = container.streams.video[0]
8585

86-
return 1000 * frame_count / fps
86+
return float(1000 * video.duration * video.time_base)
87+
88+
89+
def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image:
90+
"""Read a image from a video file at a given index."""
91+
with av.open(str(file)) as container:
92+
frames = container.decode(video=0)
93+
94+
if frame_index == FrameIndex.last:
95+
(frame,) = deque(frames, 1)
96+
else:
97+
frame = next(frames)
98+
99+
return frame.to_image()
87100

88101

89102
class Converter(BaseModel): # type: ignore
@@ -438,23 +451,6 @@ def open(self, file: Path) -> None:
438451

439452
def convert_to(self, dest: Path) -> None:
440453
"""Convert this configuration into a PDF presentation, saved to DEST."""
441-
442-
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
443-
cap = cv2.VideoCapture(str(file))
444-
445-
if frame_index == FrameIndex.last:
446-
index = cap.get(cv2.CAP_PROP_FRAME_COUNT)
447-
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
448-
449-
ret, frame = cap.read()
450-
cap.release()
451-
452-
if ret:
453-
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
454-
return Image.fromarray(frame)
455-
else:
456-
raise ValueError("Failed to read {image_index} image from video file")
457-
458454
images = []
459455

460456
for i, presentation_config in enumerate(self.presentation_configs):
@@ -490,7 +486,7 @@ class PowerPoint(Converter):
490486
def open(self, file: Path) -> None:
491487
return open_with_default(file)
492488

493-
def convert_to(self, dest: Path) -> None: # noqa: C901
489+
def convert_to(self, dest: Path) -> None:
494490
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
495491
prs = pptx.Presentation()
496492
prs.slide_width = self.width * 9525
@@ -519,53 +515,48 @@ def xpath(el: etree.Element, query: str) -> etree.XPath:
519515
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
520516
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
521517

522-
def save_first_image_from_video_file(file: Path) -> Optional[str]:
523-
cap = cv2.VideoCapture(file.as_posix())
524-
ret, frame = cap.read()
525-
cap.release()
526-
527-
if ret:
528-
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
529-
cv2.imwrite(f.name, frame)
530-
f.close()
531-
return f.name
532-
else:
533-
logger.warn("Failed to read first image from video file")
534-
return None
535-
536-
for i, presentation_config in enumerate(self.presentation_configs):
537-
for slide_config in tqdm(
538-
presentation_config.slides,
539-
desc=f"Generating video slides for config {i + 1}",
540-
leave=False,
541-
):
542-
file = slide_config.file
543-
544-
mime_type = mimetypes.guess_type(file)[0]
545-
546-
if self.poster_frame_image is None:
547-
poster_frame_image = save_first_image_from_video_file(file)
548-
else:
549-
poster_frame_image = str(self.poster_frame_image)
550-
551-
slide = prs.slides.add_slide(layout)
552-
movie = slide.shapes.add_movie(
553-
str(file),
554-
self.left,
555-
self.top,
556-
self.width * 9525,
557-
self.height * 9525,
558-
poster_frame_image=poster_frame_image,
559-
mime_type=mime_type,
560-
)
561-
if slide_config.notes != "":
562-
slide.notes_slide.notes_text_frame.text = slide_config.notes
563-
564-
if self.auto_play_media:
565-
auto_play_media(movie, loop=slide_config.loop)
566-
567-
dest.parent.mkdir(parents=True, exist_ok=True)
568-
prs.save(dest)
518+
with tempfile.TemporaryDirectory() as directory_name:
519+
directory = Path(directory_name)
520+
frame_number = 0
521+
for i, presentation_config in enumerate(self.presentation_configs):
522+
for slide_config in tqdm(
523+
presentation_config.slides,
524+
desc=f"Generating video slides for config {i + 1}",
525+
leave=False,
526+
):
527+
file = slide_config.file
528+
529+
mime_type = mimetypes.guess_type(file)[0]
530+
531+
if self.poster_frame_image is None:
532+
poster_frame_image = str(directory / f"{frame_number}.png")
533+
image = read_image_from_video_file(
534+
file, frame_index=FrameIndex.first
535+
)
536+
image.save(poster_frame_image)
537+
538+
frame_number += 1
539+
else:
540+
poster_frame_image = str(self.poster_frame_image)
541+
542+
slide = prs.slides.add_slide(layout)
543+
movie = slide.shapes.add_movie(
544+
str(file),
545+
self.left,
546+
self.top,
547+
self.width * 9525,
548+
self.height * 9525,
549+
poster_frame_image=poster_frame_image,
550+
mime_type=mime_type,
551+
)
552+
if slide_config.notes != "":
553+
slide.notes_slide.notes_text_frame.text = slide_config.notes
554+
555+
if self.auto_play_media:
556+
auto_play_media(movie, loop=slide_config.loop)
557+
558+
dest.parent.mkdir(parents=True, exist_ok=True)
559+
prs.save(dest)
569560

570561

571562
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:

0 commit comments

Comments
 (0)