DEV Community

liveavabot
liveavabot

Posted on

Fixing iPhone HEVC Videos for Telegram Video Avatars

The Problem

I uploaded an iPhone video to Telegram as my profile video avatar. Telegram accepted the file, showed a spinner, then silently did nothing. No error. The avatar stayed unchanged.

Turns out Telegram video avatars have a strict spec, and iPhone video files violate almost every requirement. iPhone records in HEVC (H.265) by default. Telegram requires H.264. iPhone videos are often 16:9 or 9:16 portrait. Telegram requires 800x800 square. iPhone videos record audio. Telegram video avatars must have no audio track.

Telegram doesn't tell you any of this. It just quietly rejects the upload.

What Telegram Actually Requires

The video avatar spec (from Telegram's own client behavior, not official docs) is:

  • Codec: H.264 (libx264), yuv420p pixel format
  • Resolution: exactly 800x800 square
  • Duration: 10 seconds or less
  • File size: 2 MB or less
  • Audio: none (leaving an audio track in causes silent failures on some clients)

The audio requirement is the silent killer. Even if you re-encode to H.264 and resize to 800x800, leaving an audio track causes the upload to appear to succeed but never apply on older Telegram clients.

How ffmpeg Solves This

The pipeline I use:

  1. Detect the actual content area (cropdetect removes letterboxing and pillarboxing)
  2. Scale to 800x800, padding to keep aspect ratio
  3. Re-encode as H.264, yuv420p
  4. Strip all audio tracks
  5. Move the moov atom to the start (faststart) so it streams properly

First pass: detect the crop rectangle.

ffmpeg -i input.mov \
  -vf "cropdetect=24:16:0" \
  -frames:v 1 -f null - 2>&1 | grep -oP 'crop=\S+' | tail -1
Enter fullscreen mode Exit fullscreen mode

Then the actual encode, substituting the crop value from the first pass:

ffmpeg -i input.mov \
  -vf "crop=w:h:x:y,scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:(ow-iw)/2:(oh-ih)/2,setsar=1" \
  -c:v libx264 -pix_fmt yuv420p \
  -t 10 \
  -an \
  -movflags +faststart \
  -crf 28 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

-an removes audio. -t 10 enforces the duration cap. -crf 28 keeps the file under 2 MB for typical clips. faststart moves metadata to the front.

For very long input videos or high-resolution sources, CRF 28 sometimes isn't enough to hit 2 MB. In that case I drop to CRF 32 and retry.

The aiogram 3 Handler

The bot is built with aiogram 3. The handler receives a video or animation (GIF) message, downloads it, runs ffmpeg, and sends back the converted file.

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

router = Router()

async def run_ffmpeg(input_path: str, output_path: str) -> bool:
    crop_proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-i", input_path,
        "-vf", "cropdetect=24:16:0",
        "-frames:v", "1", "-f", "null", "-",
        stderr=asyncio.subprocess.PIPE,
    )
    _, stderr = await crop_proc.communicate()

    crop = "in_w:in_h:0:0"
    for line in stderr.decode().splitlines():
        if "crop=" in line:
            crop = line.split("crop=")[-1].split()[0]

    encode_proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-i", input_path,
        "-vf", f"crop={crop},scale=800:800:force_original_aspect_ratio=decrease,"
               "pad=800:800:(ow-iw)/2:(oh-ih)/2,setsar=1",
        "-c:v", "libx264", "-pix_fmt", "yuv420p",
        "-t", "10", "-an", "-movflags", "+faststart",
        "-crf", "28", "-y", output_path,
        stderr=asyncio.subprocess.PIPE,
    )
    await encode_proc.communicate()
    return encode_proc.returncode == 0

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

    with tempfile.TemporaryDirectory() as tmpdir:
        input_path = os.path.join(tmpdir, "input")
        output_path = os.path.join(tmpdir, "output.mp4")

        await message.bot.download(file, destination=input_path)
        ok = await run_ffmpeg(input_path, output_path)

        if ok and os.path.getsize(output_path) <= 2 * 1024 * 1024:
            await message.reply_video(
                FSInputFile(output_path),
                caption="Ready. Set as your Telegram video avatar."
            )
        else:
            await message.reply("Couldn't convert this one. Try a shorter clip.")
Enter fullscreen mode Exit fullscreen mode

The handler works for videos, GIFs (Telegram sends those as animation), and raw document uploads. Document upload is useful when Telegram recompresses the video before sending it as a regular video message, which can change the container format.

Packaging It as @liveavabot

I deployed the bot to a VPS. The whole thing is under 300 lines of Python including the ffmpeg wrapper, error handling, and a /start message.

One thing I added after the first few users: a file size check before download. Telegram bots can receive files up to 20 MB via getFile. I reject anything over 50 MB early with a message to trim the clip first. This saves VPS disk on oversized uploads.

The bot handles concurrent requests fine because each ffmpeg invocation runs in its own temp directory. Async subprocess means the event loop isn't blocked while ffmpeg runs.

You can try it at https://t.me/LiveAvaBot?start=devto_article_20260912. Send any video or GIF and it returns an 800x800 H.264 file ready to upload as your Telegram video avatar.

Built by me, @liveavabot.

Edge Cases I Hit

HEVC 10-bit. Some newer iPhones record 10-bit HEVC for HDR. libx264 doesn't encode 10-bit yuv420p directly. I added -vf "format=yuv420p" before the scale step to force 8-bit conversion. Without this, ffmpeg errors on the encode with a cryptic pixel format mismatch.

GIFs with no audio track. The -an flag on a file that has no audio causes a warning but not an error. Safe to always include it unconditionally.

Very short clips under 1 second. Telegram sometimes won't set them as avatars. I added a minimum duration check and warn the user if the input is too short before running the full encode.

Portrait iPhone videos. A 9:16 portrait video gets padded to 1:1 before the 800x800 encode. The scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:... handles this automatically. Users sometimes expect the full portrait frame to appear; I explain in the /start message that the bot pads rather than crops.

What's next: I want to add an optional crop mode where the user picks a square region from the source video. That's a UI problem more than a technical one, probably a callback keyboard with a preview thumbnail.

Top comments (0)