DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars with FFmpeg

The problem: iPhone videos that Telegram refuses

I wanted to set a short clip as my Telegram video avatar. Recorded 8 seconds on my iPhone, opened the profile picture menu, hit upload. Telegram accepted the file, showed a spinner, then reverted to my old photo. No error message, just silence.

iPhones record video in HEVC (H.265) by default since iOS 11. Telegram's video avatar endpoint only accepts H.264 in an MP4 container. HEVC gets rejected server-side without a user-facing message. The upload "succeeds" but the profile never updates.

That's the pain that made me build @liveavabot.

What Telegram actually requires

The video avatar spec isn't in the public Bot API docs (video avatars are a client feature, not a bot feature), but the constraints are visible if you inspect the desktop client's upload path:

  • Container: MP4 with faststart (moov atom at the beginning)
  • Video codec: H.264, yuv420p pixel format, Baseline or Main profile
  • Resolution: 800x800 square, exactly
  • Duration: 10 seconds max
  • File size: 2 MB max
  • Audio: must be stripped

Miss any of these and the client either rejects the upload immediately or, worse, accepts it and silently discards it. HEVC lands in the second failure mode. So do yuv444p files from some Android editors, and any resolution that isn't exactly 800x800.

Cropdetect, scale, and format conversion in one pipe

FFmpeg handles all of this if you chain the right filters. Here's the actual command my bot runs:

ffmpeg -i input.mov \
  -t 10 \
  -vf "scale=800:800:force_original_aspect_ratio=increase,crop=800:800,format=yuv420p" \
  -c:v libx264 \
  -profile:v main \
  -preset veryfast \
  -crf 26 \
  -movflags +faststart \
  -an \
  -y output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • -t 10 caps duration at 10 seconds. If the source is longer, the tail is dropped.
  • The scale filter with force_original_aspect_ratio=increase upscales so the smaller side fits 800. Then crop=800:800 slices out a centered square. This handles portrait, landscape, and already-square input uniformly.
  • format=yuv420p forces the pixel format Telegram wants.
  • -c:v libx264 -profile:v main picks H.264 with a widely compatible profile.
  • -crf 26 targets a quality level that usually keeps output under 2 MB for 10 seconds at 800x800. If it doesn't, I re-encode with -crf 30 as a fallback.
  • -movflags +faststart moves the moov atom to the start of the file so Telegram can begin decoding without downloading the whole thing.
  • -an strips audio. Video avatars are muted anyway.

For source videos with letterboxing (someone screen-recorded a phone video on a laptop), I run cropdetect first:

ffmpeg -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 | \
  grep -oP 'crop=\K[0-9:]+' | tail -1
Enter fullscreen mode Exit fullscreen mode

That prints something like 640:640:0:0 which I feed back as a pre-crop before the scale pipeline.

The aiogram 3 handler

The bot side is small. Aiogram 3 handles file downloads, so the whole flow is: receive video, download, shell out to ffmpeg, upload the result. Here's the trimmed handler:

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

router = Router()

@router.message(F.video | F.animation | F.document)
async def convert(msg: Message):
    file = msg.video or msg.animation or msg.document
    if file.file_size and file.file_size > 50 * 1024 * 1024:
        await msg.answer("Source is over 50 MB, please trim it first.")
        return

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

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

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-i", str(src), "-t", "10",
            "-vf", "scale=800:800:force_original_aspect_ratio=increase,"
                   "crop=800:800,format=yuv420p",
            "-c:v", "libx264", "-profile:v", "main",
            "-preset", "veryfast", "-crf", "26",
            "-movflags", "+faststart", "-an", "-y", str(dst),
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        rc = await proc.wait()

        if rc != 0 or not dst.exists():
            await msg.answer("Conversion failed. Try a different source.")
            return

        if dst.stat().st_size > 2 * 1024 * 1024:
            await msg.answer("Output exceeded 2 MB. Retrying at lower quality.")
            return

        await msg.answer_video(
            FSInputFile(dst),
            caption="Ready. Long-press your avatar in Settings, "
                    "pick 'Set as Video', upload this file."
        )
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • I accept F.video | F.animation | F.document because iPhone shares often arrive as document with mime video/quicktime, not video. Missing that check drops half the real traffic.
  • The 50 MB source cap matches Bot API's getFile limit for non-premium bots. Bigger sources need the local Bot API server.
  • The 2 MB output check is a safety net. In practice CRF 26 hits under 2 MB about 95% of the time for typical phone clips.

Packaging it as @liveavabot

I glued the handler above to a Postgres row per user (for stats and rate limits), a Redis queue for concurrent conversions, and a small aiogram middleware for logging. The whole thing runs on a cheap Hetzner VPS with ffmpeg from apt. No GPU, no cloud transcoder. 800x800 for 10 seconds is trivial CPU load, veryfast preset finishes it in under a second.

Try it: https://t.me/LiveAvaBot?start=devto_article_20260722. Send any video or GIF, it sends back a Telegram-compatible avatar file.

Edge cases I learned the hard way

  • Rotated iPhone videos. iPhones store portrait video as landscape with a rotation flag in metadata. FFmpeg 5+ honors it by default, 4.x needs -noautorotate handling. Check your distro version.
  • Animated stickers as source. Telegram sends .tgs (Lottie JSON), not video. Ignore those in the handler, they'd just error.
  • GIFs with palette artifacts. Old GIFs upscale ugly. I added -sws_flags lanczos to the scale filter, which sharpens the result a lot without slowing anything down.
  • Voice-over videos. People sometimes complain that audio was stripped. I added a note in the reply caption explaining Telegram video avatars are muted by spec.

What's next

Right now the bot is stateless per conversion. Next iteration adds a trim mode where users pick the 10-second window from a longer clip, using inline buttons and ffmpeg's -ss seek. After that, probably batch mode for multi-file uploads.

Built by me. @liveavabot lives at https://t.me/LiveAvaBot?start=devto_article_20260722 if you want to try it or fork the concept.

Top comments (0)