DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars With FFmpeg

The Silent Failure

You record a short clip on your iPhone. You open Telegram Desktop, go to settings, click "Set video". The upload starts, spins for a second, and nothing happens. No error. No toast. The old picture avatar stays exactly where it was.

I hit this maybe twenty times before I sat down with tcpdump and figured out what was happening. Telegram's client silently drops the upload if the file does not match a very specific set of constraints. HEVC (the default codec on modern iPhones) is one of them. No warning, no fallback, just a dead upload.

What Telegram Actually Wants

After digging through the Bot API docs, MTProto source, and a few third-party clients, the requirements for a video avatar are:

  • Container: MP4 with faststart (moov atom at the front).
  • Video codec: H.264 baseline or main profile, yuv420p pixel format.
  • Resolution: exactly 800x800, square.
  • Duration: up to 10 seconds, ideally under 9.
  • File size: under 2 MB.
  • Audio: must be removed entirely. Not muted, removed.
  • Frame rate: 30 fps caps out cleanly.

Any of those wrong and the client refuses the upload without telling you why. HEVC is by far the most common cause because Apple made it the default in iOS 11 and never looked back.

The FFmpeg Recipe

The tricky part is not the codec conversion, that is one flag. The tricky part is going from a landscape or portrait clip to a centered 800x800 square without ugly black bars. I use cropdetect on the first pass to find the actual content bounds, then crop and scale on the second.

# Pass 1: find the crop box
ffmpeg -ss 1 -t 3 -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 \
  | grep -oP 'crop=[^ ]+' | tail -1
# outputs something like: crop=1080:1080:420:0

# Pass 2: crop, scale, re-encode, strip audio
ffmpeg -i input.mov \
  -vf "crop=1080:1080:420:0,scale=800:800:flags=lanczos,format=yuv420p" \
  -c:v libx264 -profile:v main -preset veryfast -crf 26 \
  -pix_fmt yuv420p \
  -t 9.5 -r 30 \
  -movflags +faststart \
  -an \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Key flags worth calling out:

  • crop uses the values from cropdetect. I default to a centered square crop when detection fails.
  • format=yuv420p together with -pix_fmt yuv420p kills the "unsupported pixel format" issue you get with HEVC 10-bit source.
  • -an drops audio. Telegram rejects the upload if any audio track exists.
  • -movflags +faststart moves the moov atom so the client can start validating before the whole file arrives.
  • -crf 26 is a good starting quality. If the output pushes over 2 MB, bump to 28 and re-encode.

That single ffmpeg invocation handles about 95 percent of source files I have thrown at it. The remaining 5 percent are edge cases (extreme aspect ratios, corrupt containers, HDR metadata) that need per-case handling.

The Aiogram Handler

I wanted the whole thing behind a Telegram bot so you never have to touch ffmpeg yourself. Aiogram 3 with async subprocess kept this compact:

import asyncio
import tempfile
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, FSInputFile

router = Router()

@router.message(F.video | F.animation | F.document)
async def handle_media(msg: Message):
    file = msg.video or msg.animation or msg.document
    if file.file_size > 20 * 1024 * 1024:
        await msg.answer("File is over 20 MB, too big to fetch.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "in.bin"
        dst = Path(tmp) / "out.mp4"
        await msg.bot.download(file, destination=src)

        rc = await run_ffmpeg(src, dst)
        if rc != 0 or not dst.exists():
            await msg.answer("Conversion failed. Try a shorter clip.")
            return

        if dst.stat().st_size > 2 * 1024 * 1024:
            await msg.answer("Result is over 2 MB. Try a shorter clip.")
            return

        await msg.answer_video(
            FSInputFile(dst),
            caption="Set this as your video avatar in Telegram settings.",
        )

async def run_ffmpeg(src: Path, dst: Path) -> int:
    cmd = [
        "ffmpeg", "-y", "-i", str(src),
        "-vf", "crop=in_h:in_h,scale=800:800:flags=lanczos,format=yuv420p",
        "-c:v", "libx264", "-profile:v", "main", "-preset", "veryfast",
        "-crf", "26", "-pix_fmt", "yuv420p",
        "-t", "9.5", "-r", "30",
        "-movflags", "+faststart", "-an",
        str(dst),
    ]
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    return await proc.wait()
Enter fullscreen mode Exit fullscreen mode

Two things I got wrong on my first pass:

  1. I forgot the 20 MB Bot API download cap. If you want bigger files you need a local Bot API server. For a "convert my selfie clip" use case, 20 MB is fine.
  2. I originally sent the result as a document. Telegram treats documents differently in the video avatar picker. Sending as answer_video puts it in the video gallery where the avatar picker actually looks.

Packaged as a Bot

I bundled this into a bot called @liveavabot. Send it any video or GIF, get back a valid Telegram video avatar. It runs the same ffmpeg recipe above, plus a few extras: cropdetect fallback for weird aspect ratios, auto-CRF adjustment if the file lands over 2 MB, and animated GIF support (which needs -fflags +genpts to fix timestamps).

If you want to try it: https://t.me/LiveAvaBot?start=devto_article_20260726

Lessons and Edge Cases

Stuff I learned along the way that is not in any doc:

  • HEIC still images uploaded as "video" by some Android clients arrive as MP4 with a single frame. Detect and reject early with a duration check.
  • iPhone slow-motion clips have variable frame rates that some ffmpeg builds mishandle. Force -vsync cfr -r 30 to normalize.
  • HDR HEVC from newer iPhones has BT.2020 color primaries. Add -vf "zscale=t=linear:npl=100,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p" before your crop chain, or accept washed-out output.
  • The 2 MB cap is enforced client-side. A 2.1 MB file uploads successfully via the Bot API and then vanishes when the client tries to set it as an avatar. Always re-encode with a lower CRF if you land close to the limit.
  • faststart matters more than you think. Without it, the client waits for the whole file before it can validate dimensions, and the upload times out on slower connections.

Next on my list: batch mode (send five clips, get five avatars back), and a browser extension that intercepts drag-drop uploads on Telegram Web and runs the same conversion locally with ffmpeg.wasm. If you have opinions on either, ping the bot.

Built by me, @liveavabot.

Top comments (0)