DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC to Telegram Video Avatars with FFmpeg

The problem: Telegram silently rejects your iPhone videos

If you have tried uploading an iPhone video as your Telegram profile picture (the animated one), you know the deal. The upload goes through. The progress bar fills up. Then nothing. Your avatar stays as it was. No error, no toast, no explanation.

The video looked fine in your camera roll. It plays in Photos. It plays in QuickTime. But Telegram treats it like it does not exist.

The reason is HEVC. Since iOS 11, iPhones record video in HEVC (H.265) by default. Telegram's video avatar pipeline expects H.264. When the codec check fails, the server drops the file on the floor without telling the client.

I built @LiveAvaBot to fix this. Send it any video or GIF, get back a valid Telegram video avatar. This is a walkthrough of what the format actually requires and how ffmpeg gets you there.

What the Telegram video avatar spec actually requires

Telegram does not publish this as a single page, but from bot testing and API docs, here is what a video avatar has to be:

  • Container: MP4
  • Video codec: H.264 (avc1), yuv420p pixel format
  • Resolution: exactly 800x800, square
  • Duration: 10 seconds maximum
  • File size: 2 MB maximum
  • Audio: none, must be stripped
  • Faststart: moov atom at the front

Miss any of these and the upload either fails silently (codec, resolution) or gets rejected with a size error (over 2 MB). The 2 MB cap is the tightest constraint. At 800x800 H.264 you have roughly 200 KB per second to work with for 10 seconds of video.

Cropping to square without cutting off the subject

Most phone videos are 9:16 or 16:9. You cannot just resize to 800x800, that stretches faces into pancakes. You need a center crop to a square, then scale down.

FFmpeg has a cropdetect filter that can find content bounds, but for phone video the whole frame is usually the content. The pragmatic approach is a centered square crop based on the shorter side.

ffmpeg -i input.mov \
  -vf "crop='min(iw,ih)':'min(iw,ih)':(iw-min(iw,ih))/2:(ih-min(iw,ih))/2,scale=800:800,format=yuv420p" \
  -c:v libx264 -profile:v high -level 4.0 \
  -preset veryfast -crf 28 \
  -movflags +faststart \
  -an \
  -t 10 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking that down:

  • crop=min(iw,ih):min(iw,ih):... takes a centered square from the smallest dimension of the source.
  • scale=800:800 downsamples to Telegram's exact resolution.
  • format=yuv420p forces the pixel format Telegram expects. Without this you might end up with yuv420p10le on some HDR sources and the file will be rejected.
  • -c:v libx264 re-encodes to H.264 regardless of input codec, which handles the HEVC case.
  • -crf 28 targets decent quality that fits in 2 MB for most 10 second clips. Bump to 30 if you overshoot, drop to 24 if you have headroom.
  • -movflags +faststart puts the moov atom at the start so Telegram can begin decoding without a full download.
  • -an strips audio.
  • -t 10 caps duration.

If you still bust the 2 MB budget, the next lever is a bitrate cap with -b:v 1500k -maxrate 1500k -bufsize 3000k and a second pass. For 90% of iPhone clips the crf 28 single pass is enough.

The aiogram 3 handler

I built the bot on aiogram 3. Here is the trimmed video handler.

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

router = Router()

FFMPEG_TEMPLATE = [
    "ffmpeg", "-y", "-i", "{input}",
    "-vf",
    "crop='min(iw,ih)':'min(iw,ih)':(iw-min(iw,ih))/2:(ih-min(iw,ih))/2,"
    "scale=800:800,format=yuv420p",
    "-c:v", "libx264", "-profile:v", "high", "-level", "4.0",
    "-preset", "veryfast", "-crf", "28",
    "-movflags", "+faststart",
    "-an", "-t", "10",
    "{output}",
]

@router.message(F.video | F.animation | F.document)
async def convert(msg: Message):
    file = msg.video or msg.animation or msg.document
    if not file:
        return
    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "in.bin"
        dst = Path(tmp) / "out.mp4"
        await msg.bot.download(file, destination=src)
        cmd = [c.format(input=str(src), output=str(dst)) for c in FFMPEG_TEMPLATE]
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.PIPE,
        )
        _, err = await proc.communicate()
        if proc.returncode != 0:
            await msg.reply(f"ffmpeg failed: {err.decode()[:400]}")
            return
        size_mb = dst.stat().st_size / 1024 / 1024
        if size_mb > 2:
            await msg.reply(f"Output is {size_mb:.1f} MB, over the 2 MB cap. Try a shorter clip.")
            return
        await msg.reply_video(FSInputFile(dst), caption="Upload this as your profile video.")
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • I accept document too because iOS sometimes sends MOV files as documents when the size is above a threshold.
  • The reply is reply_video, not reply_document. Telegram will show the correct preview and the user can forward it straight to their profile settings.
  • The 2 MB check is a client side courtesy. Telegram will reject bigger files, but the error the user sees is cleaner if I catch it first.

Packaging it as @liveavabot

The full bot lives at https://t.me/LiveAvaBot?start=devto_article_20260817. It runs on a small VPS, uses aiogram 3, and stores nothing except a per user conversion counter for rate limiting. The ffmpeg call is the same as above, plus a queue so a burst of uploads does not spawn 20 parallel encodes on a 2 vCPU box.

Things I punted on for v1:

  • 4K HDR sources with wide color gamut. Right now they get tone mapped to SDR with mixed results.
  • Vertical crop mode. Center square works for most clips but a face detector would beat it for portrait video.
  • GIF audio. GIFs do not have audio, so -an is a no op, but Telegram animation files can carry audio. It gets stripped anyway.

Lessons

The main takeaway from shipping this: ffmpeg is doing 95% of the work, I just wrote a wrapper. The tricky part was not the encoding, it was figuring out the exact spec Telegram wants. There is no error message when you get it wrong. The upload succeeds, the avatar does not change, and you go debug for an hour.

If you are building anything that produces media for Telegram, test with getUserProfilePhotos after the upload. If the new avatar is not there, your file was silently dropped, and you have a spec bug somewhere.

Built by me, @liveavabot: https://t.me/LiveAvaBot?start=devto_article_20260817

Top comments (0)