|
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