Back to catalog

PIP Combine

video / v1.0.0

Overlay one video onto another to create a picture-in-picture (PIP) output.

VideoEditingOverlayffmpeg

A CLI tool that uses ffmpeg to overlay an input video onto a background video with scaling, positioning, audio mixing, and frame rate normalization. Supports corner placement, interpolation, and configurable output quality.

Requirements

  • ffmpeg
  • ffprobe

Platforms

LinuxmacOSWindows

Usage

./scripts/pip-combine.py --overlay overlay.mp4 --background background.mp4
./scripts/pip-combine.py -i overlay.mp4 -b background.mp4 -o output.mp4
./scripts/pip-combine.py --overlay cam.mp4 --background screen.mp4 --corner br --scale-rel 0.33

Download

Install quickly or copy a command for your shell.

toolbox pip-combine
curl -fsSL "https://raw.githubusercontent.com/PiSaucer/toolbox/main/scripts/pip-combine.py" -o "pip-combine.py"
wget -O "pip-combine.py" "https://raw.githubusercontent.com/PiSaucer/toolbox/main/scripts/pip-combine.py"
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/PiSaucer/toolbox/main/scripts/pip-combine.py" -OutFile "pip-combine.py"
python3 -c "import urllib.request; urllib.request.urlretrieve('https://raw.githubusercontent.com/PiSaucer/toolbox/main/scripts/pip-combine.py', 'pip-combine.py')"

Integrity

SHA256

2198689801e69cbe07ff81655874df56641f821a59b43ffbc342738c6b302a40

Copy and Paste Script

Use this when you want to copy the full script directly.

#!/usr/bin/env python3
# pip-combine.py
# Copyright (c) 2026 PiSaucer
# Licensed under the MIT License
# Version 1.0.0

# Overlay an OVERLAY video on a BACKGROUND video with audio mix and smooth frame pacing
# Usage: python3 pip-combine.py --overlay Overlay.mp4 --background Background.mp4 [options]

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path

CORNER_CHOICES = {
    "tl": ("0", "0"),
    "tr": ("(main_w-overlay_w)", "0"),
    "bl": ("0", "(main_h-overlay_h)"),
    "br": ("(main_w-overlay_w)", "(main_h-overlay_h)"),
}

def ensure_tool(name):
    """Require an executable to be available on ``PATH``.

    Args:
        name: Executable name to locate.

    Raises:
        SystemExit: If the executable cannot be found.
    """
    if not shutil.which(name):
        sys.exit(f"Error: {name} not found in PATH. Install {name} and try again.")

def ffprobe_json(args):
    """Run ffprobe and decode its JSON output.

    Args:
        args: Complete ffprobe command and argument sequence.

    Returns:
        The decoded JSON value.

    Raises:
        SystemExit: If ffprobe is not available.
        subprocess.CalledProcessError: If ffprobe exits unsuccessfully.
        json.JSONDecodeError: If ffprobe output is not valid JSON.
    """
    ensure_tool("ffprobe")
    out = subprocess.check_output(args, stderr=subprocess.STDOUT)
    return json.loads(out.decode("utf-8"))

def ffprobe_has_stream(path: Path, kind: str) -> bool:
    """Check whether a media file contains an audio or video stream.

    Args:
        path: Media file to inspect.
        kind: ``audio`` to select audio; every other value selects video.

    Returns:
        ``True`` when ffprobe reports at least one selected stream, otherwise
        ``False``.

    Raises:
        SystemExit: If ffprobe is not available.
    """
    # ffprobe uses single-letter stream selectors.
    sel = "a" if kind == "audio" else "v"
    try:
        data = ffprobe_json([
            "ffprobe", "-v", "error", "-select_streams", sel,
            "-show_entries", "stream=index", "-of", "json", str(path)
        ])
        return bool(data.get("streams"))
    except subprocess.CalledProcessError:
        return False

