DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars With FFmpeg

The bug that took me a weekend

I recorded a 6-second clip on my iPhone, tried to set it as a Telegram video avatar, and got nothing. No error, no toast, no progress bar. Just the old avatar sitting there. I tried again. Same silence. I opened the file in QuickTime, it played fine. I checked the size, 1.4 MB, well under any limit I could imagine. So why did Telegram just swallow the request without a word?

Turns out iPhone records in HEVC (H.265) by default since iOS 11, and Telegram's video avatar endpoint only accepts H.264. The client uploads the file, the server rejects it silently, the UI shows nothing. If you have never dug into this, you assume your bot is broken or the network is flaky. It is neither. The codec is wrong.

What Telegram actually wants for a video avatar

The Bot API docs on this are thin, but the effective spec (confirmed by about forty test uploads) is:

  • Codec: H.264 (libx264), yuv420p pixel format
  • Container: MP4 with faststart
  • Resolution: 800x800, square, exact
  • Duration: 1 to 10 seconds
  • Frame rate: 25 to 30 fps is safest
  • Audio: must be absent (not just muted, absent)
  • File size: under about 2 MB in practice

Miss any one of these and the avatar update just fails quietly. No error is returned from the API in most client paths.

The ffmpeg pipeline that actually works

The tricky part is not the codec swap, that is one flag. The tricky part is the crop. Most iPhone clips are portrait (1080x1920) or landscape (1920x1080), and a naive scale=800:800 squashes them into a distorted square. What you want is: detect the interesting region, center-crop to a square, then scale.

I run cropdetect on a probe pass, then apply the crop and scale in a single encode pass.

# probe pass, 2 seconds of input is enough
ffmpeg -ss 0 -t 2 -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 \
  | grep -oE 'crop=[0-9:]+' | tail -1
# yields something like: crop=1080:1080:0:420

# encode pass
ffmpeg -y -i input.mov \
  -vf "crop=1080:1080:0:420,scale=800:800,format=yuv420p" \
  -c:v libx264 -profile:v baseline -level 3.1 \
  -preset veryfast -crf 26 \
  -movflags +faststart \
  -an \
  -t 10 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Notes on the flags:

  • format=yuv420p inside the filter chain forces the pixel format before encode. Some libx264 builds pick yuv444p from HEVC sources and Telegram rejects that.
  • -an strips audio. If you leave -c:a copy by mistake you get an AAC track and Telegram rejects the whole file.
  • +faststart moves the moov atom to the front so the file streams. Without it Telegram sometimes times out on the fetch.
  • -t 10 hard-caps duration. A 15-second input becomes the first 10 seconds.
  • -preset veryfast -crf 26 gives me files under 2 MB for almost all 10-second clips.

The aiogram 3 handler

I use aiogram 3 with the router pattern. The handler accepts video, video_note, animation (GIF), and document (for people who forward as file). It downloads to a temp file, runs the pipeline in a subprocess, uploads the result.

from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from aiogram.enums import ContentType
import asyncio, os, uuid

router = Router()

MEDIA = F.content_type.in_({
    ContentType.VIDEO,
    ContentType.VIDEO_NOTE,
    ContentType.ANIMATION,
    ContentType.DOCUMENT,
})

@router.message(MEDIA)
async def on_media(msg: Message):
    file = msg.video or msg.video_note or msg.animation or msg.document
    if not file:
        return
    src = f"/tmp/{uuid.uuid4()}_in"
    dst = f"/tmp/{uuid.uuid4()}_out.mp4"
    await msg.bot.download(file, destination=src)

    proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-y", "-i", src,
        "-vf", "scale=800:800:force_original_aspect_ratio=increase,crop=800:800,format=yuv420p",
        "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
        "-preset", "veryfast", "-crf", "26",
        "-movflags", "+faststart",
        "-an", "-t", "10",
        dst,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.PIPE,
    )
    await proc.communicate()
    if proc.returncode != 0:
        await msg.answer("ffmpeg failed, try a shorter clip")
        return

    if os.path.getsize(dst) > 2 * 1024 * 1024:
        await msg.answer("output too big, try a shorter clip")
        return

    await msg.answer_video(FSInputFile(dst), caption="use this as your profile video")
    os.remove(src); os.remove(dst)
Enter fullscreen mode Exit fullscreen mode

I skipped cropdetect in this inline snippet and leaned on ffmpeg's built-in scale-and-crop combo (force_original_aspect_ratio=increase then center-crop). It works for about 90% of clips. The full bot uses the two-pass cropdetect version for anything with letterboxing.

How this became @liveavabot

I wrapped the pipeline as a public Telegram bot: @LiveAvaBot. Send it any video or GIF, get back a file you can set as your Telegram video avatar. No signup, no watermark. Users so far: 382. Runs on a small Hetzner box with ffmpeg 6.1 and Python 3.11.

Bot state (rate limits, per-user cooldowns, error logs) lives in SQLite. I did not reach for Redis at this scale. If traffic ever gets serious I will swap it out, but 382 users generate about 10 conversions a day, well within what a single asyncio worker handles.

Edge cases and what is next

Things that broke along the way:

  • 10-bit HEVC from iPhone Pro cameras: libx264 handles it if you have a recent ffmpeg. Older builds throw Unsupported pixel format. Force format=yuv420p in the filter chain.
  • Portrait videos with subtitles baked in at the bottom: cropdetect includes them, the resulting square looks off. I added a heuristic to prefer top-anchored crops for portrait input.
  • Live Photos exported as .mov: they include a still image track that ffmpeg treats as a second video stream. -map 0:v:0 fixes it.
  • GIFs with transparent backgrounds: I flatten to black. Telegram avatars have no alpha channel.

Next up: batch mode (drop five clips, get the best one back based on a simple sharpness score) and a mini WebApp preview so users can pick their favorite crop before it lands on their profile.

Built by me, @LiveAvaBot. If you hit an edge case, DM me, I read every message.

Top comments (0)