Skip to content

Async API

An asynchronous mirror of the public API lives under ffmpeg_wrap.aio, powered by AnyIO. It lets you drive many ffmpeg/ffprobe jobs concurrently without blocking the event loop. The core package stays dependency-free — import ffmpeg_wrap never imports anyio — so the async path ships as an optional extra. With uv:

uv add "ffmpeg-wrap[async]"

With pip:

pip install "ffmpeg-wrap[async]"

See the auto-generated Async API Reference for full signatures.

Running a command

The fluent builder gains an arun() method that mirrors run(). Start the chain at input() exactly as in the sync API, then await the result:

import anyio

from ffmpeg_wrap import input


async def main():
    await input("input.mkv").output("output.mp4", c="copy").overwrite_output().arun()


anyio.run(main)

Mirrored module functions

The aio submodule mirrors the module-level functions. aio.probe() returns the same typed ProbeResult as probe(); aio.validate() returns the same (ok, stderr) tuple; aio.encoders()/aio.has_encoder() share the same per-path cache as their sync twins:

import anyio

from ffmpeg_wrap import aio


async def main():
    result = await aio.probe("video.mkv")
    for stream in result.streams:
        print(stream.codec_name, stream.codec_type)

    ok, stderr = await aio.validate("video.mkv")
    if not ok:
        print(f"Invalid media: {stderr}")

    codec = "h264_nvenc" if await aio.has_encoder("h264_nvenc") else "libx264"
    print(codec)


anyio.run(main)

Backend choice (asyncio or trio)

Because the async path is built on AnyIO, the same library code runs on both the asyncio and trio backends — the consumer picks the backend. anyio.run(main) uses asyncio by default; pass backend="trio" to run on trio instead:

import anyio

from ffmpeg_wrap import aio


async def main():
    await aio.probe("video.mkv")


anyio.run(main, backend="trio")

Thread behaviour under highload

On the asyncio backend, depending on platform and Python version, CPython may reap child processes with one waitpid thread per subprocess (the threaded child watcher) — so its reaping-thread count grows with the number of concurrently running children; newer CPython (3.14+) can instead use a pidfd-based watcher with no thread. On the trio backend there is no dedicated child-reaping thread at all. AnyIO's trio backend does, however, use a small bounded, reusable worker-thread pool for the blocking pipe-FD reads behind run_process/open_process. That pool scales with the number of concurrently running ffmpeg processes (threads are reused across jobs), not with the total number of jobs you queue — so bounding concurrency with a CapacityLimiter keeps the thread count flat. Trio avoids a per-process reaping thread, but it is not literally zero-thread.

Timeouts

arun(), aio.run(), aio.probe() and aio.validate() accept the same keyword-only timeout (seconds, None for no limit) as their sync twins. The deadline covers the launch itself; when it expires the child is killed and reaped, then FFmpegTimeoutError is raised with the same message, timeout, cmd, stderr (str | None) and returncode=None as the sync path:

import anyio

from ffmpeg_wrap import FFmpegTimeoutError, aio, input


async def main():
    try:
        await input("input.mkv").output("output.mp4", c="copy").overwrite_output().arun(timeout=60)
    except FFmpegTimeoutError as e:
        print(f"gave up after {e.timeout}s: {e.stderr}")

    result = await aio.probe("video.mkv", timeout=10)
    ok, stderr = await aio.validate("video.mkv", timeout=10)
    print(len(result.streams), ok)


anyio.run(main)

As in the sync API, the kill applies to the process launched from ffmpeg_path (or ffprobe_path) and never to descendants of a wrapper script. Without capture_stderr=True the deadline bounds the whole call: the pipes are drained until they close, so a background process left holding stdout or stderr turns into FFmpegTimeoutError at the deadline even though ffmpeg itself exited.

On the async paths the stderr tail is only collected where stderr is teed: arun() and aio.run() without capture_stderr=True carry it, while capture_stderr=True, aio.probe() and aio.validate() discard the child's output when the deadline cancels the call, so e.stderr is None there. The sync API keeps the tail in every case.

The keyword composes with AnyIO's own cancellation. Wrapping a call in your own anyio.fail_after / anyio.move_on_after, or cancelling the task group it runs in, kills and reaps the child the same way, but the exception you see is the one your scope produces (TimeoutError from fail_after, nothing from move_on_after), not FFmpegTimeoutError. Use timeout= when you want a structured error (with the ffmpeg stderr tail on the tee path); use your own scope when a whole batch shares one deadline. Both can be active at once, and the earlier deadline decides which exception you get.

The timeout bounds ffmpeg, not your stderr sink

The same limit as the sync API applies. Without capture_stderr=True, stderr is forwarded to sys.stderr with a blocking write on the event-loop thread, and neither timeout= nor a cancel scope can interrupt a write that is stuck on a full or stopped sink. Pass capture_stderr=True when the sink may block.

Bounding concurrency

The library does not build a job queue — bound concurrency yourself with an anyio.CapacityLimiter so only N ffmpeg processes run at once, while you queue as many jobs as you like:

import anyio

from ffmpeg_wrap import input


async def transcode(name, limiter):
    async with limiter:
        await input(f"{name}.mkv").output(f"{name}.mp4", c="copy").overwrite_output().arun()


async def main():
    limiter = anyio.CapacityLimiter(4)  # at most 4 concurrent ffmpeg processes
    names = [f"clip{i}" for i in range(100)]
    async with anyio.create_task_group() as tg:
        for name in names:
            tg.start_soon(transcode, name, limiter)


anyio.run(main)