У нас есть конвейер, который раз в день собирает кадр для Stories в Instagram (фото + выжженная поверх подпись) и публикует его через Graph API на два бренд-аккаунта. 2 сентября в моей ленте всплыла своя же сторис, и подпись «посчитал сколько стоит перевезти сестру в москву» была обрезана с обеих сторон — первые и последние буквы срезаны краем экрана. Я это почти сразу починил. 5 сентября мне снова написали, что сторис «кривые». Я почти сразу починил снова. Обе правки были правильными и обе не имели отношения к тому, что жаловались на самом деле.

Симптом №1: 02.09, обрезка по бокам

Кадр рендерится в 1080×1920 (9:16) — это ровно формат Stories. Но телефоны давно не 16:9: у iPhone экран 19,5:9, у части Android — 21:9. Instagram растягивает сторис по высоте под экран и обрезает по ширине то, что не влезает. Посчитал на пальцах и проверил на двух своих устройствах:

  • 19,5:9 — видно 886 px из 1080 по ширине;

  • 21:9 — видно 823 px из 1080.

То есть по 97–129 px с каждой стороны зритель физически не видит — это область под системными вырезами и краями экрана, а не баг Instagram. Текст рисовался с отступом 80 px от края — то есть ровно в этой мёртвой зоне. Отступ увеличил до 140 px и заодно переписал подбор кегля:

# было: жёсткий перенос по 24 знака, при неудачном подборе размера
# текст рисовался шире кадра, потому что цикл просто заканчивался
size = 86
while size > 30:
    f = ImageFont.truetype(FONT_B, size)
    wide = max((d.textlength(l, font=f) for l in lines if l), default=0)
    if wide <= W - 160 and int(size * 1.3) * len(lines) <= int(H * 0.34):
        break
    size -= 2
# стало: если при переносе по 24 знака ни один размер шрифта не влезает,
# сначала укорачиваем перенос и только потом уменьшаем кегль
SIDE = 140
MAX_TEXT_W = W - SIDE * 2

def fit():
    for wrap_at in (24, 21, 18, 15):
        ls = textwrap.wrap(text, wrap_at) or [""]
        size = 86
        while size >= 30:
            fnt = ImageFont.truetype(FONT_B, size)
            wide = max((d.textlength(l, font=fnt) for l in ls if l), default=0)
            if wide <= MAX_TEXT_W and int(size * 1.3) * len(ls) <= int(H * 0.34):
                return ls, fnt
            size -= 2
    ls = textwrap.wrap(text, 15) or [""]
    return ls, ImageFont.truetype(FONT_B, 30)

Замерил результат на сервере тем же скриптом, что и до правки: запас от края вырос с 86 px до 141–167 px в зависимости от длины строки. На проверочном кадре текст целиком внутри безопасной зоны на обоих устройствах. Задача закрыта, я про неё забыл.

Симптом №2: 05.09, «всё ещё криво», и это была другая болезнь

Пришло то же самое «сторис криво», но конкретики меньше. Первая гипотеза была логичной: у нас в двух местах лежала своя реализация рендера подписи — у одного бренд-аккаунта отдельным модулем (av_story.py), у второго — инлайновым скриптом в файле публикации (mk_ig_story_daily.py), и правку от 02.09 я внёс только во второй файл. Стал сверять константы и нашёл реальное расхождение: у первого аккаунта текст шёл от 80 px с переносом по 26 знаков и мог занимать до 42% высоты кадра, у второго — те самые 140 px и 34%, из осторожности зауженные во время вчерашней правки. На вид у второго подпись мельче и обрывистей. Это было настоящее расхождение, но не то, из-за которого жаловались.

Скачал реально опубликованный кадр по ссылке из лога (catbox) и открыл PIL.Image.open(...).size: 1080×1350, хотя рендерился он в 1080×1920. Разница — 570 px по высоте, и вырезаны они снизу — там, где сидит подпись. Кадр обрезался не при рисовании и не при публикации в Instagram, а между ними, в шаге, о существовании которого я на тот момент не подумал: у нас есть общая функция prepare_image(), которая приводит любую картинку под требования ленты Instagram (JPEG, соотношение сторон от 4:5 до 1,91:1) — и publish_story() дергала её точно так же, как обычный пост в ленту:

def publish_story(image_path: str) -> dict:
    """Stories: препроцессинг -> catbox -> media(media_type=STORIES,image_url) -> media_publish."""
    prepared = prepare_image(image_path)
    ...

