Spaces:
Sleeping
Sleeping
| """影片讀寫封裝(依賴 opencv/imageio-ffmpeg;extras: infer)。 | |
| 輸出約定:所有「給人看」的影片一律 H.264(yuv420p + faststart)。 | |
| OpenCV 的 mp4v(MPEG-4 Part 2)在瀏覽器/Gradio 會是黑畫面,且 pip 版 | |
| opencv-python 因授權不含 H.264 encoder——因此先寫 mp4v 暫存檔, | |
| 關檔時用 ffmpeg(由 imageio-ffmpeg 保證存在)重編碼。 | |
| """ | |
| from __future__ import annotations | |
| import subprocess | |
| import tempfile | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterator | |
| import cv2 | |
| import numpy as np | |
| class VideoInfo: | |
| fps: float | |
| width: int | |
| height: int | |
| n_frames: int | |
| def probe(path: str | Path) -> VideoInfo: | |
| """取得影片基本資訊;開不了檔直接拋錯。""" | |
| cap = cv2.VideoCapture(str(path)) | |
| if not cap.isOpened(): | |
| raise FileNotFoundError(f"無法開啟影片:{path}") | |
| info = VideoInfo( | |
| fps=float(cap.get(cv2.CAP_PROP_FPS)) or 30.0, | |
| width=int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), | |
| height=int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), | |
| n_frames=int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), | |
| ) | |
| cap.release() | |
| return info | |
| def iter_frames(path: str | Path) -> Iterator[tuple[int, np.ndarray]]: | |
| """逐幀迭代 (frame_idx, BGR frame)。""" | |
| cap = cv2.VideoCapture(str(path)) | |
| if not cap.isOpened(): | |
| raise FileNotFoundError(f"無法開啟影片:{path}") | |
| idx = 0 | |
| try: | |
| while True: | |
| ok, frame = cap.read() | |
| if not ok: | |
| break | |
| yield idx, frame | |
| idx += 1 | |
| finally: | |
| cap.release() | |
| def get_ffmpeg_exe() -> str: | |
| import imageio_ffmpeg | |
| return imageio_ffmpeg.get_ffmpeg_exe() | |
| def reencode_h264(src: str | Path, dst: str | Path) -> None: | |
| """重編碼為瀏覽器可播的 H.264 mp4(yuv420p + faststart)。""" | |
| dst = Path(dst) | |
| dst.parent.mkdir(parents=True, exist_ok=True) | |
| cmd = [ | |
| get_ffmpeg_exe(), | |
| "-y", | |
| "-loglevel", "error", | |
| "-i", str(src), | |
| "-c:v", "libx264", | |
| "-pix_fmt", "yuv420p", # yuv444 在 Chrome/Safari 播不了 | |
| "-movflags", "+faststart", # moov atom 前置,邊下載邊播 | |
| str(dst), | |
| ] | |
| subprocess.run(cmd, check=True) | |
| class H264VideoWriter: | |
| """先寫 mp4v 暫存,close() 時 ffmpeg 重編碼成 H.264 輸出。""" | |
| def __init__(self, out_path: str | Path, fps: float, width: int, height: int): | |
| self.out_path = Path(out_path) | |
| self._tmp = Path(tempfile.mkstemp(suffix=".mp4")[1]) | |
| fourcc = cv2.VideoWriter_fourcc(*"mp4v") | |
| self._writer = cv2.VideoWriter(str(self._tmp), fourcc, fps, (width, height)) | |
| if not self._writer.isOpened(): | |
| raise RuntimeError(f"VideoWriter 開啟失敗(fps={fps}, size={width}x{height})") | |
| def write(self, frame_bgr: np.ndarray) -> None: | |
| self._writer.write(frame_bgr) | |
| def close(self) -> None: | |
| self._writer.release() | |
| try: | |
| reencode_h264(self._tmp, self.out_path) | |
| finally: | |
| self._tmp.unlink(missing_ok=True) | |
| def __enter__(self) -> "H264VideoWriter": | |
| return self | |
| def __exit__(self, *exc) -> None: | |
| self.close() | |
| def write_video_mp4v(frames: Iterator[np.ndarray], out_path: str | Path, fps: float) -> int: | |
| """把幀序列寫成 mp4v 影片(僅供內部再處理,如 URFD PNG 序列重組; | |
| 非瀏覽器播放用途,不做 H.264 重編碼以省時)。回傳寫入幀數。""" | |
| out_path = Path(out_path) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| writer = None | |
| n = 0 | |
| for frame in frames: | |
| if writer is None: | |
| h, w = frame.shape[:2] | |
| writer = cv2.VideoWriter( | |
| str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h) | |
| ) | |
| if not writer.isOpened(): | |
| raise RuntimeError(f"VideoWriter 開啟失敗:{out_path}") | |
| writer.write(frame) | |
| n += 1 | |
| if writer is not None: | |
| writer.release() | |
| return n | |