"""
Video Helper — argparse-based command-line interface.
Thin wrapper around the pure functions in :mod:`video_helper.main` that
exposes the whole toolkit as subcommands under a single ``video-helper``
entry point. Written with :mod:`argparse` from the standard library so
the CLI works out of the box on any Python install that has the package
installed — no extra dependency required.
Subcommands
-----------
- ``validate`` — probe a video file / URL for validity (boolean)
- ``dimensions`` — dump width/height/duration/frame_rate/has_sound as JSON
- ``duration`` — print the duration in seconds of a video
- ``convert`` — re-encode / resize / drop audio
- ``chunk`` — extract a ``[start, end]`` slice
- ``black`` — synthesize a silent solid-black clip
- ``image-loop`` — loop a still image into a silent clip
- ``concat`` — concatenate several videos head-to-tail
- ``overlay`` — overlay a still image on a video (watermark / cursor)
- ``extract-audio`` — dump the audio track of a video
- ``mux-audio`` — mux a separate audio track onto a video
- ``burn-subs`` — burn ``.srt`` / ``.vtt`` / ``.ass`` into the frames
- ``srt2vtt`` — SRT → WebVTT with color-preserving CSS
- ``extract-frames``— stream frames to disk (one PNG per sampled frame)
Usage Example
-------------
>>> # video-helper validate --input clip.mp4
>>> # video-helper dimensions --input clip.mp4
>>> # video-helper duration --input clip.mp4
>>> # video-helper convert --input in.mov --output out.mp4 --width 640 --height 480
>>> # video-helper chunk --input in.mp4 --start 10 --end 20 --output cut.mp4
>>> # video-helper black --duration 3 --width 1920 --height 1080 --output buffer.mp4
>>> # video-helper image-loop --image title.png --duration 4 --output title.mp4 --width 1920 --height 1080
>>> # video-helper concat --inputs a.mp4 b.mp4 c.mp4 --output final.mp4
>>> # video-helper overlay --input clip.mp4 --image logo.png --output watermarked.mp4 --x 10 --y 10
>>> # video-helper extract-audio --input clip.mp4 --output audio.wav
>>> # video-helper mux-audio --input silent.mp4 --audio voice.wav --output final.mp4
>>> # video-helper burn-subs --input clip.mp4 --subs subs.srt --output captioned.mp4
>>> # video-helper srt2vtt --input subs.srt
>>> # video-helper extract-frames --input clip.mp4 --output-dir frames/ --frame-step 5
Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""
from __future__ import annotations
import argparse
import json
import os
from collections.abc import Sequence
import os_helper as osh
# Import the pure functions once here — every subcommand is a thin dispatch
# on top of these, no logic duplication.
from . import (
black_video,
burn_subtitles,
concat_videos,
extract_audio_track,
extract_frames,
extract_video_chunk,
image_loop_to_video,
is_valid_video_file,
mux_audio_video,
overlay_image,
srt2vtt,
video_converter,
video_dimensions,
video_duration,
)
# ---------------------------------------------------------------------------
# Subcommand handlers
#
# Each handler receives the parsed ``argparse.Namespace`` and returns a
# process exit code (``0`` on success). Handlers stay short: they translate
# CLI arguments into keyword arguments for the underlying library function,
# print a machine-friendly result (JSON for structured outputs, plain path
# for single-file outputs), and let exceptions propagate as non-zero exit
# codes.
# ---------------------------------------------------------------------------
def _handle_validate(ns: argparse.Namespace) -> int:
"""
Probe a video file / URL for validity and print the boolean.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# is_valid_video_file returns bool; emit lowercase JSON so the exit
# code matches shell expectations (0 = ok, 1 = invalid) but stdout
# still carries a machine-friendly value.
ok = is_valid_video_file(ns.input)
print("true" if ok else "false")
return 0 if ok else 1
def _handle_dimensions(ns: argparse.Namespace) -> int:
"""
Emit width/height/duration/frame_rate/has_sound as JSON.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# video_dimensions returns a dict — pass http_headers through when
# the input is a URL (e.g. yt-dlp-resolved streams that need cookies).
headers = _parse_headers(ns.header) if ns.header else None
info = video_dimensions(ns.input, http_headers=headers)
print(json.dumps(info, indent=2))
return 0
def _handle_duration(ns: argparse.Namespace) -> int:
"""
Print the duration of a video, in seconds.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# video_duration returns a float in seconds.
print(f"{video_duration(ns.input):.6f}")
return 0
def _handle_convert(ns: argparse.Namespace) -> int:
"""
Re-encode / resize / drop audio, then print the output path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# video_converter re-encodes / resizes / drops audio in one pass.
video_converter(
input_video=ns.input,
output_video=ns.output,
frame_rate=ns.frame_rate,
width=ns.width,
height=ns.height,
without_sound=ns.without_sound,
)
print(ns.output)
return 0
def _handle_chunk(ns: argparse.Namespace) -> int:
"""
Extract a ``[start, end]`` slice and print the output path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# extract_video_chunk cuts a [start, end] slice into a new file.
extract_video_chunk(
input_video=ns.input,
sample_start=ns.start,
sample_end=ns.end,
output_video=ns.output,
)
print(ns.output)
return 0
def _handle_black(ns: argparse.Namespace) -> int:
"""
Synthesize a silent solid-black clip and print its path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Silent solid-black clip — buffer / breathing shot in a montage.
black_video(
duration=ns.duration,
width=ns.width,
height=ns.height,
output_video=ns.output,
frame_rate=ns.frame_rate,
)
print(ns.output)
return 0
def _handle_image_loop(ns: argparse.Namespace) -> int:
"""
Loop a still image into a silent video and print its path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Loop a still image into a silent video — title-card / slide use case.
image_loop_to_video(
image=ns.image,
duration=ns.duration,
output_video=ns.output,
frame_rate=ns.frame_rate,
width=ns.width,
height=ns.height,
)
print(ns.output)
return 0
def _handle_concat(ns: argparse.Namespace) -> int:
"""
Concatenate several videos head-to-tail and print the path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# concat_videos joins N files via ffmpeg's concat demuxer.
concat_videos(
input_videos=list(ns.inputs),
output_video=ns.output,
reencode=ns.reencode,
frame_rate=ns.frame_rate,
)
print(ns.output)
return 0
def _handle_overlay(ns: argparse.Namespace) -> int:
"""
Overlay a still image on a video and print the output path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Overlay a still image (PNG with alpha typical) on the video.
overlay_image(
input_video=ns.input,
image=ns.image,
output_video=ns.output,
x=ns.x,
y=ns.y,
scale_width=ns.scale_width,
)
print(ns.output)
return 0
def _handle_extract_audio(ns: argparse.Namespace) -> int:
"""
Dump the audio track of a video and print the output path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Dump the audio track of a video into a standalone audio file.
extract_audio_track(
input_video=ns.input,
output_audio=ns.output,
sample_rate=ns.sample_rate,
channels=ns.channels,
encoding=ns.encoding,
)
print(ns.output)
return 0
def _handle_mux_audio(ns: argparse.Namespace) -> int:
"""
Mux a separate audio track onto a video and print the path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Replace the video's audio track with a separate audio file.
mux_audio_video(
input_video=ns.input,
input_audio=ns.audio,
output_video=ns.output,
audio_codec=ns.audio_codec,
audio_bitrate=ns.audio_bitrate,
shortest=ns.shortest,
)
print(ns.output)
return 0
def _handle_burn_subs(ns: argparse.Namespace) -> int:
"""
Burn subtitles into the video frames and print the output path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Burn subtitles into the video frames via libass.
burn_subtitles(
input_video=ns.input,
subtitles_file=ns.subs,
output_video=ns.output,
force_style=ns.force_style,
)
print(ns.output)
return 0
def _handle_srt2vtt(ns: argparse.Namespace) -> int:
"""
Convert an SRT to WebVTT (+CSS) and print the .vtt path.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# SRT → WebVTT with color-preserving CSS sidecar.
srt2vtt(
srt_file_path=ns.input,
vtt_file_path=ns.output,
css_file_path=ns.css,
)
# srt2vtt writes both files without returning paths — emit the vtt path
# (derived from stem when --output omitted) so pipelines can chain.
if ns.output:
print(ns.output)
else:
stem, _ = os.path.splitext(ns.input)
print(stem + ".vtt")
return 0
def _handle_extract_frames(ns: argparse.Namespace) -> int:
"""
Stream frames to disk as PNGs and print a JSON manifest.
Parameters
----------
ns : argparse.Namespace
Parsed arguments for this subcommand.
Returns
-------
int
Process exit code (``0`` on success).
"""
# Stream frames to disk as PNGs. Frame indexing lets us name files
# with a zero-padded counter that matches the sampled order.
import cv2 # noqa: WPS433 — deferred so `--help` stays cheap
osh.make_directory(ns.output_dir)
written: list[str] = []
# We iterate lazily — extract_frames yields BGR uint8 numpy arrays
# matching OpenCV's convention, so cv2.imwrite writes them verbatim.
iterator = extract_frames(
video_path=ns.input,
frame_step=ns.frame_step,
frame_interval=ns.frame_interval,
start_instant=ns.start,
end_instant=ns.end,
backend=ns.backend,
)
for i, frame in enumerate(iterator):
path = os.path.join(ns.output_dir, f"frame_{i:09d}.png")
cv2.imwrite(path, frame)
written.append(path)
# Emit the manifest as JSON so downstream tools can pick up the file
# list without re-scanning the directory.
print(json.dumps({"frames": written, "count": len(written)}, indent=2))
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_headers(pairs: Sequence[str]) -> dict:
"""Turn ``["User-Agent: X", "Referer: Y"]`` into a headers dict."""
out: dict = {}
for p in pairs:
# Split only on the first ':' — header values may legitimately contain colons.
if ":" not in p:
continue
k, v = p.split(":", 1)
out[k.strip()] = v.strip()
return out
# ---------------------------------------------------------------------------
# Parser construction
#
# One helper per subcommand keeps ``build_parser`` readable and lets the
# click twin (:mod:`video_helper.cli_click`) mirror the exact same flag
# names without any risk of drift.
# ---------------------------------------------------------------------------
def _add_validate(sub: argparse._SubParsersAction) -> None:
"""
Register the ``validate`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("validate", help="Probe a video file / URL for validity.")
p.add_argument("--input", required=True, help="Path or HTTP(S) URL to a video.")
p.set_defaults(func=_handle_validate)
def _add_dimensions(sub: argparse._SubParsersAction) -> None:
"""
Register the ``dimensions`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser(
"dimensions", help="Emit width/height/duration/frame_rate/has_sound as JSON."
)
p.add_argument("--input", required=True, help="Path or HTTP(S) URL to a video.")
p.add_argument(
"--header",
action="append",
default=[],
help='HTTP header as "Name: value" (repeat --header). Forwarded to ffprobe for URL inputs.',
)
p.set_defaults(func=_handle_dimensions)
def _add_duration(sub: argparse._SubParsersAction) -> None:
"""
Register the ``duration`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("duration", help="Print the duration of a video, in seconds.")
p.add_argument("--input", required=True, help="Path to a video file.")
p.set_defaults(func=_handle_duration)
def _add_convert(sub: argparse._SubParsersAction) -> None:
"""
Register the ``convert`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("convert", help="Re-encode / resize / drop audio in one pass.")
p.add_argument("--input", required=True, help="Input video path.")
p.add_argument("--output", required=True, help="Output video path.")
p.add_argument(
"--frame-rate", type=int, default=None, dest="frame_rate", help="Target frame rate."
)
p.add_argument("--width", type=int, default=None, help="Target width in pixels.")
p.add_argument("--height", type=int, default=None, help="Target height in pixels.")
p.add_argument(
"--without-sound",
action="store_true",
default=False,
dest="without_sound",
help="Drop the audio stream.",
)
p.set_defaults(func=_handle_convert)
def _add_chunk(sub: argparse._SubParsersAction) -> None:
"""
Register the ``chunk`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("chunk", help="Extract a [start, end] slice of a video.")
p.add_argument("--input", required=True)
p.add_argument("--start", type=float, required=True, help="Start time in seconds.")
p.add_argument("--end", type=float, required=True, help="End time in seconds.")
p.add_argument("--output", required=True, help="Output video path.")
p.set_defaults(func=_handle_chunk)
def _add_black(sub: argparse._SubParsersAction) -> None:
"""
Register the ``black`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("black", help="Synthesize a silent solid-black clip.")
p.add_argument("--duration", type=float, required=True, help="Duration in seconds.")
p.add_argument("--width", type=int, required=True)
p.add_argument("--height", type=int, required=True)
p.add_argument("--output", required=True)
p.add_argument("--frame-rate", type=int, default=30, dest="frame_rate")
p.set_defaults(func=_handle_black)
def _add_image_loop(sub: argparse._SubParsersAction) -> None:
"""
Register the ``image-loop`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("image-loop", help="Loop a still image into a silent video.")
p.add_argument("--image", required=True, help="Path to the input still (PNG / JPG / …).")
p.add_argument("--duration", type=float, required=True)
p.add_argument("--output", required=True)
p.add_argument("--frame-rate", type=int, default=30, dest="frame_rate")
p.add_argument("--width", type=int, default=None, help="Letterbox target width.")
p.add_argument("--height", type=int, default=None, help="Letterbox target height.")
p.set_defaults(func=_handle_image_loop)
def _add_concat(sub: argparse._SubParsersAction) -> None:
"""
Register the ``concat`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("concat", help="Concatenate several videos head-to-tail.")
p.add_argument("--inputs", nargs="+", required=True, help="Videos, in order.")
p.add_argument("--output", required=True)
p.add_argument(
"--reencode",
action="store_true",
default=True,
help="Re-encode via libx264 (default; recommended for mixed sources).",
)
p.add_argument(
"--no-reencode",
dest="reencode",
action="store_false",
help="Stream-copy — inputs must be bit-identical containers.",
)
p.add_argument("--frame-rate", type=int, default=None, dest="frame_rate")
p.set_defaults(func=_handle_concat)
def _add_overlay(sub: argparse._SubParsersAction) -> None:
"""
Register the ``overlay`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("overlay", help="Overlay a still image on a video.")
p.add_argument("--input", required=True)
p.add_argument("--image", required=True, help="Overlay image (PNG with alpha typical).")
p.add_argument("--output", required=True)
p.add_argument(
"--x", default="0", help='X position or ffmpeg overlay expression (default "0").'
)
p.add_argument(
"--y", default="0", help='Y position or ffmpeg overlay expression (default "0").'
)
p.add_argument(
"--scale-width",
type=int,
default=None,
dest="scale_width",
help="Scale overlay to this width.",
)
p.set_defaults(func=_handle_overlay)
def _add_extract_audio(sub: argparse._SubParsersAction) -> None:
"""
Register the ``extract-audio`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("extract-audio", help="Dump the audio track of a video.")
p.add_argument("--input", required=True)
p.add_argument(
"--output", required=True, help="Output audio path (extension picks the container)."
)
p.add_argument("--sample-rate", type=int, default=44100, dest="sample_rate")
p.add_argument("--channels", type=int, default=2, help="Channel count (default 2).")
p.add_argument("--encoding", default="pcm_s16le", help="Audio codec (default pcm_s16le).")
p.set_defaults(func=_handle_extract_audio)
def _add_mux_audio(sub: argparse._SubParsersAction) -> None:
"""
Register the ``mux-audio`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("mux-audio", help="Mux a separate audio track onto a video.")
p.add_argument("--input", required=True, help="Video file (existing audio is replaced).")
p.add_argument("--audio", required=True, help="Audio file.")
p.add_argument("--output", required=True)
p.add_argument("--audio-codec", default="aac", dest="audio_codec")
p.add_argument("--audio-bitrate", default="192k", dest="audio_bitrate")
p.add_argument(
"--shortest",
action="store_true",
default=False,
help="Stop the output when the shorter stream ends.",
)
p.set_defaults(func=_handle_mux_audio)
def _add_burn_subs(sub: argparse._SubParsersAction) -> None:
"""
Register the ``burn-subs`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser(
"burn-subs", help="Burn subtitles (.srt / .vtt / .ass) into the video frames."
)
p.add_argument("--input", required=True)
p.add_argument("--subs", required=True, help="Subtitles file.")
p.add_argument("--output", required=True)
p.add_argument(
"--force-style",
default=None,
dest="force_style",
help="ASS-style override (e.g. 'FontName=Helvetica,FontSize=24').",
)
p.set_defaults(func=_handle_burn_subs)
def _add_srt2vtt(sub: argparse._SubParsersAction) -> None:
"""
Register the ``srt2vtt`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser("srt2vtt", help="Convert an SRT to WebVTT + companion CSS.")
p.add_argument("--input", required=True, help="Input .srt path.")
p.add_argument("--output", default=None, help="Output .vtt (default: sibling of input).")
p.add_argument("--css", default=None, help="Output .css (default: sibling of input).")
p.set_defaults(func=_handle_srt2vtt)
def _add_extract_frames(sub: argparse._SubParsersAction) -> None:
"""
Register the ``extract-frames`` subcommand on the parser.
Parameters
----------
sub : argparse._SubParsersAction
The subparser collection to attach this command to.
"""
p = sub.add_parser(
"extract-frames",
help="Stream frames to disk as one PNG per sampled frame.",
)
p.add_argument("--input", required=True, help="Input video path or URL.")
p.add_argument(
"--output-dir", required=True, dest="output_dir", help="Destination folder for PNGs."
)
p.add_argument(
"--frame-step", type=int, default=1, dest="frame_step", help="Sampling stride (default 1)."
)
p.add_argument(
"--frame-interval",
type=float,
default=None,
dest="frame_interval",
help="Sampling period in seconds.",
)
p.add_argument("--start", type=float, default=None, help="Start instant in seconds.")
p.add_argument("--end", type=float, default=None, help="End instant in seconds.")
p.add_argument(
"--backend",
default="auto",
choices=["auto", "vidgear", "pyav", "ffmpeg-pipe"],
help="Frame-extraction backend (default auto).",
)
p.set_defaults(func=_handle_extract_frames)
[docs]
def build_parser() -> argparse.ArgumentParser:
"""
Assemble the top-level ``video-helper`` argument parser.
Returns
-------
argparse.ArgumentParser
Fully wired parser with every subcommand attached.
"""
parser = argparse.ArgumentParser(
prog="video-helper",
description=(
"Video Helper — utility CLI for validate / dimensions / duration / "
"convert / chunk / black / image-loop / concat / overlay / "
"extract-audio / mux-audio / burn-subs / srt2vtt / extract-frames."
),
)
# Every non-trivial CLI benefits from `--version` — cheap to add and
# oncall people always look for it.
try:
from importlib.metadata import version as _pkg_version
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {_pkg_version('video-helper')}",
)
except Exception: # pragma: no cover — never fatal
pass
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
subparsers.required = True
# Register every subcommand. Order matters for help output only.
_add_validate(subparsers)
_add_dimensions(subparsers)
_add_duration(subparsers)
_add_convert(subparsers)
_add_chunk(subparsers)
_add_black(subparsers)
_add_image_loop(subparsers)
_add_concat(subparsers)
_add_overlay(subparsers)
_add_extract_audio(subparsers)
_add_mux_audio(subparsers)
_add_burn_subs(subparsers)
_add_srt2vtt(subparsers)
_add_extract_frames(subparsers)
return parser
[docs]
def main(argv: Sequence[str] | None = None) -> int:
"""
Entry point invoked by ``video-helper`` (see ``[project.scripts]``).
Parameters
----------
argv : sequence of str, optional
Arguments to parse. Defaults to ``sys.argv[1:]`` when None.
Returns
-------
int
Process exit code (``0`` on success).
"""
parser = build_parser()
args = parser.parse_args(argv)
# Every subparser sets ``func`` via ``set_defaults`` — no dispatch table
# needed, argparse resolved it for us.
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())