chore(openspec): archive pano360-viewer and sync main specs

This commit is contained in:
iu
2026-10-11 17:41:54 +03:00
parent fc4233cd57
commit 9b7d81d8fc
6 changed files with 31 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,90 @@
# Design
## Context
См. proposal.md — Why. См. сценарии в specs/ui-workspace/spec.md. Опорные факты по коду:
- `Viewer.svelte` — единая область просмотра: `scroller` c `<img>` (плоский зум/панорамирование,
`Space`, колесо), топбар, лента миниатюр, контекстное меню, `f`/fullscreen. Используется и в
маршруте фото, и на публичной share-странице.
- Теги персональные (`tags.user_id`); `photo.tags` приходят и в списке, и в `/photos/:id`.
На share-страницах `GetPublic` читает фото с пустым пользователем → тегов нет.
- Варианты: `thumb` 200 и `preview` 1200 (webp); плюс `original_url` (подписанный, 1 час).
- В проекте нет runtime-зависимостей; статика вшивается в Go-бинарник.
## Goals / Non-Goals
**Goals:**
- 360-обзор для фото с личным тегом `pano360`, без сторонних библиотек.
- Сохранить плоский просмотр и все прочие функции Viewer без регрессий.
- Деградация до плоского изображения, когда 360 невозможен.
**Non-Goals:**
- Бэкенд, новый pano-вариант, автоопределение GPano/XMP.
- 360 на share-страницах и у других участников (теги персональные).
- Редактор панорам, горячие точки, стерео/VR.
## Decisions
### 1. Детект: личный тег `pano360`
`isPanoTag(photo)` — `photo.tags?.some(t => t.name.trim().toLowerCase() === 'pano360')`.
Источник — уже отдаваемые теги; бэкенд не трогаем. Следствие: на share и у других участников
режим не включается (осознанное ограничение, покрыто сценарием спеки).
### 2. Рендер: мини-WebGL equirect без зависимостей
Отдельный `PanoViewer.svelte`, владеющий `<canvas>`:
```
экран (uv) --inv(fov,yaw,pitch)--> луч dir --atan2/asin--> lon/lat --> equirect uv --> sample
```
- Вершинный шейдер: полноэкранный quad.
- Фрагментный: направление луча из `uv` + `uFov/uYaw/uPitch/uRes`; выборка текстуры equirect.
- WebGL1: NPOT-текстура → `CLAMP_TO_EDGE`/`LINEAR`, без мипмапов.
- `ResizeObserver` + `devicePixelRatio` для ресайза канвы.
Альтернативы: three.js (быстрее писать, но +~150 КБ gz и первая runtime-зависимость) и готовая
панорамная библиотека (чужая тема/UI). Отклонены в пользу 0 зависимостей.
### 3. Текстура: оригинал с даунскейлом, фолбэк preview
`original_url` загружается через `fetch` того же origin (подписанный URL может быть абсолютным на
хост API) → `blob` → `createImageBitmap`, чтобы пиксели были origin-clean (иначе `texImage2D`
бросает «cross-origin data»). Далее даунскейл до `min(4096, gl.MAX_TEXTURE_SIZE)` и `texImage2D`.
Если оригинал недоступен — `preview_url`. Новый pano-вариант не заводим (иначе бэкенд).
### 3a. Жизненный цикл WebGL
GL инициализируется один раз на элемент канвы; повторные запуски эффекта (в т.ч. из-за
`bind:this`) — no-op. Освобождение ресурсов (текстура/программа, `WEBGL_lose_context`) — только
при уничтожении компонента. Раньше `teardown()` вызывался на перезапуске эффекта, терял контекст,
и повторная инициализация компилировала шейдеры на «потерянном» контексте → ложный фолбэк.
### 4. Фолбэк на плоский `<img>`
`PanoViewer` вызывает `onFallback()` при отсутствии WebGL-контекста или сбое загрузки/декодирования;
`Viewer` тогда рендерит обычный `<img>` и плоские жесты.
### 5. Разделение ответственности и интеракции
- `Viewer` владеет топбаром, лентой, клавишами и контекстным меню.
- `PanoViewer` владеет канвой, указателем и колесом: drag/свайп — `yaw/pitch`, колесо — `fov`
(клэмп, напр. 30°..100°).
- `Space` в `Viewer` в 360-режиме вызывает `panoRef.reset()` (вместо плоского `toggleActual`).
- В 360-режиме плоский зум/панорамирование и `wheel`/`pointer`-обработчики scroller не активны;
свайп не листает фото (смена — стрелками и лентой); `f`/fullscreen и стрелки — как прежде.
- Компонент создаётся с `key={current.id}`, ресурсы освобождаются при смене фото/размонтировании.
### 6. Индикация
Признак «360» в топбаре при активном режиме; бейдж «360» на плитке сетки для фото с тегом
(теги есть в списочном ответе).
## Risks / Trade-offs
- [Оригинал больше лимита текстуры/много VRAM] → даунскейл до `min(4096, MAX_TEXTURE_SIZE)`.
- [WebGL недоступен] → `onFallback()` и плоский `<img>`.
- [Конфликт жестов с плоским режимом] → режим-специфичные обработчики; свайп в 360 только осмотр.
- [Утечки WebGL-контекста] → `deleteTexture/deleteProgram/loseContext` при размонтировании/смене фото.
- [Личный тег: 360 не виден другим/на share] → покрыто сценарием спеки как ожидаемое поведение.
- [Производительность на слабых GPU] → простая сцена (один quad), опционально ограничение DPR.
## Migration Plan
Только фронтенд; данных и API не трогаем. Откат — удаление нового режима. Проверка:
`cd frontend && npm run check` и `npm run build`, плюс панo-сценарии в CDP‑QA.
@@ -0,0 +1,39 @@
# Proposal
## Why
Пользователь загружает сферические 360-панорамы, но просмотрщик показывает их как обычное
плоское фото — искажённую equirect-развёртку. Нужен режим обзора панорамы по тегу `pano360`.
## What Changes
- **Детект по тегу.** Фото считается 360-панорамой, если среди его тегов есть `pano360`
(без учёта регистра и пробелов по краям). Теги персональные, поэтому на публичных
share-страницах и у других участников 360 не включается — фолбэк на плоский просмотр.
- **Мини-WebGL вьюер.** В просмотре появляется equirect-обзор на `<canvas>`: перетаскивание —
осмотр, колесо — угол обзора (FOV), `Space` — сброс вида. Стрелки и лента по-прежнему
переключают фото, `f` — полный экран. Без сторонних библиотек.
- **Источник изображения.** Грузится оригинал с даунскейлом до `min(4096, MAX_TEXTURE_SIZE)`;
при неудаче — `preview`. Новый вариант на бэкенде не заводится.
- **Фолбэк.** Нет WebGL или сбой загрузки/декодирования — показывается обычное плоское фото.
- **Индикация.** В топбаре просмотра виден признак «360»; на плитке сетки — бейдж «360».
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `ui-workspace`: добавляется требование «Просмотр 360-панорамы» (детект по тегу, сферический
обзор, управление, фолбэк, ограничение на share-страницах).
## Impact
- **Frontend:** новый `lib/pano.ts` (детект тега) и `lib/components/PanoViewer.svelte`
(WebGL equirect); правки `lib/components/Viewer.svelte` (переключение режима, клавиши,
фолбэк, индикатор) и `lib/components/PhotoGrid.svelte` (бейдж). Возможна новая иконка в
`lib/icons/registry.ts`.
- **Backend:** не затрагивается.
- **Совместимость:** поведение плоского просмотра сохраняется; 360 — дополнительный режим.
@@ -0,0 +1,34 @@
# Spec Delta
## ADDED Requirements
### Requirement: Просмотр 360-панорамы
Фото с тегом `pano360` SHALL открываться в режиме сферического обзора (equirect) вместо плоского
изображения. Перетаскивание SHALL менять направление взгляда, колесо — угол обзора, `Space` —
сбрасывать вид. Остальные фото SHALL просматриваться как прежде. При недоступности WebGL или
сбое загрузки/декодирования SHALL показываться плоское изображение. На публичных share-страницах
панорамный режим SHALL не активироваться.
#### Scenario: Обзор панорамы
- **WHEN** пользователь открывает фото с тегом `pano360`
- **THEN** показывается сферический обзор, и перетаскивание меняет направление взгляда
#### Scenario: Обычное фото
- **WHEN** пользователь открывает фото без тега `pano360`
- **THEN** показывается обычное плоское изображение
#### Scenario: Управление обзором
- **WHEN** в режиме панорамы пользователь вращает колесо или нажимает `Space`
- **THEN** колесо меняет угол обзора, а `Space` сбрасывает направление взгляда и угол обзора
#### Scenario: Навигация между фото
- **WHEN** в режиме панорамы пользователь нажимает стрелки влево/вправо или выбирает миниатюру
- **THEN** открывается соседнее фото, панорамный режим включается или выключается по его тегу
#### Scenario: Нет WebGL
- **WHEN** WebGL недоступен или изображение не загружается
- **THEN** показывается плоское изображение
#### Scenario: Публичная share-страница
- **WHEN** фото открыто по публичной share-ссылке
- **THEN** панорамный режим не активируется, показывается плоское изображение
@@ -0,0 +1,22 @@
# Tasks
## 1. Детект и 360-вьюер
- [x] 1.1 Добавить `lib/pano.ts` с `isPanoTag(photo)` (тег `pano360`, без учёта регистра и пробелов). Проверка: `npm run check` проходит; функция истинна для тега `pano360`/`PANO360` и ложна без него.
- [x] 1.2 Реализовать `lib/components/PanoViewer.svelte`: WebGL equirect (полноэкранный quad, луч из uv+fov+yaw/pitch, выборка equirect), текстура из `original_url` с даунскейлом до `min(4096, MAX_TEXTURE_SIZE)` и фолбэком `preview_url`, `ResizeObserver`+DPR, `reset()`, `onFallback()`. Проверка: на фото с тегом рисуется сцена; драг/колесо/сброс меняют вид; при отсутствии WebGL вызывается фолбэк.
- [x] 1.3 Освобождать ресурсы (текстура/программа/буфер, контекст) при размонтировании и смене `src`. Проверка: многократное переключение фото не копит контексты (по `gl.getError()`/поведению без падений).
## 2. Интеграция в Viewer
- [x] 2.1 В `Viewer.svelte` вычислять `pano = isPanoTag(current) || isPanoTag(fresh)` и рендерить `PanoViewer` (с `key={current.id}`) вместо плоского `<img>`; при фолбэке — `<img>`. Проверка: tagged фото → канвас, нетэгнутое → `<img>`.
- [x] 2.2 Развести интеракции: в 360 отключить плоский зум/панорамирование и скролл-жесты; `Space` → `panoRef.reset()`; drag/свайп — осмотр; колесо — FOV. Сохранить стрелки, ленту, `f`/fullscreen, контекстное меню. Проверка: смена фото стрелками/лентой в 360 работает; плоский режим не изменился.
- [x] 2.3 Добавить индикатор «360» в топбаре при активном режиме. Проверка: на панораме индикатор виден, на обычном фото — нет.
## 3. Сетка
- [x] 3.1 Добавить бейдж «360» на плитку `PhotoGrid` для фото с тегом `pano360`. Проверка: на плитке панорамного фото виден бейдж, на остальных — нет.
## 4. Проверка
- [x] 4.1 `cd frontend && npm run check` без ошибок и предупреждений; `npm run build` проходит.
- [x] 4.2 Расширить CDP‑QA панo-сценариями: тег `pano360` через API → канвас и индикатор «360»; без тега → `<img>`; drag меняет вид (`data-yaw`); смена фото сохраняет/снимает режим по тегу. Проверка: все проверки PASS.