Кадр 1080×1920 — это соотношение 0,5625. Функция считает его «слишком вертикальным» относительно ленточного диапазона 0,8–1,91, сжимает по ширине и обрезает высоту до 1080 / 0,8 = 1350. Формат Stories вообще не имеет такого ограничения — 9:16 для сторис нормален, — но prepare_image() об этом не знала, потому что вызывалась одинаково для обоих сценариев.

Что чинил не то

Обе правки от 02.09 и 05.09 (для первого симптома) были не ошибкой — они реально устранили реальную проблему с безопасной зоной экрана, и это подтверждено измерением, а не предположением. Ошибкой было решить, что раз жалоба похожа на прошлую («криво», «обрезано»), причина тоже похожая. Сверка констант между двумя копиями рендера отняла минут сорок и не могла ничего дать в принципе: обе копии рисовали кадр 1080×1920 корректно, кадр ломался позже, уже на публикации. Я потратил время на файл, который был ни при чём.

Фикс

Развёл поведение по типу контента: для Stories prepare_image() возвращает файл как есть, без ленточных ограничений, и просто логирует фактический размер для будущей проверки:

def prepare_image(local_path: str, story: bool = False) -> str:
    if story:
        try:
            proc = subprocess.run(
                [VENV_PYTHON, "-c",
                 "import sys;from PIL import Image;im=Image.open(sys.argv[1]);"
                 "print('%dx%d' % im.size)", local_path],
                capture_output=True, text=True, timeout=60)
            log.info(f"prepare_image(story): кадр {proc.stdout.strip()} отдаём как есть")
        except Exception as e:
            log.warning(f"prepare_image(story): размер не прочитан ({e})")
        return local_path
    # дальше — прежняя логика под ленту (4:5..1,91:1)
    ...

def publish_story(image_path: str) -> dict:
    prepared = prepare_image(image_path, story=True)
    ...

Заодно убрал причину, по которой две реализации вообще могли разъехаться: второй аккаунт лишился собственной копии рисовалки и стал вызывать ту же av_story.build, что и первый, отдельным процессом (Pillow стоит только в одном venv, а кириллица в аргументах командной строки бьётся — текст передаю файлом):

code = (
    "import sys;sys.path.insert(0, %r);import av_story;"
    "print(av_story.build(sys.argv[1], open(sys.argv[3], encoding='utf-8').read(),"
    " out=sys.argv[2]))" % AV_STORY_DIR
)
r = subprocess.run([VENV_PY, "-c", code, photo, out, txt], capture_output=True, timeout=120)

Раньше правка константы в одном месте не долетала до второго просто потому, что второе место физически не знало о существовании первого.

Числа до и после

Что мерили

До

После

Размер опубликованного кадра

1080×1350 (обрезан снизу на 570 px)

1080×1920, как отрендерен

Отступ текста от края

80 px (внутри зоны обрезки экрана 97–129 px)

140 px

Видимая ширина на экране 19,5:9

886/1080 px, текст у края съеден

886/1080 px, текст внутри видимой области

Высота блока подписи, доля кадра

34% (аккаунт 2) vs 42% (аккаунт 1) — разные

единая функция, одно значение

Реализаций рендера сторис

2 (расходились без предупреждения)

1

Бонус: подпись пропадала не только из-за обрезки

Пока разбирался, нашёл третью, независимую претензию — на светлых кадрах (столовая, снег, стройка) белый текст с одной тенью просто терялся в светлом фоне, притемнение снизу было фиксированной плотности и рассчитывалось под тёмные кадры. Померил яркость полосы под текстом (resize((32,16)), среднее по каналу L) и подбираю плотность подложки по трём порогам вместо одной константы, а тень заменил на обводку по кругу в шести направлениях — на пёстром фоне тень с одной стороны не спасает ни один угол буквы:

band = im.crop((0, max(0, y0 - 20), W, min(H, y0 + block + 20)))
lum = sum(band.convert('L').resize((32, 16)).getdata()) / (32 * 16)
peak = 215 if lum < 90 else (235 if lum < 150 else 250)

Эта правка не была нужна для основной обрезки, но лежала в той же ветке жалоб «сторис криво» — и её отдельная адресность лишний раз подтвердила, что под одной жалобой пользователя может скрываться две-три разных причины, и торопиться с диагнозом по прошлому опыту — плохая идея.

Комментарии (1)


  1. ChimsK
    07.09.2026 16:42

    The first fix being correct is what made the second one hard. You measured it, you verified it on two devices, so when the same complaint came back the padding was the one area you already cleared.

    Fixes that work is always a good reason not to look there again: which is really wrong when the second complaint has a different cause and the same words!

    40 minutes on the two render copies, and both were drawing 1080×1920 correctly the whole time.