Дельта media-objects перенесена в основную спеку: копирование больше не
переносит отредактированную версию, копия показывается в исходном виде и
не наследует параметры правок. Change перемещён в архив за 2026-10-21.
Описание изменений с main (84ce4d1) до develop (afe85f7): локальный
режим на SQLite и файловой системе, слой объектов, редактирование фото,
новый интерфейс рабочего пространства, видео, Android-клиент, профиль,
офлайн, живое обновление, исправления и инструкция по обновлению.
Clone переносил все привязки photo_objects, включая роли edit*, хотя
правка принадлежит фото, чьи правки её породили. Теперь копия делит
только обычные варианты и показывается в исходном виде.
- EditRoles() и IsEditRole() в edit_render.go — единый список ролей;
- фильтр edit-ролей в Store.Clone и SQLiteStore.Clone;
- миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у
копий, созданных до фикса;
- контрактная проверка копирования, сервисные и e2e-тесты.
Копирование фото переносит и отредактированную версию, хотя правка
принадлежит фото, чьи правки её породили. Изменение описывает фильтр
edit-ролей в Clone, разовую чистку копий миграцией и дельту требования
«Совместные объекты».
Дополнены спеки ui-workspace (сворачивание кривой, живая гистограмма,
точки кривой, навигация в зуме, Space к центру), media-objects (роли edit*,
привязка отредактированной версии, отдельная очередь рендера правок),
photos (рендер по оригиналу) и dev-make-workflow (сборка WASM-модуля).
График тональной кривой с гистограммой занимает много места в узкой панели, поэтому
в инструменте «Свет» он изначально свёрнут и раскрывается по клику на заголовок.
Заголовок «Кривая» стал кнопкой со стрелкой: график с гистограммой и точками
сворачивается и разворачивается, кнопки выбора канала остаются видны всегда,
чтобы канал можно было переключить и в свёрнутом виде.
Гистограмма считалась по базовому превью один раз и не менялась при движении
ползунков и кривой. Теперь по уменьшенной копии фото накапливается сэмпл
пикселей, а сама гистограмма пересчитывается с применением текущих правок, так
что график реагирует на манипуляции сразу.
График тональной кривой теперь показывает гистограмму фото: выбранный канал —
своим цветом, мастер-канал (RGB) — всеми тремя слоями. Гистограмма считается
в браузере по уменьшенной копии базового превью и не влияет на скорость
перерисовки.
Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и
fill, но presentation-атрибуты не понимают var(), а переменные темы хранят
компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном
фоне графика кривую не было видно.
Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка
точек — hsl(var(--bg)).
Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который
линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для
чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным
данным (отсюда «каша» вместо плавного градиента).
Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только
применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной
JS-реализацией на маленьком образце и, если они расходятся, доверяет JS.
Service worker больше не кэширует .wasm: старый модуль в кэше ломал
предпросмотр даже после обновления сборки.
Прокручиваемый контейнер просмотра не имел min-w-0, поэтому в зуме он
раздувался до ширины картинки вместо того, чтобы прокручиваться: фото
нельзя было двигать влево-вправо. Та же правка применена к обёрткам просмотра.
Пробел теперь зумит к центру фото: 100% от оригинала с центрированием
изображения в просмотре.
Механика зума переписана:
- Пробел зумит фото до 100% от оригинала (и обратно к вписанному виду).
- Колесо мыши зумит к курсору: точка под указателем остаётся на месте при
любом изменении масштаба.
- Стрелки влево/вправо переключают фото и в зуме — панорамирование осталось
за мышью.
- Масштаб и позиция считаются от размеров оригинала, а не от текущей рамки,
поэтому зум не «уезжает» и картинка корректно центрируется.
Клиентский предпросмотр был включён только в зуме, поэтому в обычном виде
просмотра фото оставалось без правок. Теперь холст рисует ту часть картинки,
что попала во вьюпорт, в любом режиме, а под ним лежит неотредактированная
версия фото — так правки не применяются дважды.
Новый алгоритм редактирования: обработка идёт только на клиенте.
- Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ):
он строит тональные кривые и обрабатывает пиксели. При недоступности модуля
используется JS-реализация с тем же результатом — расхождение проверяется
тестом.
- Просмотр рисует видимую область фото с правками на canvas поверх
исходной версии, поэтому изменения видны сразу, без ожидания рендера.
- Правки уходят на сервер не сразу: через десять секунд без изменений или при
переключении фото. Сервер сохраняет параметры и готовит варианты сетки и
полноразмерную версию в фоне, о готовности сообщает через SSE — клиент
подтягивает готовую версию.
Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера
только видимую область кадра: координаты относительные, поэтому запрос не
зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с
для всей картинки, а полноразмерная версия больше не готовится вовсе.
Зум больше не сбрасывается, когда приходит отрендеренная версия правок:
состояние просмотра сбрасывается только при смене самого фото.
Расчёт правок считал math.Pow для каждого канала каждого пикселя, из-за чего
большое фото обрабатывалось больше секунды. Факторы правок теперь считаются
один раз на рендер, квадраты считаются умножением, а строки обрабатываются
параллельно.
Обработка кадра 4000×3000 сократилась с 1.23 с до 0.14 с. Задержка от
сохранения правок до появления отредактированной версии в сетке и просмотре:
около 0.15 с для небольшого фото и 0.6 с для большого (полноразмерная
версия догружается следом, через 1.4 с).
Вкладка «Редактирование» больше не переключается на «Сведения» при
обновлении того же фото: временное состояние панели сбрасывается только при
смене фото.
Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу
после постановки задачи, а уведомление о готовых вариантах приходит до
кодирования полноразмерной версии. Задержка между сохранением правок и
появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.
Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
Добавлен service worker: оболочка приложения, ответы API и варианты
изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера.
Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи
не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру
и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней
панели появляется индикатор «Офлайн».
Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
Кнопке «На весь экран» в верхней панели добавлен хоткей Ctrl+F. Хоткей `f`
полноэкранного просмотра больше не срабатывает на Ctrl+F: модификаторы
проверяются явно, поэтому сочетание свободно для кнопки каркаса.
При переходе стрелками влево/вправо активная миниатюра нижней ленты уходила
за край и оставалась невидимой. Теперь лента прокручивается к текущему фото,
удерживая его плитку в поле зрения.
В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют
масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт
просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает
число колонок так, чтобы размер миниатюры остался прежним, а в строке
помещалось больше фото. Каркас публикует ширину, которую занимают панели в
текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка
использует её для пересчёта, а навигация курсора стрелками учитывает
реальное число колонок.
В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых
панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме
панели не занимают место в раскладке, а появляются по наведению на левую или
правую кромку окна и прячутся после ухода указателя.
Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей
работает на всех экранах приложения и не срабатывает при вводе текста, в
открытых диалогах и меню.
Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах
изменения размера миниатюр.
В спеку phone-album-sync добавлены требования о роли локального файла,
подтверждении новой версии до удаления устаревшей и разгрузке без
незавершённой загрузки; уточнены синхронизация изменений, провенанс и
возврат оригиналов.
- роль локального файла определяется меткой провенанса, а не состоянием
индекса: собственное превью выправляет запись (MarkPreview) вместо
повторной загрузки, постер видео связывается с самим видео;
- изменённое содержимое заменяет серверную копию только после сверки
original_checksum (ReplaceUpload): прежняя копия удаляется мягко, а при
расхождении принятая загрузка отбрасывается и элемент не разгружается;
- файл не разгружается, пока по нему есть незавершённая загрузка;
- восстановленный оригинал помечается UPLOADED, чтобы следующий проход не
разгрузил его снова;
- миграция 8→9 добавляет upload_jobs.replacesPhotoId.
Ответ на загрузку и публикуемое событие теперь несут хеш сохранённого
объекта, чтобы клиент синхронизации мог сверить принятые байты со своим
файлом и только после этого удалять прежнюю версию элемента.
Изменение описывает три дефекта двусторонней синхронизации: повторную
загрузку собственного превью, удаление серверной копии до подтверждения
замены и повторную разгрузку только что восстановленного оригинала.
Новое изменение: двусторонний миррор альбома устройства и серверного альбома
(материализация серверных фото, признак происхождения, двусторонние удаления с
явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной
спеки phone-album-sync обновлён под двустороннюю синхронизацию.
Change проходит `openspec validate --strict`.
Связанная пара «альбом устройства → серверный альбом» становится миррором:
серверные фото материализуются на устройстве (оригинал в режиме копий, превью/
постер в переносе), состав меняется в обе стороны.
- Признак происхождения (OWN/MIRROR) в индексе и метке провенанса; БД v8.
- Материализация серверных фото (MirrorManager) с датой съёмки и меткой.
- Дедуп, материализация ровно в один бакет при n:1, baseline «только новое»/«всё».
- Двустороннее удаление с учётом прав; для зеркал намерением считается только
явное удаление в приложении (надгробие), пропавшая локальная копия
материализуется заново — иначе терялись серверные оригиналы.
- Неудачное чтение серверного списка не считается пустым альбомом.
- Устойчивое выключение синхронизации (syncDisabledAt): восстановление
происхождения не возвращает связь.
- Очередь загрузки — одно дренирующее задание вместо задания на файл
(JobScheduler переставал запускать остаток на больших альбомах).
- Дебаунс наблюдателя MediaStore и кэш sha256 по (size, dateModified).
Покрыто unit-тестами и instrumented-тестами (режимы A/B/C и переключения,
двусторонний миррор, объём 100 фото + 20 видео на тест).
Дельта перенесена в основные спеки: android-client («Прогресс восстановления
оригиналов») и phone-album-sync («Ход и устойчивость восстановления оригиналов»).
Изменение перемещено в openspec/changes/archive/2026-10-16-android-album-restore-progress.
Возврат из переноса в копии скачивает оригиналы с сервера, но ход нигде не
отражался: карточка умела показывать только загрузку, а восстановление шло в
области экрана и обрывалось при уходе с него.
Добавлен RestoreCoordinator: выполняет восстановление в области приложения
(уход с экрана больше не прерывает), ведёт ход «альбом -> восстановлено из
всего» и защищает от одновременного запуска по одному альбому. RestoreManager
сообщает ход по каждому элементу и вынесен за интерфейс AlbumRestorer; SyncCheck
ходит через координатора и, как и раньше, дожидается завершения.
Карточка альбома показывает «Копии — восстановление N%» (пульсирует), экран
альбома устройства — строку «Восстановление оригиналов: X из Y» с полосой.
Изменение OpenSpec: android-album-restore-progress.
Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.
Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.
Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
Unit-тесты планировщика, координатора, политики и восстановления, Compose-
тесты маркера и шита, instrumented-проверка удаления превью с устройства и
E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен
эмулятор и сервер на 10.0.2.2:8088.
В режиме переноса, если серверное фото исчезает из целевого альбома,
разгруженное превью помечается состоянием ORPHANED: в сетке показывается
перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся
оригинал заливается заново. Детекция — в reconcile по живым фото альбома,
возврат серверного фото снимает пометку.
Правила работы в репозитории: команды, архитектура, миграции, конфигурация,
фронтенд, OpenSpec и конвенции коммитов. Отдельный раздел про прогоны с
эмулятором: instrumented-тесты и запуск AVD могут зависнуть, прогресс нужно
проверять примерно раз в минуту, а не ждать молча.
Выбор порядка раньше действовал только на серверные альбомы: карточки устройства
были закреплены сверху и не сортировались, а направления не было вовсе.
- порядок применяется ко всему списку: карточки устройства и серверные
упорядочиваются вместе единым компаратором
- альбом устройства получает дату последнего изменения (самый свежий элемент,
как при выборе обложки), поэтому «по времени изменения» осмыслен и для него
- обратный порядок инвертирует выбранный ключ; карточка без даты остаётся
последней в любом направлении
- пара (ключ, направление) хранится в DataStore за узким портом
AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок»
- README: раздел Album sorting обновлён
Цель run зависела только от backend, а статика фронтенда попадает в бинарник
через go:embed из backend/static, который обновляла лишь цель frontend. Поэтому
make run пересобирал Go-код со старым UI.
- run теперь зависит от frontend backend;
- frontend стала инкрементальной: npm ci только при изменении package.json/
package-lock.json, npm run build и копирование в backend/static — только когда
исходники или конфиги новее, чем backend/static/index.html (он же stamp);
- проверки вынесены в shell-recipe, т.к. имена SvelteKit-роутов вида (app)
ломают разбор имён файлов в make.
Тестовая активность Compose (androidx.activity.ComponentActivity) объявляется
зависимостью ui-test-manifest, подключённой как debugImplementation, поэтому в
release-манифесте её нет и createComposeRule падает на testReleaseUnitTest.
Источники тестов вариант-агностичны, поэтому прогон один раз на debug даёт то
же покрытие и не тащит тестовую активность в production-манифест.
LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник
регистрируется на устройстве, владелец создаёт альбом и приглашает его,
альбом появляется в списке без обновления, переименование меняет открытый
экран, исключение убирает альбом и возвращает экран к списку.
- E2E: хелперы invite/accept/rename/remove и получение id пользователя