|
6 | 6 | import tempfile
|
7 | 7 | import webbrowser
|
8 | 8 | from base64 import b64encode
|
| 9 | +from collections import deque |
9 | 10 | from enum import Enum
|
10 | 11 | from importlib import resources
|
11 | 12 | from pathlib import Path
|
12 | 13 | from typing import Any, Callable, Dict, List, Optional, Type, Union
|
13 | 14 |
|
| 15 | +import av |
14 | 16 | import click
|
15 |
| -import cv2 |
16 | 17 | import pptx
|
17 | 18 | from click import Context, Parameter
|
18 | 19 | from jinja2 import Template
|
@@ -79,11 +80,23 @@ def file_to_data_uri(file: Path) -> str:
|
79 | 80 |
|
80 | 81 | def get_duration_ms(file: Path) -> float:
|
81 | 82 | """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] |
85 | 85 |
|
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() |
87 | 100 |
|
88 | 101 |
|
89 | 102 | class Converter(BaseModel): # type: ignore
|
@@ -438,23 +451,6 @@ def open(self, file: Path) -> None:
|
438 | 451 |
|
439 | 452 | def convert_to(self, dest: Path) -> None:
|
440 | 453 | """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 |
| - |
458 | 454 | images = []
|
459 | 455 |
|
460 | 456 | for i, presentation_config in enumerate(self.presentation_configs):
|
@@ -490,7 +486,7 @@ class PowerPoint(Converter):
|
490 | 486 | def open(self, file: Path) -> None:
|
491 | 487 | return open_with_default(file)
|
492 | 488 |
|
493 |
| - def convert_to(self, dest: Path) -> None: # noqa: C901 |
| 489 | + def convert_to(self, dest: Path) -> None: |
494 | 490 | """Convert this configuration into a PowerPoint presentation, saved to DEST."""
|
495 | 491 | prs = pptx.Presentation()
|
496 | 492 | prs.slide_width = self.width * 9525
|
@@ -519,53 +515,48 @@ def xpath(el: etree.Element, query: str) -> etree.XPath:
|
519 | 515 | nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
|
520 | 516 | return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
521 | 517 |
|
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) |
569 | 560 |
|
570 | 561 |
|
571 | 562 | def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
0 commit comments