DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars with FFmpeg

The bug that made me write this

I tried setting a short clip from my iPhone as my Telegram profile video. Telegram accepted the upload, showed a spinner, then quietly reverted to my old static picture. No error message. No hint. Just the old avatar again.

The clip was H.265 (HEVC), which iPhones record by default. Telegram's video avatar endpoint accepts H.264 only. The upload technically worked, the server just refused to use the file, and there was no user-visible reason.

I wrote a bot that takes any video or GIF and produces a file Telegram will actually accept as a video avatar. This post is the ffmpeg pipeline and the aiogram 3 handler that runs it.

What Telegram's video avatar format actually is

Telegram doesn't document this well, but reverse engineering it gives you:

  • Container: MP4
  • Video codec: H.264, yuv420p, high profile is fine
  • Audio: must be removed entirely
  • Resolution: 800x800 square, exact
  • Duration: 10 seconds max (I clamp to 9.9 to be safe)
  • File size: 2 MB max
  • Frame rate: 30 fps works, higher gets rejected sometimes
  • moov atom at the front (faststart)

If any of those are wrong, the upload either silently fails or the avatar shows as a still frame from second zero.

The ffmpeg pipeline

The tricky part is turning a rectangular phone video into an 800x800 square without ugly letterboxing. I run cropdetect on a probe pass, then crop and scale in the encode pass.

ffmpeg -ss 00:00:02 -i input.mov -vframes 10 \
  -vf cropdetect=24:16:0 -f null - 2>&1 | \
  grep -o 'crop=[0-9:]*' | tail -1
# example output: crop=1080:1080:0:420
Enter fullscreen mode Exit fullscreen mode

Then the actual encode with the detected crop values:

ffmpeg -y -i input.mov \
  -t 9.9 \
  -an \
  -vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,fps=30" \
  -c:v libx264 -preset veryfast -crf 23 \
  -profile:v high -level 4.0 \
  -movflags +faststart \
  -pix_fmt yuv420p \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Key flags:

  • -an drops audio. Telegram rejects avatars with any audio track.
  • -vf format=yuv420p plus the explicit -pix_fmt yuv420p are belt and suspenders. Some sources are yuv444p and Telegram won't touch that.
  • -movflags +faststart moves the moov atom to the start of the file so the server can begin streaming without downloading the whole thing.
  • -t 9.9 clamps duration. I found 10.0 flat sometimes gets rejected by rounding.
  • -crf 23 keeps files under 2 MB for most 9 second clips. If the output goes over, I re-encode with -crf 28 and warn the user about quality.

For GIFs I skip cropdetect and center-crop to a square, because GIFs are usually already framed for the subject.

Aiogram 3 handler

I run aiogram 3 with the Router pattern. The handler grabs the file, hands it to an async ffmpeg subprocess, then sends back the result.

import asyncio
import subprocess
import tempfile
from pathlib import Path

from aiogram import Router, F
from aiogram.types import Message, FSInputFile

router = Router()

async def convert(src: Path, dst: Path) -> None:
    cmd = [
        "ffmpeg", "-y", "-i", str(src),
        "-t", "9.9",
        "-an",
        "-vf", "cropdetect=24:16:0,scale=800:800:flags=lanczos,format=yuv420p,fps=30",
        "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
        "-profile:v", "high", "-level", "4.0",
        "-movflags", "+faststart",
        "-pix_fmt", "yuv420p",
        str(dst),
    ]
    proc = await asyncio.create_subprocess_exec(
        *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
    )
    _, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(stderr.decode()[-500:])

@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message) -> None:
    file = message.video or message.animation or message.document
    if not file:
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "in"
        dst = Path(tmp) / "out.mp4"

        tg_file = await message.bot.get_file(file.file_id)
        await message.bot.download_file(tg_file.file_path, destination=src)

        try:
            await convert(src, dst)
        except RuntimeError as e:
            await message.answer(f"ffmpeg failed: {e}")
            return

        size_mb = dst.stat().st_size / 1024 / 1024
        if size_mb > 2.0:
            await message.answer(
                f"Output is {size_mb:.1f} MB, over the 2 MB Telegram limit. "
                "Try a shorter clip."
            )
            return

        await message.answer_video(FSInputFile(dst))
Enter fullscreen mode Exit fullscreen mode

That's the core. The production version has more error handling, rate limiting, and metrics, but the pipeline is 40 lines.

Packaging this as @liveavabot

I put the pipeline behind a bot. Send it any video or GIF, get back an MP4 you can set as your Telegram video avatar. It handles iPhone HEVC, WhatsApp exports, Instagram reels saves, TikTok downloads, and other common sources.

Stack:

  • aiogram 3 on Python 3.11
  • ffmpeg 6.x from apt
  • systemd unit, no docker
  • SQLite for user counts and rate limiting
  • Hetzner CX22, 2 GB RAM is enough for concurrent ffmpeg jobs

Cost so far: 4 EUR per month for the VPS. ffmpeg does the actual work, I just wrote the wrapper and the Telegram plumbing.

Try it: https://t.me/LiveAvaBot?start=devto_article_20260730

Edge cases I hit

  • iPhone Live Photos: the .mov wrapper contains a still HEIC plus a 3 second video. ffmpeg picks the right stream automatically, but the aspect is often 4:3 not 16:9, so cropdetect matters.
  • Animated WebPs sent as stickers: Telegram sends these as documents with mime image/webp. ffmpeg handles them, but you need -r 30 on input or the output fps is broken.
  • Videos with rotation metadata: shoot vertical on iPhone, ffmpeg auto-rotates on read. Works fine.
  • Very short GIFs (under 1 second): I loop them to 3 seconds so the avatar isn't a blink. Use -stream_loop 3 -t 3 before -i.
  • Files over 20 MB: Telegram's bot API caps downloads at 20 MB. I catch that and tell the user to trim before sending.

What's next

Small backlog:

  • Auto face-detect crop instead of center-crop (dlib or mediapipe)
  • Batch mode for multiple videos in one message
  • Preset selection (portrait, landscape, action)

The core problem, HEVC to H.264 with the right specs, is solved. The rest is polish.

Built by me, @liveavabot on Telegram.

Top comments (0)