DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars With ffmpeg

The Silent Failure Nobody Documents

I was helping a friend set a video avatar on Telegram. She recorded a 5-second clip on her iPhone, sent it to the bot, and nothing happened. No error. Just silence.

After about an hour of debugging I found the problem: her iPhone records in HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline rejects H.265 without any user-facing message. The file passes the upload step, appears to process, then gets quietly dropped.

That's what pushed me to build @liveavabot.

What Telegram Video Avatars Actually Require

Telegram's documentation on this is sparse. I pieced together the spec from their API docs, bot API behavior, and a lot of trial and error:

  • Codec: H.264 (AVC), not H.265/HEVC
  • Pixel format: yuv420p (not yuv420p10le, not yuvj420p)
  • Resolution: exactly 800x800
  • Duration: 10 seconds maximum
  • File size: 2MB maximum
  • Audio: none (audio tracks cause a "Bad Request: VIDEO_NOTE_AUDIO" error)

The resolution requirement is the tricky part. Most videos aren't square. You can't just scale to 800x800 without distorting the content. You need to crop to a square first, then scale up.

The ffmpeg Pipeline

The approach is two steps: detect the optimal square crop region, then encode to spec.

ffmpeg has a cropdetect filter that finds the largest non-black region in a video. I use it in a no-output probe pass to compute the crop rectangle, then apply it in the encode pass:

# Step 1: detect the crop rectangle (scans first 10 seconds)
ffmpeg -i input.mov -vf "cropdetect=24:2:0" -t 10 -f null - 2>&1 | grep "crop="
# Sample output: ... crop=1080:1080:0:420 ...

# Step 2: encode to Telegram spec (portrait 1080x1920 example)
ffmpeg -i input.mov \
  -vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p" \
  -c:v libx264 -preset fast -crf 28 \
  -an -t 10 -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Key flags in the encode pass:

  • crop=1080:1080:0:420 cuts to a 1080x1080 square starting at x=0, y=420
  • scale=800:800:flags=lanczos upscales to 800x800 with Lanczos resampling
  • format=yuv420p forces the exact pixel format Telegram expects (not 10-bit, not JPEG chroma)
  • -an strips the audio track
  • -movflags +faststart moves the MP4 moov atom to the front, required for streaming

In my bot I compute the crop in Python using ffprobe rather than parsing cropdetect output. For a portrait 1080x1920 video: side = min(1080, 1920) = 1080, x = 0, y = (1920 - 1080) // 2 = 420. Cropdetect is useful for videos with letterboxing where you want to remove black bars too.

The aiogram 3 Handler

The bot is built on aiogram 3.x. Here's the core handler, simplified:

import asyncio, os, tempfile
from aiogram import Bot, Router, F
from aiogram.types import Message, BufferedInputFile
import ffmpeg  # pip install ffmpeg-python

router = Router()

def build_avatar(src: str, dst: str) -> None:
    probe = ffmpeg.probe(src)
    vs = next(s for s in probe["streams"] if s["codec_type"] == "video")
    w, h = int(vs["width"]), int(vs["height"])
    side = min(w, h)
    x = (w - side) // 2
    y = (h - side) // 2
    (
        ffmpeg
        .input(src, ss=0, t=10)
        .video
        .filter("crop", side, side, x, y)
        .filter("scale", 800, 800, flags="lanczos")
        .filter("format", "yuv420p")
        .output(
            dst,
            vcodec="libx264",
            preset="fast",
            crf=28,
            an=None,
            movflags="+faststart",
        )
        .overwrite_output()
        .run(quiet=True)
    )

@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message, bot: Bot) -> None:
    file_id = (msg.video or msg.animation or msg.document).file_id
    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "in.mp4")
        dst = os.path.join(tmp, "out.mp4")
        await bot.download(file_id, destination=src)
        await asyncio.to_thread(build_avatar, src, dst)
        size = os.path.getsize(dst)
        if size > 2 * 1024 * 1024:
            await msg.reply("Still over 2MB after encoding. Try a shorter clip.")
            return
        data = open(dst, "rb").read()
        await msg.reply_video_note(
            BufferedInputFile(data, filename="avatar.mp4")
        )
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • asyncio.to_thread keeps ffmpeg off the event loop so the bot stays responsive under concurrent requests.
  • The handler matches F.video | F.animation | F.document because iPhone .mov files forwarded as documents arrive with type Document, not Video. Checking only F.video silently drops a common case.
  • reply_video_note sends the result as a round video, which Telegram accepts as a video avatar source.
  • The 2MB check after encoding catches edge cases. CRF 28 handles clips under 10 seconds well, but a fast-moving scene at high resolution can still push past the limit.

Packaging It as @liveavabot

The handler above is the core. The deployed bot adds a few things:

Duration check before encoding. I probe the source and warn if it's over 30 seconds. Encoding a 5-minute video to discover it still exceeds 2MB wastes server CPU and user patience.

Per-user rate limiting. The bot runs on a single VPS with 2 vCPUs. ffmpeg is CPU-bound. One user queuing 10 videos in parallel blocks everyone else.

Stars-based paid tier for users who convert more than 3 videos per day. The aiogram invoice flow handles this cleanly, no external payment library needed.

The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260916. Currently at 403 registered users.

Edge Cases I Didn't Expect

yuv420p10le rejections. Some screen recordings and GoPro footage comes in at 10-bit color depth. ffmpeg's format=yuv420p filter handles the downconversion, but you have to explicitly add it to the filter chain. I forgot it once and Telegram returned a cryptic "wrong dimensions" error that took 20 minutes to diagnose.

4K source videos. CRF 28 with the fast preset doesn't reliably keep 4K footage under 2MB for 10 seconds. A two-pass encode with a target bitrate would fix it but adds latency. For now I document the limitation. ffmpeg is doing the heavy lifting; I just wrote the wrapper.

GIF loops. Animated GIFs convert cleanly but they loop. A 2-second GIF becomes a 2-second looping video avatar. Users expect this, so it's not a bug.

HEVC audio rejection. iPhone HEVC videos almost always have an AAC audio track. Without -an, the bot API returns "Bad Request: VIDEO_NOTE_AUDIO" with no further context. That error message would be a lot more useful if Telegram documented it anywhere.


Built by me. Bot: https://t.me/LiveAvaBot?start=devto_article_20260916. Drop a comment if your video is still failing after this pipeline.

Top comments (0)