DEV Community

liveavabot
liveavabot

Posted on

Why iPhone Videos Break Telegram Video Avatars (and How to Fix It)

The Problem Nobody Warns You About

Telegram supports video avatars. You record a clip on your iPhone, try to upload it as your profile video, and nothing happens. No error, no warning. The upload just silently fails.

The culprit is HEVC (H.265). Every iPhone since the iPhone 7 records in HEVC by default. Telegram's video avatar processor doesn't support it. The file gets dropped without explanation.

There's also the resolution problem. Telegram requires exactly 800x800 pixels. Most video is 16:9, and most people don't know to square-crop it first.

What Telegram Actually Requires

The spec for Telegram video profile pictures is specific:

  • Codec: H.264, not H.265/HEVC
  • Resolution: 800x800 pixels
  • Duration: 10 seconds maximum
  • File size: 2MB maximum
  • Audio: none (even a muted audio track causes rejection)

Most iPhone videos fail on at least three of those simultaneously: HEVC codec, 16:9 aspect ratio, and often longer than 10 seconds.

Fixing It with ffmpeg

The conversion pipeline has four jobs: decode whatever codec the input uses, smart-crop the frame to a square, scale to 800x800, and re-encode to H.264 with yuv420p while stripping audio.

I use ffmpeg's cropdetect filter to find the content bounds in pass 1, then apply the crop in pass 2. This matters for portrait iPhone videos where a naive center-crop would cut the subject's face.

# Pass 1: detect crop bounds
ffmpeg -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 | \
  grep -oP 'crop=\S+' | tail -1

# Pass 2: apply crop + encode to spec
ffmpeg -i input.mov \
  -vf "crop=720:720:0:180,scale=800:800:flags=lanczos,format=yuv420p" \
  -c:v libx264 -crf 23 -preset slow \
  -an \
  -movflags +faststart \
  -t 10 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

The -an flag removes audio completely. The movflags +faststart moves the moov atom to the front so Telegram can start playing before the full download completes. The -t 10 hard-clips at 10 seconds.

The crop values from pass 1 (like crop=720:720:0:180) go directly into pass 2. If the source is already square, skip the crop filter.

Handling 2MB Overage

CRF 23 usually keeps output under 2MB for 10 seconds at 800x800. For high-motion content it sometimes doesn't. If the first pass comes in over 2MB, I calculate a target bitrate and re-encode:

def max_bitrate_kbps(duration_s: float, headroom_kb: int = 48) -> int:
    budget_bits = (2 * 1024 * 1024 - headroom_kb * 1024) * 8
    return int(budget_bits / duration_s / 1000)
Enter fullscreen mode Exit fullscreen mode

Then pass -b:v {kbps}k -maxrate {kbps}k -bufsize {kbps * 2}k to ffmpeg on the second attempt.

Wiring It Up in aiogram 3

Here's a minimal handler in aiogram 3. It accepts video, document, or animation (Telegram sends GIFs as Animation type), runs the ffmpeg pipeline, and sends back the converted file.

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

router = Router()

@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
    status = await message.answer("Converting...")

    file_id = (
        message.video.file_id if message.video
        else message.document.file_id if message.document
        else message.animation.file_id
    )

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

        file = await message.bot.get_file(file_id)
        await message.bot.download_file(file.file_path, 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", "-crf", "23", "-preset", "fast",
            "-an", "-movflags", "+faststart", "-t", "10",
            dst,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await proc.communicate()

        if proc.returncode != 0:
            await status.edit_text("Conversion failed.")
            return

        await message.answer_video(
            video=FSInputFile(dst),
            caption="Ready. Set it as your Telegram video avatar.",
        )
        await status.delete()
Enter fullscreen mode Exit fullscreen mode

The scale=...:force_original_aspect_ratio=increase,crop=800:800 chain scales up until the shorter dimension hits 800, then crops the overflow. This is the CSS object-fit: cover equivalent for video, and it gives better results than letterboxing.

Building @liveavabot

I packaged this pipeline into @LiveAvaBot. Send it any video, GIF, or document file. It converts and sends back an 800x800 H.264 mp4 ready to set as your Telegram video avatar.

A few things the bot handles beyond the basic pipeline:

  • GIFs: Telegram delivers them as Animation type with no duration field. I cap them at 10 seconds with -t 10 regardless.
  • HEVC color space: iPhone HDR videos need colorspace=bt709 in the filter chain. Without it, the re-encoded file looks washed out on standard displays.
  • File size gate: Telegram's bot API caps downloads at 50MB. Files over that get a clear rejection message, not a silent timeout.

The bot has 477 registered users at the time of writing.

Edge Cases I Got Wrong the First Time

Forgetting -an was the first mistake. The initial version kept a muted audio stream. Telegram still rejected the output. You need zero audio tracks, not just zero volume.

format=yuv420p is also non-negotiable. Some source files produce yuv422p or yuv444p. Certain Telegram clients won't render those correctly. Always force yuv420p on output.

Portrait iPhone videos (1080x1920) break a naive scale=800:800. It squishes the frame horizontally into a distorted square. The two-pass cropdetect approach handles these correctly.

I currently run ffmpeg inline during the request handler. For long source files this sometimes hits Telegram's response timeout. The right fix is a background worker queue with job polling. That's on the list.

Built by me: the bot is @LiveAvaBot.

Top comments (0)