| import json |
| import os |
| import sys |
| import tempfile |
| from typing import Optional |
|
|
| import ffmpeg |
| import typer |
| from loguru import logger |
| from PIL import Image |
| from tqdm import tqdm |
|
|
| logger.remove() |
| logger.add( |
| sys.stderr, |
| format="<d>{time:YYYY-MM-DD ddd HH:mm:ss}</d> | <lvl>{level}</lvl> | <lvl>{message}</lvl>", |
| ) |
| app = typer.Typer(pretty_exceptions_show_locals=False) |
|
|
| |
| TEXT_Y_POSITIONS = ("top", "middle", "bottom") |
|
|
|
|
| def parse_frame_name(fname: str): |
| """return a tuple of frame_type and frame_index |
| |
| Splits on the last underscore so frame types that themselves contain |
| underscores (e.g. ``my_clip``) still round-trip. |
| |
| >>> parse_frame_name("clip_12.jpg") |
| ('clip', 12) |
| >>> parse_frame_name("my_clip_12.jpg") |
| ('my_clip', 12) |
| """ |
| fn, fext = os.path.splitext(os.path.basename(fname)) |
| frame_type, frame_index = fn.rsplit("_", 1) |
| return frame_type, int(frame_index) |
|
|
|
|
| def _drawtext_escape(text: str) -> str: |
| r"""Escape a literal string for ffmpeg's ``drawtext`` text expansion. |
| |
| The overlay text is passed to drawtext via ``textfile=`` (see |
| :func:`extract_frames`), so the file content skips filtergraph parsing |
| entirely and only drawtext's *text expansion* applies: ``\`` escapes the |
| next character and a bare ``%`` starts a ``%{...}`` sequence that silently |
| breaks rendering. Everything else (quotes, colons, commas, spaces) is |
| literal. |
| |
| Args: |
| text (str): Raw text to render verbatim. |
| |
| Returns: |
| str: The escaped text. |
| |
| >>> _drawtext_escape("50% off: it's, fine") |
| "50\\% off: it's, fine" |
| >>> _drawtext_escape("a\\b") |
| 'a\\\\b' |
| """ |
| return text.replace("\\", "\\\\").replace("%", "\\%") |
|
|
|
|
| def _build_drawtext_text( |
| write_timestamp: bool, |
| write_frame_num: bool, |
| text_overlay: Optional[str] = None, |
| ) -> str: |
| """Build the ``drawtext`` overlay text (the ``textfile=`` content). |
| |
| Parts are joined with `` |`` on a single line: an optional caller-supplied |
| prefix (``text_overlay``, escaped via :func:`_drawtext_escape`), then the |
| timestamp, then the frame number. The frame number is only shown when the |
| timestamp is (matching :func:`extract_frames`'s historical behavior). The |
| string is a drawtext text-expansion template — it must reach drawtext |
| verbatim, which is why :func:`extract_frames` passes it via a temp file |
| (``textfile=``) instead of inline (``text=``, whose filtergraph escaping |
| cannot express every literal string). |
| |
| Args: |
| write_timestamp (bool): Include the ``%{pts:hms}`` timestamp part. |
| write_frame_num (bool): Include the ``%{frame_num}`` part (only takes |
| effect when ``write_timestamp`` is True). |
| text_overlay (str | None): Custom literal text prefixed to the line. |
| |
| Returns: |
| str: The drawtext text-expansion template. |
| |
| >>> _build_drawtext_text(True, True) |
| 'Timestamp:%{pts:hms} |Frame Number: %{frame_num}' |
| >>> _build_drawtext_text(True, False) |
| 'Timestamp:%{pts:hms}' |
| >>> _build_drawtext_text(False, False, "cam-1") |
| 'cam-1' |
| >>> _build_drawtext_text(True, True, "cam-1") |
| 'cam-1 |Timestamp:%{pts:hms} |Frame Number: %{frame_num}' |
| """ |
| parts = [] |
| if text_overlay: |
| parts.append(_drawtext_escape(text_overlay)) |
| if write_timestamp: |
| parts.append("Timestamp:%{pts:hms}") |
| if write_frame_num: |
| parts.append("Frame Number: %{frame_num}") |
| return " |".join(parts) |
|
|
|
|
| @app.command() |
| def get_video_metadata(video_path: str, bverbose: bool = True): |
| """ |
| Extract comprehensive metadata from a video file. |
| |
| Args: |
| video_path (str): Path to the video file |
| |
| Returns: |
| dict: Dictionary containing video metadata including: |
| - width, height: Video dimensions |
| - duration: Video duration in seconds |
| - fps: Frames per second |
| - codec: Video codec name |
| - bitrate: Video bitrate |
| - format_name: Container format |
| - file_size: File size in bytes |
| """ |
| probe = ffmpeg.probe(video_path) |
|
|
| |
| video_stream = next( |
| (stream for stream in probe["streams"] if stream["codec_type"] == "video"), |
| None, |
| ) |
|
|
| if video_stream is None: |
| raise ValueError("No video stream found") |
|
|
| |
| format_info = probe.get("format", {}) |
|
|
| |
| width = int(video_stream.get("width", 0)) |
| height = int(video_stream.get("height", 0)) |
| |
| |
| duration = float(video_stream.get("duration") or format_info.get("duration", 0)) |
|
|
| |
| r_frame_rate = video_stream.get("r_frame_rate", "0/1") |
| num, denom = map(int, r_frame_rate.split("/")) |
| fps = num / denom if denom != 0 else 0 |
|
|
| |
| codec = video_stream.get("codec_name", "unknown") |
| bitrate = ( |
| int(video_stream.get("bit_rate", 0)) if video_stream.get("bit_rate") else 0 |
| ) |
|
|
| format_name = format_info.get("format_name", "unknown") |
| file_size = int(format_info.get("size", 0)) |
|
|
| |
| audio_stream = next( |
| (stream for stream in probe["streams"] if stream["codec_type"] == "audio"), |
| None, |
| ) |
|
|
| audio_codec = audio_stream.get("codec_name", "none") if audio_stream else "none" |
| audio_bitrate = ( |
| int(audio_stream.get("bit_rate", 0)) |
| if audio_stream and audio_stream.get("bit_rate") |
| else 0 |
| ) |
|
|
| metadata = { |
| "width": width, |
| "height": height, |
| "duration": duration, |
| "fps": fps, |
| "video_codec": codec, |
| "video_bitrate": bitrate, |
| "audio_codec": audio_codec, |
| "audio_bitrate": audio_bitrate, |
| "format_name": format_name, |
| "file_size": file_size, |
| "total_streams": len(probe["streams"]), |
| } |
| if bverbose: |
| logger.info(f"Video metadata extracted: {json.dumps(metadata, indent=4)}") |
| return metadata |
|
|
|
|
| @app.command() |
| def extract_frames( |
| input_path: str, |
| fps: Optional[float] = 8, |
| max_short_edge: int = 1080, |
| write_timestamp: bool = True, |
| write_frame_num: bool = True, |
| output_dir: Optional[str] = None, |
| out_vid_path: Optional[str] = None, |
| text_overlay: Optional[str] = None, |
| text_font_size: int = 20, |
| text_y_position: str = "bottom", |
| ): |
| """ |
| Extract frames from a video file using FFmpeg. |
| |
| Args: |
| input_path (str): Path to the input video file. |
| fps (float | None): Frames per second to extract; capped to the source |
| fps. Pass ``None`` to skip resampling and extract *every* native |
| frame (so output index i == source frame i — needed for |
| frame-accurate, per-frame annotation). |
| max_short_edge (int): Maximum length of the shorter edge of the extracted frames. |
| write_timestamp (bool): Whether to write the timestamp of each frame. |
| write_frame_num (bool): Whether to write the frame number of each frame. |
| output_dir (str): Directory to save the extracted frames. |
| out_vid_path (str): Path to save the extracted frames as a video. |
| text_overlay (str | None): Custom literal text prefixed to the overlay |
| line (escaped for drawtext). Drawn even when ``write_timestamp`` is |
| False, so it can be used on its own. |
| text_font_size (int): Font size of the timestamp/frame-number overlay. |
| text_y_position (str): Vertical placement of the overlay. One of |
| "top", "middle", or "bottom" (default). |
| |
| Returns: |
| List of PIL Images |
| """ |
| y_position_map = { |
| "top": "2*lh", |
| "middle": "(h-lh)/2", |
| "bottom": "h-(2*lh)", |
| } |
| assert ( |
| text_y_position in TEXT_Y_POSITIONS |
| ), f"text_y_position must be one of {list(TEXT_Y_POSITIONS)}, got {text_y_position!r}" |
| text_y_expr = y_position_map[text_y_position] |
|
|
| if output_dir: |
| assert os.path.isdir( |
| output_dir |
| ), f"Output directory {output_dir} does not exist" |
|
|
| |
| vmeta = get_video_metadata(input_path, bverbose=False) |
| org_w, org_h = vmeta["width"], vmeta["height"] |
| max_short_edge = int(max_short_edge) if max_short_edge else min(org_w, org_h) |
| long_edge = int((max(org_h, org_w) / min(org_h, org_w)) * max_short_edge) |
| long_edge += 0 if long_edge % 2 == 0 else 1 |
| duration = vmeta["duration"] |
| org_fps = vmeta["fps"] |
| if fps is not None and fps > org_fps: |
| logger.debug( |
| f"requested fps({fps}) exceeded source fps({org_fps}): fps will be capped to source fps({org_fps})" |
| ) |
| fps = org_fps |
|
|
| |
| |
| total_frames = int(duration * (org_fps if fps is None else fps)) |
|
|
| |
| add_scale_filter = max_short_edge < min(org_w, org_h) |
| w = max_short_edge if org_w < org_h else long_edge |
| h = max_short_edge if org_w > org_h else long_edge |
| logger.debug(f"Video dimensions: {org_w}x{org_h}") |
| if add_scale_filter: |
| logger.debug(f"\tscaling video to {w}x{h}") |
|
|
| |
| |
| |
| |
| |
| filters = [] |
| drawtext_file = None |
| if fps is not None: |
| filters.append(f"fps={fps}") |
| if write_timestamp or text_overlay: |
| with tempfile.NamedTemporaryFile( |
| "w", suffix=".txt", delete=False, encoding="utf-8" |
| ) as tf: |
| tf.write(_build_drawtext_text(write_timestamp, write_frame_num, text_overlay)) |
| drawtext_file = tf.name |
| filters.append( |
| f"drawtext=textfile={drawtext_file}: x=(w-tw)/2: y={text_y_expr}: fontcolor=white: fontsize={text_font_size}: box=1: boxcolor=0x00000099: boxborderw=5" |
| ) |
| if add_scale_filter: |
| filters.append(f"scale='{w}:{h}'") |
| filter_chain = ",".join(filters) |
|
|
| |
| |
| |
| |
| |
| |
| try: |
| output_kwargs = {"format": "rawvideo", "pix_fmt": "rgb24"} |
| if filter_chain: |
| output_kwargs["vf"] = filter_chain |
| process = ( |
| ffmpeg.input(input_path) |
| .output("pipe:", **output_kwargs) |
| .run_async(pipe_stdout=True) |
| ) |
| logger.info(f"running ffmpeg with filter:\n{filter_chain or '(none)'}") |
|
|
| frame_size = ( |
| long_edge * max_short_edge * 3 if add_scale_filter else org_w * org_h * 3 |
| ) |
| frames = [] |
|
|
| |
| |
| |
| with tqdm(total=total_frames, desc="Extracting frames with FFMPEG") as pbar: |
| while True: |
| in_bytes = process.stdout.read(frame_size) |
| if not in_bytes or len(in_bytes) < frame_size: |
| break |
| frame = Image.frombytes( |
| "RGB", (w, h) if add_scale_filter else (org_w, org_h), in_bytes |
| ) |
| frames.append(frame) |
| pbar.update(1) |
|
|
| process.stdout.close() |
| process.wait() |
|
|
| if output_dir: |
| vname, _ = os.path.splitext(os.path.basename(input_path)) |
| for i, im in enumerate(tqdm(frames, desc=f"Saving frames to {output_dir}")): |
| output_path = os.path.join(output_dir, f"{vname}_{i}.jpg") |
| im.save(output_path) |
|
|
| if out_vid_path: |
| vid_kwargs = { |
| "vcodec": "libx264", |
| "pix_fmt": "yuv420p", |
| "r": org_fps if fps is None else fps, |
| } |
| if filter_chain: |
| vid_kwargs["vf"] = filter_chain |
| ffmpeg.input(input_path).output(out_vid_path, **vid_kwargs).run( |
| overwrite_output=True |
| ) |
| logger.success(f"Video created at {out_vid_path}") |
| finally: |
| if drawtext_file: |
| os.unlink(drawtext_file) |
|
|
| return frames |
|
|
|
|
| @app.command() |
| def extract_specific_frames( |
| input_path: str, |
| timestamps_or_frames: list[str] = typer.Option(), |
| max_short_edge: int = 1080, |
| as_timestamps: bool = True, |
| output_dir: Optional[str] = None, |
| ): |
| """ |
| Extract specific frames from a video file using FFmpeg at given timestamps or frame numbers. |
| |
| Args: |
| input_path (str): Path to the input video file. |
| timestamps_or_frames (list): List of timestamps (in seconds) or frame numbers to extract. |
| max_short_edge (int): Maximum length of the shorter edge of the extracted frames. |
| as_timestamps (bool): If True, treat input list as timestamps. If False, treat as frame numbers. |
| output_dir (str): Directory to save the extracted frames as ``{vname}_{target}.jpg``. |
| If None, frames are only returned in memory. |
| |
| Returns: |
| List of PIL Images corresponding to the specified timestamps/frames |
| """ |
| if output_dir: |
| assert os.path.isdir( |
| output_dir |
| ), f"Output directory {output_dir} does not exist" |
| vname, _ = os.path.splitext(os.path.basename(input_path)) |
| |
| vmeta = get_video_metadata(input_path, bverbose=False) |
| org_w, org_h = vmeta["width"], vmeta["height"] |
| max_short_edge = int(max_short_edge) if max_short_edge else min(org_w, org_h) |
| long_edge = int((max(org_h, org_w) / min(org_h, org_w)) * max_short_edge) |
| long_edge += 0 if long_edge % 2 == 0 else 1 |
| duration = vmeta["duration"] |
| org_fps = vmeta["fps"] |
|
|
| |
| add_scale_filter = max_short_edge < min(org_w, org_h) |
| w = max_short_edge if org_w < org_h else long_edge |
| h = max_short_edge if org_w > org_h else long_edge |
| logger.debug(f"Video dimensions: {org_w}x{org_h}") |
| if add_scale_filter: |
| logger.debug(f"\tscaling video to {w}x{h}") |
| scale_filter = f",scale='{w}:{h}'" if add_scale_filter else "" |
|
|
| frames = [] |
|
|
| for target in tqdm(timestamps_or_frames, desc="Extracting specific frames"): |
| try: |
| |
| if as_timestamps: |
| seek_time = float(target) |
| if seek_time > duration: |
| logger.warning( |
| f"Timestamp {seek_time}s exceeds video duration {duration}s, skipping" |
| ) |
| frames.append(None) |
| continue |
| else: |
| |
| seek_time = float(target) / org_fps |
| if seek_time > duration: |
| logger.warning(f"Frame {target} exceeds video duration, skipping") |
| frames.append(None) |
| continue |
|
|
| filter_chain = f"fps={org_fps}{scale_filter}" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| process = ( |
| ffmpeg.input(input_path, ss=seek_time) |
| .output( |
| "pipe:", |
| vf=filter_chain, |
| format="rawvideo", |
| pix_fmt="rgb24", |
| frames=1, |
| ) |
| .run_async(pipe_stdout=True) |
| ) |
|
|
| frame_size = ( |
| w * h * 3 if add_scale_filter else org_w * org_h * 3 |
| ) |
|
|
| in_bytes = process.stdout.read(frame_size) |
| if in_bytes and len(in_bytes) >= frame_size: |
| frame = Image.frombytes( |
| "RGB", (w, h) if add_scale_filter else (org_w, org_h), in_bytes |
| ) |
| frames.append(frame) |
| if output_dir: |
| frame.save(os.path.join(output_dir, f"{vname}_{target}.jpg")) |
| else: |
| logger.warning( |
| f"Failed to extract frame at {'timestamp' if as_timestamps else 'frame'} {target}" |
| ) |
| frames.append( |
| None |
| ) |
|
|
| process.stdout.close() |
| process.wait() |
|
|
| except Exception as e: |
| logger.error( |
| f"Error extracting frame at {'timestamp' if as_timestamps else 'frame'} {target}: {e}" |
| ) |
| frames.append(None) |
|
|
| |
| logger.info( |
| f"Successfully extracted {len([f for f in frames if f is not None])} out of {len(timestamps_or_frames)} requested frames" |
| ) |
|
|
| return frames |
|
|
|
|
| def frames_to_video( |
| frames, |
| out_path: str, |
| fps: float, |
| *, |
| vcodec: str = "libx264", |
| pix_fmt: str = "yuv420p", |
| ): |
| """Encode a list of PIL frames into a (silent) video file via FFmpeg. |
| |
| RGB frames are piped to ffmpeg's stdin as ``rawvideo`` and encoded. All |
| frames must share the size of ``frames[0]``. The inverse of |
| :func:`extract_frames` — used to write annotated frames back out. |
| |
| Args: |
| frames: Non-empty list of same-size ``PIL.Image.Image`` (converted to RGB). |
| out_path (str): Destination video path (overwritten if it exists). |
| fps (float): Output frame rate. |
| vcodec (str): Video codec (default ``libx264``). |
| pix_fmt (str): Output pixel format (default ``yuv420p``). |
| |
| Returns: |
| str: ``out_path``. |
| |
| Raises: |
| ValueError: If ``frames`` is empty. |
| """ |
| if not frames: |
| raise ValueError("frames_to_video: no frames to encode") |
| w, h = frames[0].size |
| |
| |
| process = ( |
| ffmpeg.input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"{w}x{h}", r=fps) |
| .output( |
| out_path, |
| vcodec=vcodec, |
| pix_fmt=pix_fmt, |
| r=fps, |
| vf="scale=trunc(iw/2)*2:trunc(ih/2)*2", |
| ) |
| .overwrite_output() |
| .run_async(pipe_stdin=True) |
| ) |
| for frame in tqdm(frames, desc=f"Encoding {out_path}"): |
| process.stdin.write(frame.convert("RGB").tobytes()) |
| process.stdin.close() |
| process.wait() |
| logger.success(f"Video created at {out_path} ({len(frames)} frames @ {fps}fps)") |
| return out_path |
|
|
|
|
| @app.command() |
| def extract_audio( |
| video_path: str, |
| output_dir: Optional[str] = None, |
| overwrite: bool = False, |
| lossless: bool = False, |
| ): |
| """Extract the audio track of a video file. |
| |
| By default the audio is re-encoded to mp3. Set ``lossless=True`` to copy the |
| original audio stream without re-encoding into an m4a container. |
| |
| Args: |
| video_path (str): Path to the input video file. |
| output_dir (str): Directory to save the audio under. Defaults to the |
| video's own directory when None. Output is written to |
| ``{output_dir}/{vname}/{vname}.{ext}``. |
| overwrite (bool): Overwrite the output if it already exists. |
| lossless (bool): If True, copy the audio stream (acodec="copy") into an |
| m4a file instead of re-encoding to mp3. |
| |
| Returns: |
| str | None: Path to the extracted audio file, or None on failure / no audio. |
| """ |
| |
| vmeta = get_video_metadata(video_path, bverbose=False) |
| if vmeta.get("audio_codec") == "none": |
| logger.error(f"No audio found in {video_path}") |
| return None |
|
|
| |
| output_dir = output_dir if output_dir else os.path.dirname(video_path) |
| vname, vext = os.path.splitext(os.path.basename(video_path)) |
| output_dir = os.path.join(output_dir, vname) |
| out_ext = "m4a" if lossless else "mp3" |
| output_fname = os.path.join(output_dir, f"{vname}.{out_ext}") |
| if os.path.isfile(output_fname): |
| if overwrite: |
| os.remove(output_fname) |
| logger.warning(f"removed existing data: {output_fname}") |
| else: |
| logger.error(f"overwrite is false and data already exists: {output_fname}") |
| return None |
| os.makedirs(output_dir, exist_ok=True) |
|
|
| |
| stream = ffmpeg.input(video_path) |
| config_dict = {"map": "0:a", "acodec": "copy" if lossless else "mp3"} |
| stream = ffmpeg.output(stream, output_fname, **config_dict) |
|
|
| |
| try: |
| ffmpeg.run(stream, capture_stdout=True, capture_stderr=True) |
| logger.success(f"audio extracted to {output_fname}") |
| return output_fname |
| except ffmpeg.Error as e: |
| logger.error(f"Error executing FFmpeg command: {e.stderr.decode()}") |
| return None |
|
|
|
|
| if __name__ == "__main__": |
| app() |
|
|