DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars With ffmpeg

I hit this bug last month. Uploaded a clip from my iPhone as a video avatar in Telegram. It processed, the UI said done, and then nothing happened. My avatar was still the old one. No error, no message, just silence.

Turns out Telegram silently rejects videos that don't match its exact video-avatar spec. And iPhone videos never match it, because Apple encodes in HEVC by default.

I got annoyed. I wrote a bot. Now it lives at t.me/LiveAvaBot and I want to walk through what the spec actually requires and how ffmpeg handles it.

What Telegram Actually Wants

The video-avatar format is stricter than most people realize. Here's what I reverse-engineered by trial and error:

  • Codec: H.264 (avc1). HEVC (hvc1) is rejected.
  • Resolution: exactly 800x800 square. Not 720, not 1080.
  • Pixel format: yuv420p. yuv420p10le fails silently.
  • Duration: 10 seconds or less. 10.1 seconds gets truncated with weird artifacts.
  • File size: 2 MB or less. Over that, Telegram compresses it aggressively and quality drops.
  • Audio: must be stripped. Audio tracks cause silent rejection on some clients.
  • Container: mp4 with faststart flag (moov atom at the start).

None of this is documented well. The Bot API accepts sendVideo happily, but for the personal profile video avatar the constraints tighten dramatically and there's no error surface. Your upload just quietly does nothing.

The Encode Pipeline

Most source videos aren't square. A phone clip is 1080x1920 portrait, a screen recording might be 1920x1080 landscape. You need to pick a 1:1 window from the interesting part of the frame.

I use ffmpeg's cropdetect filter to find where the actual content is (skipping black bars) and then center-crop a square from that. Two passes, first detect, then encode:

# Pass 1: detect crop rectangle
CROP=$(ffmpeg -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 \
  | grep -oE 'crop=[0-9:]+' | tail -1)

# Pass 2: encode with detected crop, scaled to 800x800
ffmpeg -y -loglevel error \
  -i input.mov \
  -t 10 \
  -vf "${CROP},scale=800:800:flags=lanczos,format=yuv420p,fps=30" \
  -an \
  -c:v libx264 -preset veryfast -crf 23 \
  -profile:v high -level 4.0 \
  -movflags +faststart \
  -f mp4 output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking down the filters in pass 2:

  • crop=... cuts the detected rectangle from the source.
  • scale=800:800:flags=lanczos downscales to the target with a decent kernel.
  • format=yuv420p forces 8-bit 4:2:0 (kills HEVC 10-bit HDR).
  • fps=30 normalizes the frame rate. iPhone shoots 60, Telegram gets confused.
  • -an strips audio.
  • -t 10 clips to 10 seconds.
  • -movflags +faststart puts the moov atom at the front so Telegram can start playing before download completes.

CRF 23 with the veryfast preset lands most videos under 2 MB. If a clip goes over, I re-encode at CRF 28. Anything still over 2 MB gets rejected with a friendly message to the user.

The aiogram 3 Handler

Here's the minimal handler that ties it together. aiogram 3 with async/await, the whole path from receiving the video to responding with the converted file:

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

router = Router()

async def convert_to_avatar(src: str, dst: str) -> None:
    proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-y", "-loglevel", "error",
        "-i", src,
        "-t", "10",
        "-vf", "crop=in_h:in_h:(in_w-in_h)/2:0,scale=800:800:flags=lanczos,format=yuv420p,fps=30",
        "-an",
        "-c:v", "libx264",
        "-preset", "veryfast",
        "-crf", "23",
        "-profile:v", "high",
        "-level", "4.0",
        "-movflags", "+faststart",
        "-f", "mp4",
        dst,
        stderr=asyncio.subprocess.PIPE,
    )
    _, err = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(err.decode(errors="ignore"))

@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message):
    file = msg.video or msg.animation or msg.document
    if file.file_size and file.file_size > 50 * 1024 * 1024:
        await msg.reply("File too big. 50 MB cap.")
        return
    await msg.bot.send_chat_action(msg.chat.id, ChatAction.UPLOAD_VIDEO)
    with tempfile.TemporaryDirectory() as td:
        src = pathlib.Path(td) / "in.bin"
        dst = pathlib.Path(td) / "out.mp4"
        await msg.bot.download(file, destination=str(src))
        try:
            await convert_to_avatar(str(src), str(dst))
        except RuntimeError as e:
            await msg.reply(f"ffmpeg failed: {str(e)[:200]}")
            return
        if dst.stat().st_size > 2 * 1024 * 1024:
            await msg.reply("Output over 2 MB, try a shorter clip.")
            return
        await msg.reply_video(FSInputFile(dst))
Enter fullscreen mode Exit fullscreen mode

That's the whole flow. Download, convert, check size, send back. In production I added a queue, per-user rate limiting, and better error messages, but this is the core.

Packaging It as a Bot

I wrapped this into a live bot: @LiveAvaBot. Users send any video or GIF, and get back a file they can drag into Telegram's video-avatar picker. It handles HEVC, portrait, landscape, GIFs, MOV, MP4, and weirder containers like webm.

The whole thing runs on a small VPS. ffmpeg does the heavy lifting, I just wrote the wrapper.

Edge Cases and What's Next

A few things I learned the hard way:

  • Rotation metadata: iPhone videos have a rotation flag in the container. Newer ffmpeg respects it by default, but on older builds the output comes out sideways. I added -noautorotate and handle rotation manually via transpose when the metadata says so.
  • Variable frame rate: Screen recordings often have VFR. Forcing fps=30 fixed frame-drift artifacts even without audio.
  • HDR to SDR: iPhone Pro shoots Dolby Vision. Just forcing yuv420p tone-maps poorly. I added a zscale plus tonemap chain when the source is HDR. It's ugly but works.
  • 4K sources: Currently I downscale in one pass. Not great quality-wise. Next version will do a two-pass with proper sharpening.

Next on my list is a Telegram Stars payment for a batch mode (convert 10 clips at once). Free tier keeps the single-clip flow.

Built by me, @liveavabot. Feedback welcome, especially edge cases where my ffmpeg pipeline breaks.

Top comments (0)