DEV Community

liveavabot
liveavabot

Posted on

Fixing iPhone HEVC Videos That Telegram Silently Rejects

The Avatar That Never Shows Up

You record a short clip on your iPhone, open Telegram, tap "Set profile video", pick the file. The upload spinner runs, finishes, and then nothing happens. No error message, no failed toast, no avatar. You try again and get the same nothing.

I hit this last year and burned a solid hour thinking my client was broken. It wasn't. iPhones have recorded in HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline only accepts H.264. Instead of telling you that, the client silently drops the file. Same story with 10-bit HDR clips and anything in the wrong pixel format.

I ended up writing an ffmpeg wrapper, then a Telegram bot around the wrapper. This post is the build log.

What Telegram Actually Wants

The video avatar spec isn't written down in one place. I pieced it together from the Bot API docs, client behavior, and a lot of failed uploads. Here is what works reliably:

  • Video codec: H.264 with pixel format yuv420p. HEVC fails. 10-bit anything fails.
  • Resolution: square, 800x800 is the sweet spot. Non-square gets rejected.
  • Duration: 10 seconds max.
  • File size: roughly 2 MB in practice.
  • Audio: none. The avatar player has no sound, and files with an audio track get dropped.
  • Container: MP4 with the moov atom at the front (faststart), so playback starts instantly.

A stock iPhone clip fails on at least three of those. It's HEVC, it's 16:9 or 9:16, and it has an audio track. HDR clips add a fourth problem with 10-bit color.

The ffmpeg Pipeline

Two passes. First, cropdetect finds black bars (common when people screen-record or convert from other apps). Second, crop to square, scale down, convert the pixel format, encode H.264, strip audio, trim to 10 seconds.

# pass 1: find the active picture area (first 2 seconds is enough)
ffmpeg -i input.mov -vf "cropdetect=24:16:0" -t 2 -f null - 2>&1 \
  | grep -o 'crop=[0-9:]*' | tail -1

# pass 2: crop to square, scale to 800x800, encode, strip audio
ffmpeg -i input.mov \
  -vf "crop=1080:1080:0:420,scale=800:800,format=yuv420p" \
  -c:v libx264 -profile:v baseline -level 3.1 \
  -b:v 1200k -maxrate 1500k -bufsize 2000k \
  -t 10 -an -movflags +faststart \
  -y output.mp4
Enter fullscreen mode Exit fullscreen mode

Notes on the flags:

  • format=yuv420p is the fix for HDR and 10-bit sources. Without it libx264 happily encodes yuv420p10le and Telegram drops the file.
  • -an strips audio. Forgetting this was my most common failure early on.
  • -movflags +faststart moves the moov atom to the front. Telegram sometimes accepts files without it, but playback stutters.
  • The bitrate ladder (1200k target, 1500k max) lands a 10-second 800x800 clip around 1.6 MB. If the output still exceeds 2 MB, I re-encode at 900k. Two attempts have covered every file I've seen so far.

If you don't care about black bars you can skip cropdetect and center-crop with crop='min(iw,ih)':'min(iw,ih)', which takes the largest centered square. That's the fallback my bot uses when cropdetect returns nothing useful.

Wiring It Into aiogram 3

The bot side is small. Accept a video, animation, or document, download it, run ffmpeg in a subprocess, send back the result.

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

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("50 MB limit, sorry.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "in.bin")
        dst = os.path.join(tmp, "out.mp4")
        await msg.bot.download(file, destination=src)

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-i", src,
            "-vf", "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p",
            "-c:v", "libx264", "-profile:v", "baseline",
            "-b:v", "1200k", "-t", "10", "-an",
            "-movflags", "+faststart", "-y", dst,
        )
        await proc.wait()

        if proc.returncode != 0 or not os.path.exists(dst):
            await msg.answer("Conversion failed. Is the file actually a video?")
            return

        await msg.answer_video(
            FSInputFile(dst),
            caption="Save this, then set it as your profile video.",
        )
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. create_subprocess_exec takes an argument list, not a shell string, so weird filenames can't inject anything. And the whole job runs inside a TemporaryDirectory, so a crashed conversion doesn't leave junk on disk.

Shipping It as @liveavabot

I packaged this as @LiveAvaBot. You send it any video or GIF, it replies with an avatar-ready MP4, you save that file and set it as your profile video. No separate app, no upload site.

Current numbers, since this is a build log: 201 users, around 10 conversions a day. Small, but every one of those is someone who would otherwise be staring at that silent upload spinner.

The production version does a bit more than the snippet above: cropdetect with the center-crop fallback, the bitrate retry when output exceeds 2 MB, and GIF handling. Telegram delivers GIFs as MP4 "animations" already, but often in mpeg4 codec, so they still need re-encoding.

Edge Cases That Bit Me

  • Rotation metadata. iPhone stores orientation as a display matrix instead of rotating pixels. Modern ffmpeg autorotates on decode; the ffmpeg 4.x build on my first server did not, so portrait videos came out sideways. Check your ffmpeg version.
  • Variable frame rate. iPhone clips are VFR and some players choke after a re-encode. Adding -r 30 normalizes it and costs almost nothing.
  • HDR tone mapping. format=yuv420p makes 10-bit files encode, but HDR clips come out washed out without a tonemap filter. I currently accept the washed-out look. Proper tonemapping is on the list.
  • Zero-duration "videos". Some apps export files where the container claims 0 seconds. Run ffprobe first and reject early, it saves you a confusing ffmpeg error.

Next up is smarter handling of 4K sources (right now they just take longer than I'd like) and maybe animated emoji stickers, which use a related but stricter format.

If you've fought with Telegram's media formats and found other undocumented rules, I'd like to hear about them in the comments.

Built by me: @liveavabot.

Top comments (0)