У нас есть конвейер, который раз в день собирает кадр для 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)
Эта правка не была нужна для основной обрезки, но лежала в той же ветке жалоб «сторис криво» — и её отдельная адресность лишний раз подтвердила, что под одной жалобой пользователя может скрываться две-три разных причины, и торопиться с диагнозом по прошлому опыту — плохая идея.

