product-photos
Снимки товаров — где лежат, как привязываются к товарам, как готовятся к экрану прибора и как попадают на карточки. Использовать при работе с frontend/public/products, скриптами link_photos.py и shrin
SKILL.md.claude/skills/product-photos/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. На приборе это копейки, но заголовок стоит проставить явно, если
дело дойдёт до вылизывания старта.