product-photos

Снимки товаров — где лежат, как привязываются к товарам, как готовятся к экрану прибора и как попадают на карточки. Использовать при работе с frontend/public/products, скриптами link_photos.py и shrin

SKILL.md

GitHub

Снимки товаров

Два источника, в базе хранится только имя файла (поле image):

  • frontend/public/products/ — демо-набор из репозитория; на экран попадает через сборку фронта (dist/products), поэтому новый файл здесь виден только после npm run build. Инструкция для оператора — docs/photos.md.
  • backend/data/photos/ (PHOTOS_DIR в config.py) — снимки, присланные на прибор извне: сейчас это выгрузка из 1С (services/photos.py). Лежат в данных, а не в сборке: сборка внутри docker-образа переписывается при каждом обновлении, а том data/ переживает пересоздание контейнера и переносится на новый прибор вместе с базой. Раньше файл писался в dist и public и пропадал вместе с контейнером.

Раздаёт оба маршрут /products/{name} в main.py: сначала data/photos, потом сборка. Имя файла — код товара, так что боевой снимок перекрывает демо-снимок того же кода, не трогая сборку и git. import_products.py при привязке смотрит в оба каталога.

Обложка группы по умолчанию — фото первого товара в ней. Своя картинка задаётся в админке («Товари» → «Обкладинки груп»): она сильнее автоматической, потому что снимок первого товара меняется с каждой выгрузкой из 1С, а выбранная оператором — нет. Хранится в таблице category_covers (имя группы → имя файла), сам файл кладётся в data/photos под именем group-<sha1(имя)[:10]>.<ext> — транслитерация украинских названий давала то нечитаемые, то совпадающие имена, а из цифр состоят снимки товаров. Загрузка идёт тем же путём, что снимки из 1С: base64 в JSON, PUT /api/products/categories/{name}/image, ужатие до 800 px; DELETE того же адреса возвращает автоматическую обложку и стирает файл. Ни tools/clean_db.py, ни чистка из админки такие файлы не трогают: обложку подбирают руками, а группа с тем же именем вернётся из 1С и подхватит её сама.

Привязка к товарам

Имя файла — код товара: 4038.jpg, 47751.webp. Так снимок находит свой товар без ручной работы в админке.

  • backend/tools/link_photos.py привязывает уже готовый набор к заведённым товарам: по коду, а если не вышло — по прежнему имени из поля image.
  • backend/tools/import_products.py делает то же самое при заливке каталога из выгрузки товароучёта: артикул становится кодом товара и им же ищется снимок.
  • Оба скрипта без --apply только показывают план.

Если у одного кода несколько файлов (4624.jpg и 4624.webp), берётся последний по сортировке — то есть .webp перебивает .jpg и .png. Лишние файлы, которым не нашлось товара, никто не удаляет: они просто перечисляются в конце отчёта.

Подготовка снимков

backend/tools/shrink_photos.py ужимает файлы до 800 px по длинной стороне (--max=, качество --quality=, по умолчанию 82). Карточка на экране прибора — около 300 px, а приходящие из товароучёта кадры бывают 2427x2427: такой файл распаковывается в 24 МБ пикселей ради миниатюры, и на J6412 это пропущенные кадры ровно тогда, когда покупатель листает каталог. На боевом наборе ужалось 41 файл из 437, каталог 24.1 -> 19.0 МБ.

Скрипт переписывает файлы на месте и сохраняет формат: имя записано у товара в поле image, менять расширение значило бы чинить ещё и базу. Поворот из EXIF применяется до ужатия — после его негде будет взять. Откат — через git checkout, снимки лежат в репозитории.

Загрузка на экран

loading="lazy" на карточках не используется. Что держать наготове, решает окно ленты (RIBBON_WINDOW, см. скилл kiosk-motion) — по номеру страницы, точно и заранее. Ленивая загрузка решает ту же задачу по расстоянию до вьюпорта и соседнюю страницу готовить отказывается: она стоит за 1920 px, а порог Chrome около 1250 px. Замер: из двенадцати снимков соседней страницы грузилось три, и свайп приезжал на серые плашки. Со снятым lazy — двенадцать из двенадцати.

  • Снимки декодируются вне основного потока: decoding="async".
  • Снимок касаний не ловит вовсе (pointer-events: none на .kiosk img в shared/styles/base.css, плюс draggable="false"). Палец, начавший свайп с фотографии, запускал нативное перетаскивание картинки: браузер забирал указатель себе и присылал pointercancel — лента не двигалась, хотя тот же свайп с промежутка между карточками работал. Карточка — кнопка, нажатие всё равно достаётся ей, поэтому снимку терять нечего.
  • Незагруженный снимок — не дыра: .photo имеет фон --s2l-soft, а размеры карточки задаёт сетка, поэтому появление картинки ничего не сдвигает и атрибуты width и height не нужны.
  • object-fit: cover — карточка кадрирует снимок, а не растягивает.
  • Вес одной страницы каталога — около 560 КБ, окна из пяти страниц — 2.2 МБ. Читается это с локального диска тем же процессом, что отдаёт фронт, так что дорого не чтение, а декодирование: за ним и следим.

Раздача

Файлы отдаёт общий обработчик киоска в backend/app/main.py — с ETag и Last-Modified, но без Cache-Control, поэтому браузер кэширует их по эвристике и ходит за 304. На приборе это копейки, но заголовок стоит проставить явно, если дело дойдёт до вылизывания старта.