def fraction_to_float(s: str) -> float:
    """Convert a number or fractional string to a float.

    Args:
        s: Numeric text such as ``29.97`` or ``30000/1001``.

    Returns:
        The parsed value, or ``0.0`` for invalid input or a zero denominator.
    """
    try:
        if "/" in s:
            n, d = s.split("/", 1)
            n = float(n)
            d = float(d)
            return 0.0 if d == 0 else n / d
        return float(s)
    except Exception:
        return 0.0

def ffprobe_fps(path: Path) -> float:
    """Read the best available video frame rate from a media file.

    Args:
        path: Media file to inspect.

    Returns:
        A positive frame rate, or ``0.0`` when probing or parsing fails.

    Raises:
        SystemExit: If ffprobe is not available.
    """
    try:
        data = ffprobe_json([
            "ffprobe", "-v", "error", "-select_streams", "v:0",
            "-show_entries", "stream=avg_frame_rate,r_frame_rate",
            "-of", "json", str(path)
        ])
        s = (data.get("streams") or [{}])[0]
        
        # Prefer the average rate; fall back to the declared real/base rate.
        for key in ("avg_frame_rate", "r_frame_rate"):
            if key in s and s[key]:
                fps = fraction_to_float(s[key])
                if fps > 0:
                    return fps
        return 0.0
    except subprocess.CalledProcessError:
        return 0.0

def prompt_if_missing(args):
    """Interactively fill media and layout options missing from parsed arguments.

    Args:
        args: Argparse namespace to update in place.

    Returns:
        The updated namespace.

    Raises:
        EOFError: If interactive input ends before a required answer is given.
        ValueError: If the entered relative scale is not numeric.
    """
    def ask_file(prompt_text, default=None):
        """Prompt until the user supplies a file path.

        Args:
            prompt_text: Label shown before the input field.
            default: Value used when the user submits an empty response.

        Returns:
            A nonempty path string.

        Raises:
            EOFError: If standard input closes while prompting.
        """
        while True:
            p = input(f"{prompt_text}{' [' + default + ']' if default else ''}: ").strip() or (default or "")
            if p:
                return p

    if not args.overlay:
        args.overlay = ask_file("Path to OVERLAY picture-in-picture video", "Overlay.mp4")

    if not args.background:
        args.background = ask_file("Path to BACKGROUND main video", "Background.mp4")

    if not args.corner:
        print("Corner for overlay video: tl=top-left, tr=top-right, bl=bottom-left, br=bottom-right.")
        c = input("Choose corner [tr]: ").strip().lower() or "tr"
        if c not in CORNER_CHOICES:
            print("Unrecognized corner; defaulting to tr.")
            c = "tr"
        args.corner = c

    if not args.overlay_scale and args.scale_rel is None:
        s = input("Shrink OVERLAY video by what factor? [0.5]: ").strip()
        args.scale_rel = float(s) if s else 0.5

    return args

