feat(photos): full EXIF metadata extraction, backfill and info panel
- backend: switch EXIF parser to dsoprea/go-exif/v3 (all recognised tags, numeric normalization); store in photos.exif jsonb (migration 000003); add storage.Get and cmd/backfill-exif to backfill existing photos from S3 originals - frontend: info panel renders all EXIF attributes with RU labels, enum translation and units (1/N, f/N, mm, EV); service tags hidden - viewer: remove make-cover action (cover managed from panel only), icon-only download - openspec: sync lightroom-workspace-ui artifacts (EXIF, cover-from-panel) and add plan for configurable info attributes/labels stored in user profile
This commit is contained in:
@@ -24,12 +24,15 @@
|
||||
- Тёмная графитовая тема по умолчанию при сохранении остальных тем.
|
||||
- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей.
|
||||
- Адаптивность до мобильного без потери каркаса.
|
||||
- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи),
|
||||
хранимые в профиле.
|
||||
|
||||
**Non-Goals:**
|
||||
- Мультивыбор фото и массовые действия / bulk-удаление.
|
||||
- Замена роутинга на «чистые панели» без маршрутов (вариант P).
|
||||
- Новые внешние зависимости (иконки и токены вендорятся/расширяются).
|
||||
- Изменение доменных контрактов API (только добавление поля ответа и FK).
|
||||
- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя.
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -59,10 +62,10 @@ ON DELETE SET NULL`. БД сама чинит любой путь удалени
|
||||
безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже.
|
||||
Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления).
|
||||
|
||||
### 5. Точки выбора обложки: панель + просмотр (T1+T2)
|
||||
### 5. Точка выбора обложки: правая панель (T1)
|
||||
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
|
||||
в просмотре — действие «Сделать обложкой» для текущего фото. Всё под `roles.CanEditAlbum`.
|
||||
Hover-оверлей в сетке (T3) не берём — шум и случайные клики.
|
||||
всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей
|
||||
в сетке (T3) не используются — шум и случайные клики.
|
||||
|
||||
### 6. Тема: сохранить темы, dark = графит и дефолт (α)
|
||||
Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию
|
||||
@@ -101,11 +104,16 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
|
||||
### 13. Просмотр: полный экран и зум
|
||||
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
||||
`fullscreenchange`. Зум — локальные `zoomed`/`scale`: колесо меняет масштаб с привязкой к
|
||||
курсору; `Space` переключает 100% (1:1); при увеличении изображение панорамируется
|
||||
перетаскиванием и скроллбарами. Хоткеи игнорируются при фокусе в поле ввода. Зум
|
||||
сбрасывается при смене фото. Альтернатива — только `object-fit` без ручного масштаба —
|
||||
не даёт 1:1 и панорамирования.
|
||||
`fullscreenchange`. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
||||
скрыты и всплывают у соответствующих кромок (панели — по касанию края, скрытие ~1 с после
|
||||
ухода; стрелки — при курсоре в 1/6 левой/правой области, скрытие ~300 мс; в зуме стрелки
|
||||
скрыты полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
||||
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
||||
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
||||
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
||||
перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при
|
||||
смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и
|
||||
панорамирования.
|
||||
|
||||
### 14. Панели: ресайз и сворачиваемые разделы
|
||||
Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя
|
||||
@@ -124,6 +132,33 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
|
||||
обновляет запись в списке.
|
||||
|
||||
### 17. Настройки информации: оверрайды в профиле пользователя
|
||||
Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список:
|
||||
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
|
||||
`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа
|
||||
означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не
|
||||
«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с
|
||||
пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной
|
||||
валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей).
|
||||
Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под
|
||||
шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога
|
||||
подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые
|
||||
атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото).
|
||||
Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и
|
||||
хранение на клиенте (не переносится между устройствами) — отклонены.
|
||||
|
||||
### 18. Метаданные фото: устойчивый парсер и хранение в jsonb
|
||||
EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`)
|
||||
как плоская карта «тег → значение» всех распознаваемых библиотекой тегов. Парсер —
|
||||
`github.com/dsoprea/go-exif/v3`: он переживает нестандартные и битые блоки EXIF, на которых
|
||||
падал прежний `rwcarlsen/goexif` (реальные снимки HUAWEI давали zero-length sub-IFD и пустой
|
||||
результат). Числовые значения нормализуются (дроби → число); дата съёмки и GPS дополнительно
|
||||
пишутся в колонки `taken_at`, `latitude`/`longitude`. На клиенте теги показываются с русскими
|
||||
подписями и переводом enum. Для ранее загруженных фото предусмотрена разовая команда
|
||||
`cmd/backfill-exif`, которая тянет оригиналы из S3 (`storage.Get`) и заполняет
|
||||
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
||||
пользователю нужны все атрибуты.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||
@@ -147,19 +182,29 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
- [Крупные файлы утяжеляют память] → лимит 100 МБ и чтение через `LimitReader`; превышение
|
||||
даёт понятную 413.
|
||||
- [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном.
|
||||
- [Настройки в профиле: рост jsonb и приватность] → `preferences` не входят в `PublicUser`;
|
||||
серверная валидация ограничивает структуру, длину подписей и число скрытых ключей.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE
|
||||
cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем
|
||||
`ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`.
|
||||
2. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
|
||||
2. Миграция `000003_photo_exif`: `ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
3. Миграция `000004_user_preferences`: `ALTER TABLE users ADD COLUMN preferences jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
4. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
|
||||
(LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только
|
||||
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`).
|
||||
3. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
|
||||
разделами и EXIF, модалки, `Viewer` с зумом), стор альбомов, токены и скроллбары, `types.ts`.
|
||||
4. Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа
|
||||
игнорируется старым клиентом.
|
||||
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`; извлечение EXIF в
|
||||
`photos.exif` и команда `cmd/backfill-exif`), `models.User` (+`Preferences`), `auth`
|
||||
(`GetUserByID` c `preferences`, `Me` отдаёт настройки, `PATCH /auth/me/preferences` с
|
||||
валидацией).
|
||||
5. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
|
||||
разделами и EXIF, модалки, `Viewer` с зумом), слот действий и модалка настроек атрибутов,
|
||||
стор настроек, `types.ts`.
|
||||
6. Откат: снять constraint и колонки `photos.exif`/`users.preferences` (down-миграции);
|
||||
фронтенд откатывается независимо, поля ответа игнорируются старым клиентом.
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user