Python Image and Video Libraries

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Baseline date: 2026-10-07. Versions are the latest on PyPI on that date: Pillow 12.3.0, pillow-heif 1.8.0, pillow-avif-plugin 1.6.0, pyvips 3.2.0, MoviePy 2.2.1, blurhash-python 1.2.2. Check the release notes of the exact versions you install, since format support depends on how the wheels were built.

Image libraries

Library Licence Notes

Pillow

MIT-CMU (HPND style)

The standard imaging library. JPEG, PNG, WebP, GIF, TIFF. AVIF read and write is built in from Pillow 11.3 when the wheel includes libavif, so check features.check("avif"). Pure Python API over C code.

pillow-heif

BSD-3-Clause

Adds HEIF and HEIC (and AVIF) reading and writing to Pillow, using libheif. Needed to read iPhone uploads.

pillow-avif-plugin

MIT

AVIF support for older Pillow versions. Treat it as a stopgap: with a current Pillow, use the built-in AVIF support.

pyvips

MIT

Binding to libvips. Lower memory and higher throughput than Pillow on big images, because it streams. Needs libvips installed, or the binary wheel.

Pillow: thumbnails, formats and EXIF

pip install Pillow pillow-heif
from PIL import Image, ImageOps, features
from pillow_heif import register_heif_opener

register_heif_opener()                      # lets Image.open read HEIC and HEIF

def make_variants(src: str, out_dir: str, widths=(320, 640, 1280)) -> list[str]:
    written = []
    with Image.open(src) as im:
        im = ImageOps.exif_transpose(im)    # apply the orientation, then drop the tag
        im = im.convert("RGB")              # sRGB, no alpha, no palette
        for width in widths:
            if width >= im.width:
                width = im.width            # never upscale
            height = round(im.height * width / im.width)
            resized = im.resize((width, height), Image.Resampling.LANCZOS)
            resized.save(f"{out_dir}/w{width}.jpg", "JPEG", quality=80, optimize=True, progressive=True)
            resized.save(f"{out_dir}/w{width}.webp", "WEBP", quality=78, method=5)
            if features.check("avif"):
                resized.save(f"{out_dir}/w{width}.avif", "AVIF", quality=60, speed=6)
            written += [f"w{width}.jpg", f"w{width}.webp"]
    return written

Image.thumbnailw, h resizes in place keeping the aspect ratio, and ImageOps.fit crops to an exact aspect ratio. Image.open is lazy, and Pillow protects against decompression bombs with Image.MAX_IMAGE_PIXELS: leave the limit on and catch DecompressionBombError. Saving a Pillow image without passing exif= drops the EXIF block, which is how metadata gets stripped. For a colour profile, convert with ImageCms to sRGB before saving.

pyvips

import pyvips

def avif_variant(src: str, dst: str, width: int) -> None:
    # thumbnail() shrinks on load, so it is much cheaper than open-then-resize.
    image = pyvips.Image.thumbnail(src, width, height=10_000_000, size="down")   # width bound only
    image.write_to_file(dst, Q=60, effort=5, strip=True)

Reference: pyvips documentation.

BlurHash

pip install blurhash-python
import blurhash
from PIL import Image

with Image.open("original.jpg") as im:
    im = im.convert("RGB")
    im.thumbnail((64, 64))                   # downscale first
    hash_ = blurhash.encode(im, x_components=4, y_components=3)

The blurhash-python package from the woltapp repository accepts a Pillow image or a file object. The blurhash package on PyPI is the other implementation, and has a different call signature (it takes a list of pixel rows), so check the README of the one you install. References: woltapp/blurhash-python and halcy/blurhash-python. The algorithm is described in the BlurHash page.

Video libraries

Library Status Notes

ffmpeg-python

Unmaintained since 2019 (0.2.0)

A builder for FFmpeg command lines. Still works for simple graphs, but it is not updated for newer FFmpeg options. Avoid it for new code.

MoviePy

Maintained (2.2.1)

Python editing library on top of FFmpeg. Suited to editing, overlays and clips, not to bulk transcoding, since it decodes frames in Python.

subprocess and FFmpeg

n/a

A direct call to the ffmpeg and ffprobe executables. The most predictable option and the one to prefer for transcoding.

subprocess

import json
import subprocess

def probe(path: str) -> dict:
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,width,height",
         "-of", "json", path],
        check=True, capture_output=True, text=True, timeout=30,
    )
    return json.loads(out.stdout)

def transcode(src: str, dst: str, height: int = 720) -> None:
    subprocess.run(
        ["ffmpeg", "-y", "-i", src, "-vf", f"scale=-2:{height}",
         "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-g", "48", "-keyint_min", "48",
         "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", dst],
        check=True, capture_output=True, timeout=3600,
    )

def cover(src: str, dst: str, seconds: float) -> None:
    subprocess.run(
        ["ffmpeg", "-y", "-ss", str(seconds), "-i", src, "-frames:v", "1", "-q:v", "2", dst],
        check=True, capture_output=True, timeout=120,
    )

Pass the arguments as a list, never as a shell string, so file names from users cannot inject options or commands, and always set a timeout. Reference: Python subprocess and the commands in video encoding fundamentals.

MoviePy v2

MoviePy 2 renamed much of its API: moviepy.editor is gone (import from moviepy), resize became resized, subclip became subclipped, and the set_* methods became with_*.

from moviepy import VideoFileClip

with VideoFileClip("input.mov") as clip:
    small = clip.resized(height=360).subclipped(0, 10)       # first 10 seconds at 360p
    small.write_videofile("preview.mp4", codec="libx264", audio_codec="aac", fps=24)
    clip.save_frame("cover.jpg", t=clip.duration * 0.1)      # frame at 10 percent

Use it for previews and montage, and use FFmpeg directly for the renditions. Reference: MoviePy documentation.

A FastAPI upload endpoint

The framework side is covered in forms and file uploads. For media, the endpoint should not receive the file at all when a pre-signed URL can be used, see upload pipelines. For small images, accepting the upload directly is fine:

from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
import uuid

app = FastAPI()
MAX_BYTES = 10 * 1024 * 1024

@app.post("/images", status_code=202)
async def upload_image(background: BackgroundTasks, file: UploadFile = File(...)):
    data = await file.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise HTTPException(status_code=413, detail="File too large")
    media_id = str(uuid.uuid4())
    save_original(media_id, data)                   # object storage, private
    background.add_task(process_image, media_id)    # small, fast work only
    return {"id": media_id, "status": "processing"}

@app.get("/images/{media_id}")
def image_status(media_id: str):
    return load_status(media_id)

BackgroundTasks runs the function in the same process after the response is sent. That is fine for resizing a small image. It is not fine for video, for three reasons: a transcode can take minutes and would hold a worker slot, the work is lost if the process restarts or the pod is rescheduled, and there are no retries, backoff, dead-letter queue or visibility into progress. For video (and for any heavy image work), publish a job to a real queue (Celery, RQ, Dramatiq, or a cloud queue) and process it in a separate worker deployment. See settings, lifespan and background tasks for what BackgroundTasks does and does not do, and the queue patterns in upload pipelines.