def build_video_chain(args, fps_overlay, fps_background):
    """Build the ffmpeg picture-in-picture video filtergraph.

    Args:
        args: Parsed layout, sizing, interpolation, and frame-rate options.
        fps_overlay: Detected overlay input frame rate.
        fps_background: Detected background input frame rate.

    Returns:
        A tuple containing the filtergraph fragment and final video pad label.

    Raises:
        ValueError: If frame-rate or background-size values are invalid.
        KeyError: If the selected corner is not in ``CORNER_CHOICES``.
    """
    target_fps = float(args.target_fps) if args.target_fps else 30.0
    interp = args.interp

    # Build background chain
    bg_steps = []
    bg_in = "[1:v]"

    # Raise only low-frame-rate inputs; the final fps filter normalizes both
    # streams after composition.
    if interp != "off" and fps_background > 0 and fps_background < target_fps:
        if interp == "minterpolate":
            bg_steps.append(f"{bg_in}minterpolate=fps={target_fps}[bg_i]")
        else:
            bg_steps.append(f"{bg_in}fps={target_fps}[bg_i]")
        bg_in = "[bg_i]"

    # Fill the canvas while preserving aspect ratio, then crop any overflow.
    w_bg, h_bg = map(int, args.bg_size.lower().split("x"))
    bg_steps.append(
        f"{bg_in}scale={w_bg}:{h_bg}:force_original_aspect_ratio=increase,"
        f"crop={w_bg}:{h_bg}[bg]"
    )

    # Build overlay chain
    overlay_steps = []
    overlay_in = "[0:v]"

    if interp != "off" and fps_overlay > 0 and fps_overlay < target_fps:
        if interp == "minterpolate":
            overlay_steps.append(f"{overlay_in}minterpolate=fps={target_fps}[overlay_i]")
        else:
            overlay_steps.append(f"{overlay_in}fps={target_fps}[overlay_i]")
        overlay_in = "[overlay_i]"

    # Scale overlay
    if args.overlay_scale:
        scale_value = args.overlay_scale if "x" in args.overlay_scale else f"{args.overlay_scale}:-1"
        overlay_steps.append(f"{overlay_in}scale={scale_value}:flags=lanczos[pip0]")
    else:
        rel = args.scale_rel if args.scale_rel is not None else args.overlay_rel
        overlay_steps.append(f"{overlay_in}scale=iw*{rel}:ih*{rel}:flags=lanczos[pip0]")

    # Corner with optional margin
    x_expr, y_expr = CORNER_CHOICES[args.corner]
    if args.margin and args.margin != 0:
        if x_expr == "0":
            x_expr = str(args.margin)
        elif x_expr == "(main_w-overlay_w)":
            x_expr = f"(main_w-overlay_w-{args.margin})"

        if y_expr == "0":
            y_expr = str(args.margin)
        elif y_expr == "(main_h-overlay_h)":
            y_expr = f"(main_h-overlay_h-{args.margin})"

    # Overlay and final fps normalize
    overlay_filter = f"[bg][pip0]overlay=x={x_expr}:y={y_expr}[vtmp]"
    fps_normalize = f"[vtmp]fps={target_fps}[vout]"

    return ";".join(bg_steps + overlay_steps + [overlay_filter, fps_normalize]), "[vout]"

def build_audio_chain(overlay_has_audio, background_has_audio, added_silence, mix_duration):
    """Build the ffmpeg audio-selection and mixing filtergraph.

    Args:
        overlay_has_audio: Whether input zero has an audio stream.
        background_has_audio: Whether input one has an audio stream.
        added_silence: Whether input two is an injected silent audio source.
        mix_duration: ffmpeg ``amix`` duration policy.

    Returns:
        A filtergraph fragment producing the ``[aout]`` audio pad.
    """
    audio_labels = []

    if overlay_has_audio:
        audio_labels.append("[0:a]")

    if background_has_audio:
        audio_labels.append("[1:a]")

    if added_silence:
        audio_labels.append("[2:a]")

    # Input two is the synthetic silence source when neither video has audio.
    if len(audio_labels) == 0:
        return "[2:a]anull[aout]"

    # A single stream still gets asynchronous resampling for timestamp drift.
    if len(audio_labels) == 1:
        return f"{audio_labels[0]}aresample=async=1[aout]"

    return (
        f"{''.join(audio_labels)}"
        f"amix=inputs={len(audio_labels)}:duration={mix_duration}:dropout_transition=2,"
        f"aresample=async=1[aout]"
    )

