unslothai/unsloth · error · RuntimeError

GIF export needs the 'av' and 'Pillow' packages.

Error message

GIF export needs the 'av' and 'Pillow' packages.

What it means

_transcode_gif lazily imports both PyAV ('av') and Pillow ('PIL.Image') and raises this RuntimeError when either import fails, since GIF export decodes with PyAV and re-encodes paletted frames with Pillow. MP4 export needs neither, and WebM needs only av, so this is GIF-specific.

Source

Thrown at studio/backend/core/inference/video_gallery.py:218

        raise
    except Exception as exc:  # noqa: BLE001 -- surface as "encoder unavailable"
        raise RuntimeError(f"WebM export failed (libvpx-vp9 unavailable?): {exc}") from exc


# Ceilings for a GIF export, which must hold every kept frame in memory before encoding. 720 px and 300 frames (25s at the
# 12 fps target) bound that at roughly 150 MB for the widest clip a generate request allows.
_GIF_MAX_EDGE = 720
_GIF_MAX_FRAMES = 300


def _transcode_gif(path: Path) -> bytes:
    import io

    try:
        import av
        from PIL import Image
    except Exception as exc:  # noqa: BLE001 -- missing deps -> no transcode
        raise RuntimeError("GIF export needs the 'av' and 'Pillow' packages.") from exc
    frames: list[Any] = []
    try:
        with av.open(str(path)) as src:
            if not src.streams.video:
                raise RuntimeError("GIF export failed: the clip has no video stream.")
            in_v = src.streams.video[0]
            rate = float(in_v.average_rate or 24)
            # Full-rate GIFs are huge and stutter; ~12 fps (skipping source frames) is the sweet spot.
            step = max(1, round(rate / 12))
            # Every kept frame is held as a paletted image until the encoder runs, so an unbounded walk is a memory bomb (a 2048x2048 clip of 1024
            # frames is >4 GB). Bound both axes: downscale past _GIF_MAX_EDGE and widen the step to at most _GIF_MAX_FRAMES. MP4 keeps the full clip.
            total = in_v.frames or 0
            kept = (total + step - 1) // step if total else 0
            if kept > _GIF_MAX_FRAMES:
                step = -(-total // _GIF_MAX_FRAMES)
            for i, frame in enumerate(src.decode(in_v)):
                if i % step:
                    continue

View on GitHub (pinned to 203007d190)

Solutions

  1. pip install av Pillow.
  2. Reinstall whichever import fails (test with python -c "import av; from PIL import Image").
  3. Export MP4 instead if installing is not an option.

Example fix

# before
pip install av
# GIF export -> RuntimeError('needs av and Pillow')

# after
pip install av Pillow
Defensive patterns

Strategy: fallback

Validate before calling

def gif_deps_available() -> bool:
    try:
        import av
        from PIL import Image  # noqa: F401,F403
        return True
    except Exception:
        return False

Try / catch

try:
    data = export_clip(clip, fmt="gif")
except RuntimeError as e:
    if str(e).startswith("GIF export needs"):
        data = export_clip(clip, fmt="mp4")
    else:
        raise

Prevention

When it happens

Trigger: Requesting GIF export where Pillow or av (or both) is missing from the environment; virtualenvs created without the extras after a fresh clone.

Common situations: Headless servers without Pillow; partial dependency installs where av was added for WebM but Pillow never was; Python upgrades breaking one of the two packages.

Related errors


AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15). Data as JSON: /api/errors/b5cf03d12ddb3441. Report an issue: GitHub.