def main():
    """Parse options and run the picture-in-picture ffmpeg command.

    Raises:
        SystemExit: If dependencies or inputs are missing, or ffmpeg exits
            unsuccessfully.
        EOFError: If interactive input closes while required values are read.
        ValueError: If an interactive or sizing value is invalid.
    """
    parser = argparse.ArgumentParser(description="Overlay an OVERLAY video on a BACKGROUND video with audio mix and smooth frame pacing.")
    parser.add_argument("--overlay", "-i", help="Path to overlay picture-in-picture video.")
    parser.add_argument("--background", "-b", help="Path to background main video.")
    parser.add_argument("--corner", "-c", choices=CORNER_CHOICES.keys(), default="tr", help="Corner: tl, tr, bl, br. Default: tr.")
    parser.add_argument("--overlay-scale", help="Fixed size for overlay, e.g. 640x480 or 640 for width only.")
    parser.add_argument("--scale-rel", type=float, default=None, help="Shrink factor relative to overlay's original size, e.g. 0.33.")
    parser.add_argument("--overlay-rel", type=float, default=0.5, help="Fallback relative scale if no --overlay-scale or --scale-rel.")
    parser.add_argument("--margin", type=int, default=0, help="Margin in pixels from edges. Default: 0.")
    parser.add_argument("--bg-size", default="1920x1080", help='Canvas size, e.g. "1920x1080".')
    parser.add_argument("--mix-duration", default="longest", choices=["first", "shortest", "longest"], help="amix duration behavior.")
    parser.add_argument("--interp", choices=["minterpolate", "dup", "off"], default="dup", help="How to raise low-FPS inputs to target fps. Default: dup.")
    parser.add_argument("--target-fps", type=float, default=30.0, help="Output/normalized FPS. Default: 30.")
    parser.add_argument("--crf", type=int, default=20, help="x264 CRF. Default: 20.")
    parser.add_argument("--preset", default="veryfast", help="x264 preset. Default: veryfast.")
    parser.add_argument("--output", "-o", default="output_overlay.mp4", help="Output filename.")
    parser.add_argument("--shortest", action="store_true",  help="End when the shortest input finishes.")
    parser.add_argument("--silence-rate", default="48000", help="Sample rate for injected silence if needed.")

    args = parser.parse_args()
    args = prompt_if_missing(args)

    if not Path(args.output).suffix:
        args.output += ".mp4"

    overlay_path = Path(args.overlay).expanduser()
    background_path = Path(args.background).expanduser()

    if not overlay_path.exists():
        sys.exit(f"Error: Overlay video not found: {overlay_path}")

    if not background_path.exists():
        sys.exit(f"Error: Background video not found: {background_path}")

    ensure_tool("ffmpeg")

    overlay_has_audio = ffprobe_has_stream(overlay_path, "audio")
    background_has_audio = ffprobe_has_stream(background_path, "audio")

    fps_overlay = ffprobe_fps(overlay_path)
    fps_background = ffprobe_fps(background_path)

    extra_inputs = []
    added_silence = False

    # Always map an audio output. Inject silence when neither source supplies it.
    if not overlay_has_audio and not background_has_audio:
        extra_inputs = [
            "-f", "lavfi",
            "-i", f"anullsrc=channel_layout=stereo:sample_rate={args.silence_rate}"
        ]
        added_silence = True

    v_chain, vout = build_video_chain(args, fps_overlay, fps_background)
    a_chain = build_audio_chain(
        overlay_has_audio,
        background_has_audio,
        added_silence,
        args.mix_duration
    )

    # Video and audio chains share one filter_complex graph and expose named pads.
    filtergraph = ";".join([v_chain, a_chain])

    cmd = [
        "ffmpeg", "-hide_banner", "-y",
        "-i", str(overlay_path),
        "-i", str(background_path),
        *extra_inputs,
        "-filter_complex", filtergraph,
        "-map", vout,
        "-map", "[aout]",
        "-c:v", "libx264",
        "-preset", args.preset,
        "-crf", str(args.crf),
        "-pix_fmt", "yuv420p",
        "-c:a", "aac",
        "-b:a", "192k",
    ]

    # An infinite synthetic silence input must be bounded by the video duration.
    if args.shortest or added_silence:
        cmd.append("-shortest")

    cmd.append(str(args.output))

    print("\nRunning:\n", " ".join(cmd), "\n")

    try:
        subprocess.check_call(cmd)
        print(f"Done! Wrote: {args.output}")
    except subprocess.CalledProcessError as e:
        sys.exit(f"ffmpeg failed with exit code {e.returncode}")

if __name__ == "__main__":
    main()