21 Commits
Author SHA1 Message Date
iu 4cc5c0f340 docs(release): версия 0.2.0
Релиз Galo 0.2.0: инструмент «Цвет», пресеты тоновой кривой, мерж настроек профиля и исправления переключения фото.
2026-10-23 23:09:06 +03:00
iu a481375783 docs: описание релиза Galo за 23 октября 2026
Версия 0.2-alpha: инструмент «Цвет», пресеты тоновой кривой, мерж настроек профиля и исправления переключения фото.
2026-10-23 22:59:09 +03:00
iu 9e39e3d84c chore(openspec): архивировать photo-curve-presets и обновить photos, auth 2026-10-23 22:57:23 +03:00
iu 0bfee5f2f8 feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
2026-10-23 22:56:55 +03:00
iu 3b8406fb4c chore(openspec): архивировать photo-color-editing и обновить photos
Дельта-спека, дизайн, предложение и задачи приведены к фактической реализации:
WebGL2-предпросмотр, пипетка баланса белого, цветокоррекция по зонам.
Изменение применено к основной спеке photos и перемещено в archive.
2026-10-23 22:01:53 +03:00
iu 502f2766d7 docs(openspec): изменение photo-color-editing
Предложение, дельта-спека photos, дизайн и задачи для инструмента «Цвет».
2026-10-23 21:57:51 +03:00
iu d791b9a572 feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность,
  цветовой микшер, колористика и поканальная коррекция
- серверный рендер цвета в Go и паритетная реализация в WASM
- живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк
- пипетка баланса белого по нейтральной точке
- новые контролы: слайдеры с градиентами, цветовые колёса,
  двойной клик по контролу сбрасывает значение
- тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
2026-10-23 21:57:48 +03:00
iu c886c6f22c chore(openspec): архивировать fix-copy-edit-variant-sharing и синхронизировать спеки
Дельта media-objects перенесена в основную спеку: копирование больше не
переносит отредактированную версию, копия показывается в исходном виде и
не наследует параметры правок. Change перемещён в архив за 2026-10-21.
2026-10-21 21:33:43 +03:00
iu 723b19bbe5 docs: описание релиза Galo за 21 октября 2026
Описание изменений с main (84ce4d1) до develop (afe85f7): локальный
режим на SQLite и файловой системе, слой объектов, редактирование фото,
новый интерфейс рабочего пространства, видео, Android-клиент, профиль,
офлайн, живое обновление, исправления и инструкция по обновлению.
2026-10-21 21:15:59 +03:00
iu afe85f7be0 docs: отчёт об изменениях — раздел по реализации fix-copy-edit-variant-sharing 2026-10-21 21:01:33 +03:00
iu 70ac12db10 chore(openspec): отметить задачи fix-copy-edit-variant-sharing выполненными
Все 9 задач закрыты; в design.md уточнено, что удалённые привязки
не осиротевают объекты: отредактированная версия живёт вместе с фото.
2026-10-21 21:01:33 +03:00
iu 8dd8cb6f0c fix(photos): копия фото не наследует отредактированную версию
Clone переносил все привязки photo_objects, включая роли edit*, хотя
правка принадлежит фото, чьи правки её породили. Теперь копия делит
только обычные варианты и показывается в исходном виде.

- EditRoles() и IsEditRole() в edit_render.go — единый список ролей;
- фильтр edit-ролей в Store.Clone и SQLiteStore.Clone;
- миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у
  копий, созданных до фикса;
- контрактная проверка копирования, сервисные и e2e-тесты.
2026-10-21 21:01:33 +03:00
iu 759e78ff79 docs: отчёт об изменениях, сделанных без спецификаций
Разбор 21 коммита диапазона 45a953f..eb2d478: что было описано,
что осталось без спек и почему, найденное расхождение по копированию фото
с правками.
2026-10-21 20:31:39 +03:00
iu b075f4c3a4 chore(openspec): предложить fix-copy-edit-variant-sharing
Копирование фото переносит и отредактированную версию, хотя правка
принадлежит фото, чьи правки её породили. Изменение описывает фильтр
edit-ролей в Clone, разовую чистку копий миграцией и дельту требования
«Совместные объекты».
2026-10-21 20:31:39 +03:00
iu 748143c9d7 docs(openspec): описать правки редактора, сделанные без спецификаций
Дополнены спеки ui-workspace (сворачивание кривой, живая гистограмма,
точки кривой, навигация в зуме, Space к центру), media-objects (роли edit*,
привязка отредактированной версии, отдельная очередь рендера правок),
photos (рендер по оригиналу) и dev-make-workflow (сборка WASM-модуля).
2026-10-21 20:21:59 +03:00
iu eb2d478275 feat(web): блок «Кривая» свёрнут по умолчанию
График тональной кривой с гистограммой занимает много места в узкой панели, поэтому
в инструменте «Свет» он изначально свёрнут и раскрывается по клику на заголовок.
2026-10-21 20:02:34 +03:00
iu 332f35b553 feat(web): сворачивание блока «Кривая» в инструменте «Свет»
Заголовок «Кривая» стал кнопкой со стрелкой: график с гистограммой и точками
сворачивается и разворачивается, кнопки выбора канала остаются видны всегда,
чтобы канал можно было переключить и в свёрнутом виде.
2026-10-21 20:00:30 +03:00
iu 48ed0bb775 fix(web): гистограмма пересчитывается при правках
Гистограмма считалась по базовому превью один раз и не менялась при движении
ползунков и кривой. Теперь по уменьшенной копии фото накапливается сэмпл
пикселей, а сама гистограмма пересчитывается с применением текущих правок, так
что график реагирует на манипуляции сразу.
2026-10-21 01:32:14 +03:00
iu b64574b921 feat(web): гистограммы каналов в редакторе кривой
График тональной кривой теперь показывает гистограмму фото: выбранный канал —
своим цветом, мастер-канал (RGB) — всеми тремя слоями. Гистограмма считается
в браузере по уменьшенной копии базового превью и не влияет на скорость
перерисовки.
2026-10-21 01:29:35 +03:00
iu d904b2fe31 fix(web): линия тональной кривой была не видна
Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и
fill, но presentation-атрибуты не понимают var(), а переменные темы хранят
компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном
фоне графика кривую не было видно.

Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка
точек — hsl(var(--bg)).
2026-10-21 01:24:40 +03:00
iu 2668d87347 fix(web): искажение цветов в предпросмотре правок
Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который
линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для
чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным
данным (отсюда «каша» вместо плавного градиента).

Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только
применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной
JS-реализацией на маленьком образце и, если они расходятся, доверяет JS.

Service worker больше не кэширует .wasm: старый модуль в кэше ломал
предпросмотр даже после обновления сборки.
2026-10-21 01:19:45 +03:00
62 changed files with 7437 additions and 430 deletions
+103 -6
View File
@@ -4,7 +4,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -181,6 +184,9 @@ const (
maxLabelEntries = 500 maxLabelEntries = 500
maxPreferenceKeyLen = 128 maxPreferenceKeyLen = 128
maxPreferenceValueLen = 128 maxPreferenceValueLen = 128
maxCurvePresets = 100
maxCurvePresetNameLen = 64
maxCurvePresetPoints = 16
) )
type photoInfoPreferences struct { type photoInfoPreferences struct {
@@ -188,15 +194,41 @@ type photoInfoPreferences struct {
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty"`
} }
type curvePresetPoint struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type curvePresetChannels struct {
RGB []curvePresetPoint `json:"rgb"`
Red []curvePresetPoint `json:"red"`
Green []curvePresetPoint `json:"green"`
Blue []curvePresetPoint `json:"blue"`
}
type curvePreset struct {
Name string `json:"name"`
Channels curvePresetChannels `json:"channels"`
}
type preferencesPayload struct { type preferencesPayload struct {
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"` PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
CurvePresets *[]curvePreset `json:"curve_presets,omitempty"`
}
// normalize trims preset names before they are validated and stored.
func (p *preferencesPayload) normalize() {
if p.CurvePresets == nil {
return
}
for i := range *p.CurvePresets {
(*p.CurvePresets)[i].Name = strings.TrimSpace((*p.CurvePresets)[i].Name)
}
} }
func (p preferencesPayload) validate() error { func (p preferencesPayload) validate() error {
pi := p.PhotoInfo pi := p.PhotoInfo
if pi == nil { if pi != nil {
return nil
}
if len(pi.Visible) > maxVisibleAttrs { if len(pi.Visible) > maxVisibleAttrs {
return fmt.Errorf("too many visible attributes") return fmt.Errorf("too many visible attributes")
} }
@@ -216,6 +248,49 @@ func (p preferencesPayload) validate() error {
return fmt.Errorf("label value too long") return fmt.Errorf("label value too long")
} }
} }
}
if p.CurvePresets != nil {
return validateCurvePresets(*p.CurvePresets)
}
return nil
}
func validateCurvePresets(presets []curvePreset) error {
if len(presets) > maxCurvePresets {
return fmt.Errorf("too many curve presets")
}
names := make(map[string]struct{}, len(presets))
for _, preset := range presets {
if preset.Name == "" || utf8.RuneCountInString(preset.Name) > maxCurvePresetNameLen {
return fmt.Errorf("invalid curve preset name")
}
channels := []struct {
name string
points []curvePresetPoint
}{
{"rgb", preset.Channels.RGB},
{"red", preset.Channels.Red},
{"green", preset.Channels.Green},
{"blue", preset.Channels.Blue},
}
for _, channel := range channels {
if len(channel.points) < 2 || len(channel.points) > maxCurvePresetPoints {
return fmt.Errorf("invalid curve preset points for %s", channel.name)
}
for _, pt := range channel.points {
if math.IsNaN(pt.X) || math.IsNaN(pt.Y) || math.IsInf(pt.X, 0) || math.IsInf(pt.Y, 0) {
return fmt.Errorf("invalid curve preset point")
}
if pt.X < 0 || pt.X > 1 || pt.Y < 0 || pt.Y > 1 {
return fmt.Errorf("invalid curve preset point")
}
}
}
if _, dup := names[preset.Name]; dup {
return fmt.Errorf("duplicate curve preset name")
}
names[preset.Name] = struct{}{}
}
return nil return nil
} }
@@ -230,18 +305,40 @@ func (h *Handler) UpdatePreferences(c *gin.Context) {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object")) httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
return return
} }
payload.normalize()
if err := payload.validate(); err != nil { if err := payload.validate(); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error())) httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
return return
} }
raw, err := json.Marshal(payload)
ctx := c.Request.Context()
u, err := h.svc.store.GetUserByID(ctx, UserID(c))
if err != nil {
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return
}
// Merge the submitted sections into the stored blob so saving one section
// never clears the others. Unknown keys are preserved.
merged := make(map[string]any, len(u.Preferences)+2)
for k, v := range u.Preferences {
merged[k] = v
}
if payload.PhotoInfo != nil {
merged["photo_info"] = payload.PhotoInfo
}
if payload.CurvePresets != nil {
merged["curve_presets"] = *payload.CurvePresets
}
if payload.PhotoInfo != nil || payload.CurvePresets != nil {
raw, err := json.Marshal(merged)
if err != nil { if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences")) httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
return return
} }
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil { if err := h.svc.store.UpdatePreferences(ctx, UserID(c), raw); err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{"preferences": payload}) }
c.JSON(http.StatusOK, gin.H{"preferences": merged})
} }
+214
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -30,6 +31,7 @@ func newTestRouter(t *testing.T) *gin.Engine {
authed.PATCH("/auth/me", h.UpdateMe) authed.PATCH("/auth/me", h.UpdateMe)
authed.POST("/auth/me/password", h.ChangePassword) authed.POST("/auth/me/password", h.ChangePassword)
authed.POST("/auth/me/identifiers", h.AddIdentifier) authed.POST("/auth/me/identifiers", h.AddIdentifier)
authed.PATCH("/auth/me/preferences", h.UpdatePreferences)
authed.DELETE("/auth/me", h.DeleteMe) authed.DELETE("/auth/me", h.DeleteMe)
return r return r
} }
@@ -213,3 +215,215 @@ func TestDeleteMeHandler(t *testing.T) {
t.Fatalf("me after delete status = %d", rec.Code) t.Fatalf("me after delete status = %d", rec.Code)
} }
} }
func curvePresetPayloadWith(name, channel string, points []map[string]float64) map[string]any {
linear := func() []map[string]float64 {
return []map[string]float64{
{"x": 0, "y": 0},
{"x": 1, "y": 1},
}
}
channels := map[string]any{
"rgb": linear(),
"red": linear(),
"green": linear(),
"blue": linear(),
}
channels[channel] = points
return map[string]any{"name": name, "channels": channels}
}
func curvePresetPayload(name string) map[string]any {
return curvePresetPayloadWith(name, "rgb", []map[string]float64{
{"x": 0, "y": 0},
{"x": 1, "y": 0.8},
})
}
func decodePreferences(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode preferences %s: %v", rec.Body.String(), err)
}
return body.Preferences
}
func TestUpdatePreferencesMergesSections(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "prefs@test.local", "password": "password123", "name": "Prefs",
})
token := decodeAuth(t, reg).Tokens.AccessToken
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"photo_info": map[string]any{
"visible": []string{"camera"},
"labels": map[string]string{"camera": "Камера"},
},
})
if rec.Code != http.StatusOK {
t.Fatalf("save photo_info status = %d body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload("Мой")},
})
if rec.Code != http.StatusOK {
t.Fatalf("save presets status = %d body = %s", rec.Code, rec.Body.String())
}
prefs := decodePreferences(t, rec)
if _, ok := prefs["photo_info"]; !ok {
t.Fatalf("photo_info lost after saving presets: %+v", prefs)
}
presets, ok := prefs["curve_presets"].([]any)
if !ok || len(presets) != 1 {
t.Fatalf("presets = %#v, want one", prefs["curve_presets"])
}
// Saving photo_info again must keep the presets.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"photo_info": map[string]any{"visible": []string{"iso"}},
})
prefs = decodePreferences(t, rec)
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 1 {
t.Fatalf("presets lost after saving photo_info: %+v", prefs)
}
info, _ := prefs["photo_info"].(map[string]any)
if visible, _ := info["visible"].([]any); len(visible) != 1 || visible[0] != "iso" {
t.Fatalf("photo_info not updated: %+v", prefs["photo_info"])
}
// The merged state is what GET /auth/me returns.
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
if _, ok := me.Preferences["photo_info"]; !ok {
t.Fatalf("me lost photo_info: %+v", me.Preferences)
}
if _, ok := me.Preferences["curve_presets"]; !ok {
t.Fatalf("me lost curve_presets: %+v", me.Preferences)
}
// An empty preset list clears only presets.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{},
})
prefs = decodePreferences(t, rec)
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 0 {
t.Fatalf("presets not cleared: %+v", prefs["curve_presets"])
}
if _, ok := prefs["photo_info"]; !ok {
t.Fatalf("photo_info lost after clearing presets: %+v", prefs)
}
// Names are stored without surrounding whitespace.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload(" Мой ")},
})
prefs = decodePreferences(t, rec)
presets = prefs["curve_presets"].([]any)
first, _ := presets[0].(map[string]any)
if first["name"] != "Мой" {
t.Fatalf("preset name = %#v, want trimmed", first["name"])
}
}
func TestPreferencesArePrivate(t *testing.T) {
r := newTestRouter(t)
first := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "private1@test.local", "password": "password123", "name": "One",
}))
second := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "private2@test.local", "password": "password123", "name": "Two",
}))
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", first.Tokens.AccessToken, map[string]any{
"curve_presets": []any{curvePresetPayload("Секрет")},
})
if rec.Code != http.StatusOK {
t.Fatalf("save status = %d body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(r, http.MethodGet, "/auth/me", second.Tokens.AccessToken, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
if _, ok := me.Preferences["curve_presets"]; ok {
t.Fatalf("other user sees presets: %+v", me.Preferences)
}
}
func TestUpdatePreferencesRejectsInvalidCurvePresets(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "invalidprefs@test.local", "password": "password123", "name": "Invalid",
})
token := decodeAuth(t, reg).Tokens.AccessToken
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload("Хороший")},
})
if rec.Code != http.StatusOK {
t.Fatalf("seed status = %d body = %s", rec.Code, rec.Body.String())
}
manyPoints := make([]map[string]float64, 17)
for i := range manyPoints {
manyPoints[i] = map[string]float64{"x": float64(i) / 16, "y": float64(i) / 16}
}
tooMany := make([]any, 101)
for i := range tooMany {
tooMany[i] = curvePresetPayload("preset-" + string(rune('a'+i%26)) + strings.Repeat("x", i/26))
}
missingChannel := curvePresetPayload("bad")
delete(missingChannel["channels"].(map[string]any), "blue")
cases := []struct {
name string
presets []any
}{
{"too long name", []any{curvePresetPayload(strings.Repeat("a", 65))}},
{"missing channel", []any{missingChannel}},
{"one point", []any{curvePresetPayloadWith("one", "red", []map[string]float64{
{"x": 0, "y": 0},
})}},
{"too many points", []any{curvePresetPayloadWith("many", "rgb", manyPoints)}},
{"x out of range", []any{curvePresetPayloadWith("range", "blue", []map[string]float64{
{"x": 1.1, "y": 0},
{"x": 1, "y": 1},
})}},
{"duplicate name", []any{curvePresetPayload("dup"), curvePresetPayload("dup")}},
{"too many presets", tooMany},
}
for _, tc := range cases {
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": tc.presets,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d body = %s", tc.name, rec.Code, rec.Body.String())
}
}
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
presets, _ := me.Preferences["curve_presets"].([]any)
if len(presets) != 1 {
t.Fatalf("invalid requests changed presets: %+v", me.Preferences)
}
}
+75
View File
@@ -0,0 +1,75 @@
package e2e
import (
"context"
"path/filepath"
"testing"
"time"
"galo/internal/albums"
"galo/internal/photos"
)
// A color edit must survive the round trip: the parameters are stored in the
// photo metadata, the edited version is rendered, and a reset returns the
// photo to its original look.
func TestColorEditRoundTrip(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "color@test.local", "", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Color"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "c.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
color := photos.ColorParams{Temperature: 25, Vibrance: 20}
color.Mixer.Red.Saturation = 30
color.Grading.Shadows = photos.ColorWheel{Hue: 220, Saturation: 40}
edited, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
Light: photos.LightParams{Exposure: 0.3},
Color: color,
})
if err != nil {
t.Fatalf("edit: %v", err)
}
if edited.EditParams == nil {
t.Fatal("edit returned no parameters")
}
w.ProcessOnce(ctx)
got, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.EditPreviewURL == "" {
t.Fatalf("no edited preview url: %+v", got)
}
if _, ok := got.EditParams["color"]; !ok {
t.Fatalf("color params not stored: %+v", got.EditParams)
}
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("reset: %v", err)
}
if len(reset.EditParams) != 0 {
t.Fatalf("reset kept params: %+v", reset.EditParams)
}
if reset.EditURL != "" || reset.EditPreviewURL != "" {
t.Fatalf("reset kept the edited version: %+v", reset)
}
}
+164
View File
@@ -0,0 +1,164 @@
package e2e
import (
"context"
"io/fs"
"path/filepath"
"testing"
"time"
assets "galo"
"galo/internal/albums"
"galo/internal/photos"
)
// A copy of an edited photo must not inherit the edited version: the edit
// belongs to the photo whose edits produced it.
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "copy@test.local", "", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
if err != nil {
t.Fatalf("create album: %v", err)
}
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
if err != nil {
t.Fatalf("create target album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.ProcessOnce(ctx)
source, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get source: %v", err)
}
if source.EditThumbURL == "" || source.EditPreviewURL == "" {
t.Fatalf("source has no edited version: %+v", source)
}
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
// The copy shares the base variants with the source.
if clone.ThumbURL != source.ThumbURL || clone.PreviewURL != source.PreviewURL {
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", source, clone)
}
// But it shows the original picture and carries no edit parameters.
if clone.EditURL != "" || clone.EditThumbURL != "" || clone.EditMediumURL != "" || clone.EditPreviewURL != "" {
t.Fatalf("clone shares the edited version: %+v", clone)
}
if len(clone.EditParams) != 0 {
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
}
}
// The migration drops the edit bindings that copies carried before the fix,
// while the edited version of the photo that owns them stays untouched.
func TestMigrationDropsEditBindingsOfCopies(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "mig@test.local", "", "password123", "M")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
if err != nil {
t.Fatalf("create album: %v", err)
}
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
if err != nil {
t.Fatalf("create target album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.ProcessOnce(ctx)
var editObjectID string
if err := st.db.QueryRowContext(ctx,
`SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&editObjectID); err != nil {
t.Fatalf("edited thumb object: %v", err)
}
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
// Reproduce the state left by the old code: the copy carries the edited
// version of the source photo.
if _, err := st.db.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'edit_thumb')`,
clone.ID, editObjectID); err != nil {
t.Fatalf("bind edited version to the copy: %v", err)
}
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
up, err := fs.ReadFile(migFS, "000008_photo_edit_copy_bindings.up.sql")
if err != nil {
t.Fatalf("read migration: %v", err)
}
if _, err := st.db.ExecContext(ctx, string(up)); err != nil {
t.Fatalf("apply migration: %v", err)
}
var n int
if err := st.db.QueryRowContext(ctx,
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role LIKE 'edit%'`, clone.ID).Scan(&n); err != nil {
t.Fatalf("count copy edit bindings: %v", err)
}
if n != 0 {
t.Fatalf("copy still carries %d edit bindings", n)
}
// The photo that owns the edited version keeps it.
if err := st.db.QueryRowContext(ctx,
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&n); err != nil {
t.Fatalf("count source edit bindings: %v", err)
}
if n != 1 {
t.Fatalf("source edit bindings = %d, want 1", n)
}
// A bound object is never garbage, so the edited version stays alive.
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
for _, o := range claimed {
if o.ID == editObjectID {
t.Fatalf("edited version of the owner was claimed as garbage")
}
}
}
@@ -0,0 +1,56 @@
package e2e
import (
"context"
"path/filepath"
"testing"
)
// Curve presets live in the user profile: they survive a round trip through
// the metadata layer and are never visible to another user.
func TestCurvePresetsPersistAndArePrivate(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
owner, _, err := st.auth.Register(ctx, "presets@test.local", "", "password123", "Owner")
if err != nil {
t.Fatalf("register owner: %v", err)
}
other, _, err := st.auth.Register(ctx, "otherpresets@test.local", "", "password123", "Other")
if err != nil {
t.Fatalf("register other: %v", err)
}
raw := []byte(`{"curve_presets":[{"name":"Мой контраст","channels":{"rgb":[{"x":0,"y":0},{"x":1,"y":0.9}],"red":[{"x":0,"y":0},{"x":1,"y":1}],"green":[{"x":0,"y":0},{"x":1,"y":1}],"blue":[{"x":0,"y":0},{"x":1,"y":1}]}}]}`)
if err := st.repos.Auth.UpdatePreferences(ctx, owner.ID, raw); err != nil {
t.Fatalf("update preferences: %v", err)
}
got, err := st.repos.Auth.GetUserByID(ctx, owner.ID)
if err != nil {
t.Fatalf("get owner: %v", err)
}
presets, ok := got.Preferences["curve_presets"].([]any)
if !ok || len(presets) != 1 {
t.Fatalf("presets not stored: %+v", got.Preferences)
}
preset, _ := presets[0].(map[string]any)
if preset["name"] != "Мой контраст" {
t.Fatalf("preset = %+v", preset)
}
channels, _ := preset["channels"].(map[string]any)
for _, channel := range []string{"rgb", "red", "green", "blue"} {
if points, ok := channels[channel].([]any); !ok || len(points) != 2 {
t.Fatalf("channel %s = %#v", channel, channels[channel])
}
}
otherUser, err := st.repos.Auth.GetUserByID(ctx, other.ID)
if err != nil {
t.Fatalf("get other: %v", err)
}
if _, ok := otherUser.Preferences["curve_presets"]; ok {
t.Fatalf("other user sees presets: %+v", otherUser.Preferences)
}
}
+54
View File
@@ -74,6 +74,60 @@ func TestCopySharesStorageObjects(t *testing.T) {
} }
} }
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
svc, cl, _ := newRecordingServiceFS(t, pool)
user := createUser(t, ctx, pool)
albumA := createAlbum(t, ctx, pool, user)
albumB := createAlbum(t, ctx, pool, user)
p, err := svc.Upload(ctx, UploadInput{
AlbumID: albumA, UserID: user, Filename: "p.png", MimeType: "image/png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
w.processVariantJobs(ctx)
// The edit belongs to the photo: the parameters live in its metadata and
// the background render binds the edited version to it.
if _, err := svc.EditPhoto(ctx, p.ID, user, EditParams{Light: LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.processEditJobs(ctx)
src, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get source: %v", err)
}
if src.EditThumbKey == "" || src.EditPreviewKey == "" {
t.Fatalf("source has no edited version: %+v", src)
}
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
// The copy shares the base variants with the source.
if clone.ThumbKey != src.ThumbKey || clone.MediumKey != src.MediumKey || clone.PreviewKey != src.PreviewKey {
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", src, clone)
}
// But it never inherits the edited version or its parameters.
if clone.EditKey != "" || clone.EditThumbKey != "" || clone.EditMediumKey != "" || clone.EditPreviewKey != "" {
t.Fatalf("clone shares the edited version: %+v", clone)
}
if len(clone.EditParams) != 0 {
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
}
}
func sameSet(a, b []string) bool { func sameSet(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
+451 -17
View File
@@ -39,9 +39,93 @@ type CurvePoint struct {
Y float64 `json:"y"` Y float64 `json:"y"`
} }
// ColorParams holds the parameters of the "Цвет" (color) tool: white balance,
// vibrance/saturation, the per-hue color mixer, color grading and a simple
// per-channel color correction. Percent values use -100..100, wheel hues use
// 0..360.
type ColorParams struct {
Temperature float64 `json:"temperature"`
Tint float64 `json:"tint"`
Vibrance float64 `json:"vibrance"`
Saturation float64 `json:"saturation"`
Mixer ColorMixer `json:"mixer"`
Grading ColorGrading `json:"grading"`
Correction ColorCorrection `json:"correction"`
}
// ColorMixer holds one HSL band per hue, mirroring the Lightroom color mixer.
type ColorMixer struct {
Red MixerBand `json:"red"`
Orange MixerBand `json:"orange"`
Yellow MixerBand `json:"yellow"`
Green MixerBand `json:"green"`
Aqua MixerBand `json:"aqua"`
Blue MixerBand `json:"blue"`
Purple MixerBand `json:"purple"`
Magenta MixerBand `json:"magenta"`
}
// MixerBand is a hue/saturation/luminance adjustment of one color band.
type MixerBand struct {
Hue float64 `json:"hue"`
Saturation float64 `json:"saturation"`
Luminance float64 `json:"luminance"`
}
// ColorGrading holds the three color-grading wheels and their blending.
type ColorGrading struct {
Shadows ColorWheel `json:"shadows"`
Midtones ColorWheel `json:"midtones"`
Highlights ColorWheel `json:"highlights"`
Blending float64 `json:"blending"`
Balance float64 `json:"balance"`
}
// ColorWheel is a hue, strength and luminance used by color grading.
type ColorWheel struct {
Hue float64 `json:"hue"`
Saturation float64 `json:"saturation"`
Luminance float64 `json:"luminance"`
}
// ColorCorrection is a simple per-channel gain, in percent.
type ColorCorrection struct {
Red float64 `json:"red"`
Green float64 `json:"green"`
Blue float64 `json:"blue"`
}
// mixerBand is a color band with its hue center in degrees.
type mixerBand struct {
center float64
hue float64
saturation float64
luminance float64
}
// bands returns the mixer bands in a stable order.
func (m ColorMixer) bands() [8]mixerBand {
return [8]mixerBand{
{0, m.Red.Hue, m.Red.Saturation, m.Red.Luminance},
{30, m.Orange.Hue, m.Orange.Saturation, m.Orange.Luminance},
{60, m.Yellow.Hue, m.Yellow.Saturation, m.Yellow.Luminance},
{120, m.Green.Hue, m.Green.Saturation, m.Green.Luminance},
{180, m.Aqua.Hue, m.Aqua.Saturation, m.Aqua.Luminance},
{240, m.Blue.Hue, m.Blue.Saturation, m.Blue.Luminance},
{280, m.Purple.Hue, m.Purple.Saturation, m.Purple.Luminance},
{320, m.Magenta.Hue, m.Magenta.Saturation, m.Magenta.Luminance},
}
}
// wheels returns the grading wheels in a stable order.
func (g ColorGrading) wheels() [3]ColorWheel {
return [3]ColorWheel{g.Shadows, g.Midtones, g.Highlights}
}
// EditParams is the full set of non-destructive editing parameters of a photo. // EditParams is the full set of non-destructive editing parameters of a photo.
type EditParams struct { type EditParams struct {
Light LightParams `json:"light"` Light LightParams `json:"light"`
Color ColorParams `json:"color"`
} }
// Limits of the editing parameters. They are enforced on write so a hand-made // Limits of the editing parameters. They are enforced on write so a hand-made
@@ -82,9 +166,54 @@ func (p EditParams) Normalized() EditParams {
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red) p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green) p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue) p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
p.Color = p.Color.Normalized()
return p return p
} }
// Normalized returns a copy of the color parameters clamped to their ranges.
func (c ColorParams) Normalized() ColorParams {
c.Temperature = clampFloat(c.Temperature, -MaxPercent, MaxPercent)
c.Tint = clampFloat(c.Tint, -MaxPercent, MaxPercent)
c.Vibrance = clampFloat(c.Vibrance, -MaxPercent, MaxPercent)
c.Saturation = clampFloat(c.Saturation, -MaxPercent, MaxPercent)
bands := c.Mixer.bands()
for i := range bands {
bands[i].hue = clampFloat(bands[i].hue, -MaxPercent, MaxPercent)
bands[i].saturation = clampFloat(bands[i].saturation, -MaxPercent, MaxPercent)
bands[i].luminance = clampFloat(bands[i].luminance, -MaxPercent, MaxPercent)
}
c.Mixer = ColorMixer{
Red: MixerBand{bands[0].hue, bands[0].saturation, bands[0].luminance},
Orange: MixerBand{bands[1].hue, bands[1].saturation, bands[1].luminance},
Yellow: MixerBand{bands[2].hue, bands[2].saturation, bands[2].luminance},
Green: MixerBand{bands[3].hue, bands[3].saturation, bands[3].luminance},
Aqua: MixerBand{bands[4].hue, bands[4].saturation, bands[4].luminance},
Blue: MixerBand{bands[5].hue, bands[5].saturation, bands[5].luminance},
Purple: MixerBand{bands[6].hue, bands[6].saturation, bands[6].luminance},
Magenta: MixerBand{bands[7].hue, bands[7].saturation, bands[7].luminance},
}
wheels := c.Grading.wheels()
for i := range wheels {
wheels[i].Hue = clampFloat(wheels[i].Hue, 0, 360)
wheels[i].Saturation = clampFloat(wheels[i].Saturation, -MaxPercent, MaxPercent)
wheels[i].Luminance = clampFloat(wheels[i].Luminance, -MaxPercent, MaxPercent)
}
c.Grading = ColorGrading{
Shadows: wheels[0],
Midtones: wheels[1],
Highlights: wheels[2],
Blending: clampFloat(c.Grading.Blending, 0, MaxPercent),
Balance: clampFloat(c.Grading.Balance, -MaxPercent, MaxPercent),
}
c.Correction.Red = clampFloat(c.Correction.Red, -MaxPercent, MaxPercent)
c.Correction.Green = clampFloat(c.Correction.Green, -MaxPercent, MaxPercent)
c.Correction.Blue = clampFloat(c.Correction.Blue, -MaxPercent, MaxPercent)
return c
}
// IsEmpty reports whether the parameters describe an untouched photo. // IsEmpty reports whether the parameters describe an untouched photo.
func (p EditParams) IsEmpty() bool { func (p EditParams) IsEmpty() bool {
l := p.Light l := p.Light
@@ -99,7 +228,26 @@ func (p EditParams) IsEmpty() bool {
} }
} }
} }
return true return p.Color.IsEmpty()
}
// IsEmpty reports whether the color parameters leave the image untouched.
func (c ColorParams) IsEmpty() bool {
if c.Temperature != 0 || c.Tint != 0 || c.Vibrance != 0 || c.Saturation != 0 {
return false
}
for _, b := range c.Mixer.bands() {
if b.hue != 0 || b.saturation != 0 || b.luminance != 0 {
return false
}
}
for _, w := range c.Grading.wheels() {
// A hue without strength or luminance has no effect.
if w.Saturation != 0 || w.Luminance != 0 {
return false
}
}
return c.Correction.Red == 0 && c.Correction.Green == 0 && c.Correction.Blue == 0
} }
// JSON encodes the parameters for storage in the photo metadata. // JSON encodes the parameters for storage in the photo metadata.
@@ -169,8 +317,9 @@ func clampInt(v, min, max int) int {
} }
// curveLUT builds a 256-entry lookup table from the control points of a // curveLUT builds a 256-entry lookup table from the control points of a
// curve. The first and last points extend to the edges of the range, and the // curve. The first and last points extend to the edges of the range as flat
// values in between are interpolated linearly. // shelves, and the values in between are interpolated with a monotone cubic
// Hermite spline (PCHIP, Fritsch–Carlson).
func curveLUT(points []CurvePoint) [256]uint8 { func curveLUT(points []CurvePoint) [256]uint8 {
var lut [256]uint8 var lut [256]uint8
pts := normalizeCurve(points) pts := normalizeCurve(points)
@@ -180,16 +329,71 @@ func curveLUT(points []CurvePoint) [256]uint8 {
} }
return lut return lut
} }
tangents := curveTangents(pts)
for i := 0; i < 256; i++ { for i := 0; i < 256; i++ {
x := float64(i) / 255 x := float64(i) / 255
lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255)) lut[i] = uint8(math.Round(clamp01(curveEvalWithTangents(pts, tangents, x)) * 255))
} }
return lut return lut
} }
// curveEval evaluates the curve at x using linear interpolation between the // curveTangents returns the PCHIP slopes at the control points. Interior
// surrounding control points. // tangents are weighted harmonic means of the neighbouring slopes; endpoint
// tangents use the one-sided three-point formula with the Fritsch–Carlson
// shape-preserving clamp.
func curveTangents(points []CurvePoint) []float64 {
n := len(points)
if n < 2 {
return nil
}
h := make([]float64, n-1)
delta := make([]float64, n-1)
for i := 0; i < n-1; i++ {
h[i] = points[i+1].X - points[i].X
delta[i] = (points[i+1].Y - points[i].Y) / h[i]
}
m := make([]float64, n)
if n == 2 {
m[0] = delta[0]
m[1] = delta[0]
return m
}
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1])
m[n-1] = endpointTangent(h[n-2], h[n-3], delta[n-2], delta[n-3])
for i := 1; i < n-1; i++ {
if delta[i-1]*delta[i] <= 0 {
m[i] = 0
continue
}
w1 := 2*h[i] + h[i-1]
w2 := h[i] + 2*h[i-1]
m[i] = (w1 + w2) / (w1/delta[i-1] + w2/delta[i])
}
return m
}
// endpointTangent is the one-sided endpoint slope of the PCHIP spline.
func endpointTangent(h0, h1, d0, d1 float64) float64 {
m := ((2*h0+h1)*d0 - h0*d1) / (h0 + h1)
if m*d0 <= 0 {
return 0
}
if d0*d1 < 0 && math.Abs(m) > math.Abs(3*d0) {
return 3 * d0
}
return m
}
// curveEval evaluates the curve at x with a monotone cubic Hermite spline.
// Outside the first and last points the value is constant.
func curveEval(points []CurvePoint, x float64) float64 { func curveEval(points []CurvePoint, x float64) float64 {
if len(points) < 2 {
return x
}
return curveEvalWithTangents(points, curveTangents(points), x)
}
func curveEvalWithTangents(points []CurvePoint, tangents []float64, x float64) float64 {
if x <= points[0].X { if x <= points[0].X {
return points[0].Y return points[0].Y
} }
@@ -197,19 +401,23 @@ func curveEval(points []CurvePoint, x float64) float64 {
if x >= last.X { if x >= last.X {
return last.Y return last.Y
} }
for i := 1; i < len(points); i++ { i := 1
if x > points[i].X { for i < len(points)-1 && x > points[i].X {
continue i++
} }
x0, y0 := points[i-1].X, points[i-1].Y p0, p1 := points[i-1], points[i]
x1, y1 := points[i].X, points[i].Y h := p1.X - p0.X
if x1-x0 < 1e-9 { if h < 1e-9 {
return y1 return p1.Y
} }
t := (x - x0) / (x1 - x0) t := (x - p0.X) / h
return y0 + t*(y1-y0) t2 := t * t
} t3 := t2 * t
return last.Y h00 := 2*t3 - 3*t2 + 1
h10 := t3 - 2*t2 + t
h01 := -2*t3 + 3*t2
h11 := t3 - t2
return h00*p0.Y + h10*h*tangents[i-1] + h01*p1.Y + h11*h*tangents[i]
} }
// lightAdjuster holds the precomputed factors of the light adjustments. The // lightAdjuster holds the precomputed factors of the light adjustments. The
@@ -267,3 +475,229 @@ func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 { func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
return newLightAdjuster(p).apply(v, master, channel) return newLightAdjuster(p).apply(v, master, channel)
} }
// rgbToHSL converts RGB (each 0..255) to HSL: hue in degrees, saturation and
// lightness in 0..1. It uses only arithmetic, so it is easy to mirror in the
// client and in the WASM module.
func rgbToHSL(r, g, b float64) (h, s, l float64) {
r /= 255
g /= 255
b /= 255
max := math.Max(r, math.Max(g, b))
min := math.Min(r, math.Min(g, b))
l = (max + min) / 2
if max == min {
return 0, 0, l
}
d := max - min
if l > 0.5 {
s = d / (2 - max - min)
} else {
s = d / (max + min)
}
switch max {
case r:
h = (g - b) / d
if g < b {
h += 6
}
case g:
h = (b-r)/d + 2
case b:
h = (r-g)/d + 4
}
h *= 60
return h, s, l
}
// hslToRGB converts HSL (hue in degrees, saturation and lightness in 0..1)
// back to RGB in 0..255.
func hslToRGB(h, s, l float64) (r, g, b float64) {
if s == 0 {
v := l * 255
return v, v, v
}
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
hk := h / 360
r = hueToRGB(p, q, hk+1.0/3)
g = hueToRGB(p, q, hk)
b = hueToRGB(p, q, hk-1.0/3)
return r * 255, g * 255, b * 255
}
// hueToRGB is the per-channel helper of hslToRGB.
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
if t < 1.0/6 {
return p + (q-p)*6*t
}
if t < 1.0/2 {
return q
}
if t < 2.0/3 {
return p + (q-p)*(2.0/3-t)*6
}
return p
}
// bandWeight returns a triangular weight of a hue around a band center.
func bandWeight(h, center float64) float64 {
d := math.Abs(h - center)
if d > 180 {
d = 360 - d
}
return clamp01(1 - d/60)
}
// colorAdjuster holds the precomputed factors of the color adjustments. The
// order of the operations is fixed and mirrored by the client-side preview and
// the WASM module, so the live preview and the rendered result match.
type colorAdjuster struct {
temperature float64
tint float64
vibrance float64
saturation float64
mixer [8]mixerBand
hasMixer bool
wheels [3]ColorWheel
luminances [3]float64
blending float64
balance float64
correction [3]float64
tints [3][3]float64
}
func newColorAdjuster(p ColorParams) colorAdjuster {
a := colorAdjuster{
temperature: p.Temperature / 100,
tint: p.Tint / 100,
vibrance: p.Vibrance / 100,
saturation: p.Saturation / 100,
mixer: p.Mixer.bands(),
wheels: p.Grading.wheels(),
blending: p.Grading.Blending / 100,
balance: p.Grading.Balance / 100,
correction: [3]float64{p.Correction.Red / 100, p.Correction.Green / 100, p.Correction.Blue / 100},
}
for _, band := range a.mixer {
if band.hue != 0 || band.saturation != 0 || band.luminance != 0 {
a.hasMixer = true
break
}
}
for i, wheel := range a.wheels {
a.luminances[i] = wheel.Luminance / 100
r, g, b := hslToRGB(wheel.Hue, 1, 0.5)
a.tints[i] = [3]float64{r, g, b}
}
return a
}
// apply color-corrects one RGB pixel (each 0..255) and returns the result.
func (a colorAdjuster) apply(r, g, b float64) (float64, float64, float64) {
// Баланс белого: тёплый/холодный и зелёный/пурпурный сдвиг.
r *= 1 + 0.20*a.temperature + 0.10*a.tint
g *= 1 - 0.20*a.tint
b *= 1 - 0.20*a.temperature + 0.10*a.tint
r = clampFloat(r, 0, 255)
g = clampFloat(g, 0, 255)
b = clampFloat(b, 0, 255)
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
// HSL-переход не нужен, lightness считается напрямую.
fast := !a.hasMixer && a.saturation == 0 && a.vibrance == 0
var weights [3]float64
var rr, gg, bb float64
if fast {
max := math.Max(r, math.Max(g, b))
min := math.Min(r, math.Min(g, b))
l := (max + min) / 510
lb := clamp01(l + a.balance*0.5)
shadowW := (1 - lb) * (1 - lb)
shadowW *= shadowW
highlightW := lb * lb
highlightW *= highlightW
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
targetL := l
for i := range a.wheels {
targetL += a.luminances[i] * 0.2 * weights[i]
}
targetL = clamp01(targetL)
delta := (targetL - l) * 255
rr = r + delta
gg = g + delta
bb = b + delta
} else {
// RGB -> HSL.
h, s, l := rgbToHSL(r, g, b)
// Насыщенность и вибрация: вибрация сильнее влияет на приглушённые цвета.
if a.saturation != 0 || a.vibrance != 0 {
s = clamp01(s * (1 + a.saturation + a.vibrance*(1-s)))
}
// Цветовой микшер: сдвиг оттенка, насыщенности и яркости по зонам.
if a.hasMixer {
for _, band := range a.mixer {
w := bandWeight(h, band.center)
if w == 0 {
continue
}
h += (band.hue / 100) * 30 * w
s = clamp01(s * (1 + (band.saturation/100)*w))
l = clamp01(l + (band.luminance/100)*0.2*w)
}
}
h = math.Mod(h, 360)
if h < 0 {
h += 360
}
// Зоны яркости: веса в сумме дают 1, баланс сдвигает границы.
lb := clamp01(l + a.balance*0.5)
// Более узкие зоны: колесо теней почти не задевает средние тона.
shadowW := (1 - lb) * (1 - lb)
shadowW *= shadowW
highlightW := lb * lb
highlightW *= highlightW
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
for i := range a.wheels {
l = clamp01(l + a.luminances[i]*0.2*weights[i])
}
// HSL -> RGB.
rr, gg, bb = hslToRGB(h, s, l)
}
// Точечный цвет: три колеса по зонам яркости.
blend := a.blending
for i, wheel := range a.wheels {
strength := (wheel.Saturation / 100) * 0.5 * blend * weights[i]
if strength == 0 {
continue
}
tint := a.tints[i]
rr = rr*(1-strength) + tint[0]*strength
gg = gg*(1-strength) + tint[1]*strength
bb = bb*(1-strength) + tint[2]*strength
}
// Цветокоррекция: поканальный сдвиг.
rr *= 1 + a.correction[0]*0.5
gg *= 1 + a.correction[1]*0.5
bb *= 1 + a.correction[2]*0.5
return clampFloat(rr, 0, 255), clampFloat(gg, 0, 255), clampFloat(bb, 0, 255)
}
+73 -3
View File
@@ -19,6 +19,22 @@ const (
RoleEditPreview = "edit_preview" RoleEditPreview = "edit_preview"
) )
// EditRoles lists the roles of the edited version. The edited version belongs
// to the photo whose edits produced it, so copies of that photo never share
// these roles.
func EditRoles() []string {
return []string{RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview}
}
// IsEditRole reports whether the role belongs to the edited version.
func IsEditRole(role string) bool {
switch role {
case RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview:
return true
}
return false
}
// applyLight renders the light adjustments into a copy of the image. The // applyLight renders the light adjustments into a copy of the image. The
// original pixels are never modified in place. // original pixels are never modified in place.
func applyLight(src image.Image, p LightParams) *image.NRGBA { func applyLight(src image.Image, p LightParams) *image.NRGBA {
@@ -74,14 +90,68 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r
} }
} }
// decodeAndAdjust decodes the original and applies the light adjustments to // decodeAndAdjust decodes the original and applies the light and color
// it, so the variants and the full-size version share one decode. // adjustments to it, so the variants and the full-size version share one
// decode. Color is applied after light.
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) { func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return applyLight(img, p.Light), nil edited := applyLight(img, p.Light)
if p.Color.IsEmpty() {
return edited, nil
}
return applyColor(edited, p.Color), nil
}
// applyColor applies the color adjustments in place to an image that already
// has the light adjustments applied. The image is owned by the caller.
func applyColor(dst *image.NRGBA, p ColorParams) *image.NRGBA {
adj := newColorAdjuster(p)
rows := dst.Rect.Dy()
workers := runtime.GOMAXPROCS(0)
if workers > rows {
workers = rows
}
if workers <= 1 {
applyColorRows(dst, 0, rows, adj)
return dst
}
// Рендер больших фото идёт по строкам параллельно.
chunk := (rows + workers - 1) / workers
var wg sync.WaitGroup
for from := 0; from < rows; from += chunk {
to := from + chunk
if to > rows {
to = rows
}
wg.Add(1)
go func(from, to int) {
defer wg.Done()
applyColorRows(dst, from, to, adj)
}(from, to)
}
wg.Wait()
return dst
}
// applyColorRows applies the color adjustments to the given range of rows.
func applyColorRows(dst *image.NRGBA, from, to int, adj colorAdjuster) {
width := dst.Rect.Dx()
for y := from; y < to; y++ {
row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4]
for i := 0; i+3 < len(row); i += 4 {
if row[i+3] == 0 {
continue
}
r, g, b := adj.apply(float64(row[i]), float64(row[i+1]), float64(row[i+2]))
row[i] = uint8(clampFloat(r+0.5, 0, 255))
row[i+1] = uint8(clampFloat(g+0.5, 0, 255))
row[i+2] = uint8(clampFloat(b+0.5, 0, 255))
}
}
} }
// encodeEditedVariants renders the grid variants of an edited photo. They // encodeEditedVariants renders the grid variants of an edited photo. They
+1 -2
View File
@@ -74,8 +74,7 @@ func (s *Service) ResetEdit(ctx context.Context, photoID, userID string) (*model
if err := s.store.DeleteEditJob(ctx, photoID); err != nil { if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
return nil, err return nil, err
} }
if err := s.store.DeleteObjectBindings(ctx, photoID, if err := s.store.DeleteObjectBindings(ctx, photoID, EditRoles()...); err != nil {
RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview); err != nil {
return nil, err return nil, err
} }
return s.publishEdit(ctx, photoID, userID) return s.publishEdit(ctx, photoID, userID)
+273
View File
@@ -34,6 +34,23 @@ func TestParseEditParamsClampsValues(t *testing.T) {
} }
} }
func TestEditRolesCoverTheEditedVersion(t *testing.T) {
roles := EditRoles()
if len(roles) != 4 {
t.Fatalf("edit roles = %v, want four", roles)
}
for _, role := range roles {
if !IsEditRole(role) {
t.Fatalf("role %q must be an edit role", role)
}
}
for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} {
if IsEditRole(role) {
t.Fatalf("role %q must not be an edit role", role)
}
}
}
func TestEditParamsIsEmpty(t *testing.T) { func TestEditParamsIsEmpty(t *testing.T) {
var zero EditParams var zero EditParams
if !zero.IsEmpty() { if !zero.IsEmpty() {
@@ -74,6 +91,70 @@ func TestCurveLUTSteepens(t *testing.T) {
} }
} }
// The client builds the same 256-entry LUT (frontend/src/lib/edit.ts). The
// hard-coded samples keep the Go and TypeScript implementations in sync.
func TestCurveLUTMatchesClient(t *testing.T) {
samples := []int{0, 32, 64, 128, 192, 224, 255}
cases := []struct {
name string
points []CurvePoint
want []int
}{
{"linear", []CurvePoint{{0, 0}, {1, 1}}, []int{0, 32, 64, 128, 192, 224, 255}},
{
"medium",
[]CurvePoint{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
[]int{0, 23, 51, 128, 205, 233, 255},
},
{
"strong",
[]CurvePoint{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
[]int{0, 17, 43, 128, 213, 239, 255},
},
{
"inner",
[]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}},
[]int{13, 17, 42, 104, 186, 234, 242},
},
}
for _, tc := range cases {
lut := curveLUT(tc.points)
for i, index := range samples {
if int(lut[index]) != tc.want[i] {
t.Fatalf("%s lut[%d] = %d, want %d", tc.name, index, lut[index], tc.want[i])
}
}
}
}
func TestCurveLUTIsMonotone(t *testing.T) {
for _, points := range [][]CurvePoint{
{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
} {
lut := curveLUT(points)
for i := 1; i < len(lut); i++ {
if lut[i] < lut[i-1] {
t.Fatalf("lut[%d] = %d < lut[%d] = %d", i, lut[i], i-1, lut[i-1])
}
}
}
}
func TestCurveLUTFlatOutsideEndpoints(t *testing.T) {
lut := curveLUT([]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}})
for i := 0; i <= int(math.Floor(0.1*255)); i++ {
if lut[i] != 13 {
t.Fatalf("lut[%d] = %d, want flat 13", i, lut[i])
}
}
for i := 230; i < len(lut); i++ {
if lut[i] != 242 {
t.Fatalf("lut[%d] = %d, want flat 242", i, lut[i])
}
}
}
func TestApplyLightBrightensAndKeepsSource(t *testing.T) { func TestApplyLightBrightensAndKeepsSource(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 2, 2)) src := image.NewNRGBA(image.Rect(0, 0, 2, 2))
for i := range src.Pix { for i := range src.Pix {
@@ -165,3 +246,195 @@ func mustEncodePNG(t *testing.T, img image.Image) []byte {
} }
return buf.Bytes() return buf.Bytes()
} }
func TestParseEditParamsClampsColor(t *testing.T) {
raw := []byte(`{"color":{"temperature":200,"tint":-300,"vibrance":50,"saturation":-500,"mixer":{"red":{"hue":150,"saturation":-200,"luminance":10}},"grading":{"shadows":{"hue":400,"saturation":150},"balance":-200},"correction":{"red":120}}}`)
params, err := ParseEditParams(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
c := params.Color
if c.Temperature != MaxPercent || c.Tint != -MaxPercent {
t.Fatalf("wb = %v/%v, want clamped", c.Temperature, c.Tint)
}
if c.Saturation != -MaxPercent {
t.Fatalf("saturation = %v, want %v", c.Saturation, -MaxPercent)
}
if c.Mixer.Red.Hue != MaxPercent || c.Mixer.Red.Saturation != -MaxPercent {
t.Fatalf("mixer red = %+v, want clamped", c.Mixer.Red)
}
if c.Grading.Shadows.Hue != 360 || c.Grading.Shadows.Saturation != MaxPercent {
t.Fatalf("grading shadows = %+v, want clamped", c.Grading.Shadows)
}
if c.Grading.Balance != -MaxPercent || c.Correction.Red != MaxPercent {
t.Fatalf("grading/correction not clamped: %+v %+v", c.Grading, c.Correction)
}
}
func TestColorParamsIsEmpty(t *testing.T) {
var zero ColorParams
if !zero.IsEmpty() {
t.Fatal("zero color params must be empty")
}
vibrance := ColorParams{Vibrance: 1}
if vibrance.IsEmpty() {
t.Fatal("vibrance must not be empty")
}
mixer := ColorParams{}
mixer.Mixer.Blue.Luminance = -10
if mixer.IsEmpty() {
t.Fatal("mixer band must not be empty")
}
// Оттенок колеса без силы не влияет на картинку.
hueOnly := ColorParams{}
hueOnly.Grading.Shadows.Hue = 120
if !hueOnly.IsEmpty() {
t.Fatal("grading hue without strength must count as empty")
}
grade := ColorParams{}
grade.Grading.Highlights.Saturation = 10
if grade.IsEmpty() {
t.Fatal("grading strength must not be empty")
}
}
func TestApplyColorWhiteBalance(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 100, 100, 100, 255
out := applyColor(src, ColorParams{Temperature: 100})
if !(out.Pix[0] > out.Pix[1] && out.Pix[1] > out.Pix[2]) {
t.Fatalf("warm white balance = %d/%d/%d, want r > g > b", out.Pix[0], out.Pix[1], out.Pix[2])
}
}
func TestApplyColorSaturationMinus100IsGray(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 200, 50, 50, 255
out := applyColor(src, ColorParams{Saturation: -100})
if out.Pix[0] != out.Pix[1] || out.Pix[1] != out.Pix[2] {
t.Fatalf("desaturated pixel = %d/%d/%d, want gray", out.Pix[0], out.Pix[1], out.Pix[2])
}
}
func TestApplyColorSaturationPlus100(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 150, 120, 120, 255
before := src.Pix[0] - src.Pix[1]
out := applyColor(src, ColorParams{Saturation: 100})
if out.Pix[0]-out.Pix[1] <= before {
t.Fatalf("saturation did not spread channels: %d/%d/%d", out.Pix[0], out.Pix[1], out.Pix[2])
}
}
func TestApplyColorGradingTintsShadows(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
p := ColorParams{}
p.Grading.Blending = 50
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
out := applyColor(src, p)
if out.Pix[2] <= out.Pix[0] {
t.Fatalf("blue shadow grading = %d/%d/%d, want b > r", out.Pix[0], out.Pix[1], out.Pix[2])
}
}
func TestApplyColorGradingLuminance(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
p := ColorParams{}
p.Grading.Shadows = ColorWheel{Luminance: 100}
out := applyColor(src, p)
if out.Pix[0] <= 40 {
t.Fatalf("shadow luminance did not brighten: %d", out.Pix[0])
}
}
func TestApplyColorUniformRows(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 64, 64))
for i := 0; i < len(src.Pix); i += 4 {
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 40, 40, 40, 255
}
p := ColorParams{}
p.Grading.Blending = 50
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
out := applyColor(src, p)
wantR, wantG, wantB := out.Pix[0], out.Pix[1], out.Pix[2]
for y := 0; y < 64; y++ {
for x := 0; x < 64; x++ {
i := y*out.Stride + x*4
if out.Pix[i] != wantR || out.Pix[i+1] != wantG || out.Pix[i+2] != wantB {
t.Fatalf("pixel (%d,%d) = %d/%d/%d, want %d/%d/%d", x, y, out.Pix[i], out.Pix[i+1], out.Pix[i+2], wantR, wantG, wantB)
}
}
}
}
func TestApplyColorParallelMatchesSingle(t *testing.T) {
makeImage := func() *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, 32, 64))
for y := 0; y < 64; y++ {
for x := 0; x < 32; x++ {
i := y*img.Stride + x*4
img.Pix[i] = uint8(20 + y*3)
img.Pix[i+1] = uint8(20 + y*2)
img.Pix[i+2] = uint8(30 + y)
img.Pix[i+3] = 255
}
}
return img
}
p := ColorParams{}
p.Grading.Blending = 50
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
parallel := applyColor(makeImage(), p)
single := makeImage()
applyColorRows(single, 0, single.Rect.Dy(), newColorAdjuster(p))
for i := range parallel.Pix {
if parallel.Pix[i] != single.Pix[i] {
t.Fatalf("parallel differs at byte %d: %d != %d", i, parallel.Pix[i], single.Pix[i])
}
}
}
func TestApplyColorKeepsTransparentPixels(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 10, 20, 30, 0
out := applyColor(src, ColorParams{Saturation: 100, Temperature: 100})
if out.Pix[0] != 10 || out.Pix[1] != 20 || out.Pix[2] != 30 || out.Pix[3] != 0 {
t.Fatalf("transparent pixel changed: %v", out.Pix)
}
}
func TestParseEditParamsWithoutColorIsBackwardCompatible(t *testing.T) {
params, err := ParseEditParams([]byte(`{"light":{"exposure":0.5}}`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !params.Color.IsEmpty() {
t.Fatalf("missing color must be empty: %+v", params.Color)
}
if params.IsEmpty() {
t.Fatal("light edits must not be empty")
}
}
func TestApplyColorEmptyIsIdentity(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 3, 1))
colors := [][3]uint8{{0, 0, 0}, {200, 50, 25}, {33, 210, 128}}
for i, c := range colors {
src.Pix[i*4] = c[0]
src.Pix[i*4+1] = c[1]
src.Pix[i*4+2] = c[2]
src.Pix[i*4+3] = 255
}
before := make([]byte, len(src.Pix))
copy(before, src.Pix)
applyColor(src, ColorParams{})
for i := range before {
if before[i] != src.Pix[i] {
t.Fatalf("empty color changed pixel %d: %d != %d", i, src.Pix[i], before[i])
}
}
}
+3 -2
View File
@@ -124,12 +124,13 @@ func (h *Handler) Download(c *gin.Context) {
func (h *Handler) Edit(c *gin.Context) { func (h *Handler) Edit(c *gin.Context) {
var req struct { var req struct {
Light LightParams `json:"light"` Light LightParams `json:"light"`
Color ColorParams `json:"color"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "light params are required")) httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
return return
} }
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light}) p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color})
if err != nil { if err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
return return
+10 -2
View File
@@ -250,10 +250,18 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
mediaKind(src.Kind), src.DurationMS); err != nil { mediaKind(src.Kind), src.DurationMS); err != nil {
return err return err
} }
// The edited version belongs to the photo whose edits produced it, so the
// copy shares the base variants only.
roles := EditRoles()
args := []any{id, src.ID}
for _, role := range roles {
args = append(args, role)
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) `INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`, SELECT $1, object_id, role FROM photo_objects
id, src.ID); err != nil { WHERE photo_id = $2 AND role NOT IN (`+sqliteutil.Placeholders(3, len(roles))+`)`,
args...); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
+5 -2
View File
@@ -290,10 +290,13 @@ func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderI
mediaKind(src.Kind), src.DurationMS); err != nil { mediaKind(src.Kind), src.DurationMS); err != nil {
return err return err
} }
// The edited version belongs to the photo whose edits produced it, so the
// copy shares the base variants only.
if _, err := tx.Exec(ctx, if _, err := tx.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) `INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`, SELECT $1, object_id, role FROM photo_objects
id, src.ID); err != nil { WHERE photo_id = $2 AND role <> ALL($3::text[])`,
id, src.ID, EditRoles()); err != nil {
return err return err
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
{"Social", testSocial}, {"Social", testSocial},
{"Photos", testPhotos}, {"Photos", testPhotos},
{"Objects", testObjects}, {"Objects", testObjects},
{"Clone", testClone},
{"UserAvatar", testUserAvatar}, {"UserAvatar", testUserAvatar},
{"VariantJobKinds", testVariantJobKinds}, {"VariantJobKinds", testVariantJobKinds},
{"Settings", testSettings}, {"Settings", testSettings},
@@ -709,6 +710,56 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
} }
} }
func testClone(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
target := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
// The photo carries the base variants and an edited version.
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
t.Fatalf("create thumb: %v", err)
}
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
t.Fatalf("activate thumb: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
t.Fatalf("bind thumb: %v", err)
}
edited := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, edited); err != nil {
t.Fatalf("create edited object: %v", err)
}
if err := r.Photos.ActivateObject(ctx, edited.ID); err != nil {
t.Fatalf("activate edited object: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, edited.ID, photos.RoleEdit); err != nil {
t.Fatalf("bind edited version: %v", err)
}
cloneID := newID()
if err := r.Photos.Clone(ctx, p, target.ID, owner.ID, cloneID); err != nil {
t.Fatalf("clone: %v", err)
}
cloneObjects, err := r.Photos.PhotoObjectIDs(ctx, cloneID)
if err != nil {
t.Fatalf("clone objects: %v", err)
}
// The copy shares the base variants.
if !contains(cloneObjects, thumb.ID) {
t.Fatalf("clone objects = %+v, want thumb %s", cloneObjects, thumb.ID)
}
// The edited version belongs to the photo whose edits produced it.
if contains(cloneObjects, edited.ID) {
t.Fatalf("clone shares the edited version: %+v", cloneObjects)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, edited.ID); refs != 1 {
t.Fatalf("edited object refs = %d, want 1", refs)
}
}
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) { func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner") owner := seedUser(t, ctx, r, "Owner")
@@ -0,0 +1,3 @@
-- The removed bindings cannot be restored: which object belonged to which copy
-- is not recorded anywhere. Rollback of the code is enough - the edited
-- versions of the photos that own them stay untouched.
@@ -0,0 +1,10 @@
-- The edited version belongs to the photo whose edits produced it. Copies made
-- before the fix still carried the edit bindings, so a photo without edit
-- parameters showed the edited picture. Drop those bindings: the objects
-- become collectable garbage and the copies show the original picture again.
DELETE FROM photo_objects
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
AND photo_id IN (
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'::jsonb
);
@@ -0,0 +1,3 @@
-- The removed bindings cannot be restored: which object belonged to which copy
-- is not recorded anywhere. Rollback of the code is enough - the edited
-- versions of the photos that own them stay untouched.
@@ -0,0 +1,10 @@
-- The edited version belongs to the photo whose edits produced it. Copies made
-- before the fix still carried the edit bindings, so a photo without edit
-- parameters showed the edited picture. Drop those bindings: the objects
-- become collectable garbage and the copies show the original picture again.
DELETE FROM photo_objects
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
AND photo_id IN (
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'
);
+21 -4
View File
@@ -22,10 +22,16 @@
let picking = $state(false); let picking = $state(false);
let photo = $state<Photo | null>(null); let photo = $state<Photo | null>(null);
// Вкладка правой панели живёт здесь и переживает смену фото.
let photoTab = $state<'info' | 'edit'>('info');
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []); const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null); const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto); // Не показываем в панели фото от предыдущего маршрута, пока не загрузилось
// текущее: иначе правки могут уйти не тому фото.
const panelPhoto = $derived(
photoId ? (photo?.id === photoId ? photo : null) : selectedPhoto
);
const canEdit = $derived(role === 'owner' || role === 'admin'); const canEdit = $derived(role === 'owner' || role === 'admin');
@@ -51,7 +57,9 @@
} }
function onRenamed(updated: Photo) { function onRenamed(updated: Photo) {
photo = updated; // Фоновое сохранение предыдущего фото может завершиться уже после
// переключения: его ответ не должен подменять текущее фото в панели.
if (updated.id === photoId) photo = updated;
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated); if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
photoUpdates.publish(updated); photoUpdates.publish(updated);
} }
@@ -90,9 +98,17 @@
photo = null; photo = null;
return; return;
} }
let cancelled = false;
api<{ photo: Photo }>(`/photos/${id}`) api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (photo = data.photo)) .then((data) => {
.catch((e) => (error = (e as Error).message)); if (!cancelled && data.photo.id === id) photo = data.photo;
})
.catch((e) => {
if (!cancelled) error = (e as Error).message;
});
return () => {
cancelled = true;
};
}); });
async function resetCover() { async function resetCover() {
@@ -146,6 +162,7 @@
{#if panelPhoto} {#if panelPhoto}
<PhotoPanel <PhotoPanel
bind:tab={photoTab}
photo={panelPhoto} photo={panelPhoto}
{role} {role}
viewer={!!photoId} viewer={!!photoId}
@@ -0,0 +1,87 @@
<script lang="ts">
interface Props {
hue: number;
saturation: number;
label?: string;
disabled?: boolean;
onchange?: (hue: number, saturation: number) => void;
oncommit?: () => void;
}
let { hue, saturation, label, disabled = false, onchange, oncommit }: Props = $props();
let dragging = $state(false);
let el: HTMLDivElement | undefined = $state();
function pick(ev: PointerEvent) {
if (!el || disabled) return;
const rect = el.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width - 0.5;
const y = (ev.clientY - rect.top) / rect.height - 0.5;
const radius = Math.sqrt(x * x + y * y);
const angle = Math.atan2(x, -y);
const nextHue = (((angle * 180) / Math.PI) + 360) % 360;
const nextSaturation = Math.min(1, radius * 2) * 100;
onchange?.(nextHue, nextSaturation);
}
function onDown(ev: PointerEvent) {
if (disabled) return;
ev.preventDefault();
dragging = true;
pick(ev);
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
function onMove(ev: PointerEvent) {
if (dragging) pick(ev);
}
function onUp() {
if (!dragging) return;
dragging = false;
oncommit?.();
}
/** Двойной клик сбрасывает колесо в нейтраль. */
function onDoubleClick(ev: MouseEvent) {
ev.preventDefault();
if (disabled) return;
onchange?.(0, 0);
oncommit?.();
}
const knob = $derived({
left:
50 +
Math.sin((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50,
top:
50 -
Math.cos((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50
});
</script>
<div class="flex flex-col items-center gap-1">
<div
bind:this={el}
class="relative aspect-square w-full max-w-[88px] touch-none rounded-full"
style="background: radial-gradient(circle, #d9d9d9 0%, rgba(217,217,217,0) 55%), conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red);"
role="slider"
tabindex="0"
aria-label={label ?? 'Цвет'}
aria-valuemin="0"
aria-valuemax="360"
aria-valuenow={Math.round(hue)}
onpointerdown={onDown}
onpointermove={onMove}
onpointerup={onUp}
onpointerleave={onUp}
ondblclick={onDoubleClick}
>
<span
class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow"
style="left: {knob.left}%; top: {knob.top}%;"
></span>
</div>
{#if label}<span class="text-text-muted text-[10px]">{label}</span>{/if}
</div>
@@ -0,0 +1,432 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { CURVE_CHANNELS, type CurveChannel, type CurvePoint } from '$lib/edit';
import {
BUILTIN_CURVE_PRESETS,
MAX_CURVE_PRESET_NAME,
exportCurvePresetFile,
normalizeCurvePresetName,
parseCurvePresetFile
} from '$lib/curve-presets';
import type { CurvePreset } from '$lib/types';
interface Props {
curves: Record<CurveChannel, CurvePoint[]>;
presets: CurvePreset[];
activeName: string;
disabled?: boolean;
onApply: (preset: CurvePreset) => void;
onSave: (preset: CurvePreset) => Promise<void>;
onRename: (oldName: string, newName: string) => Promise<void>;
onDelete: (name: string) => Promise<void>;
onImport: (presets: CurvePreset[]) => Promise<void>;
}
let {
curves,
presets,
activeName,
disabled = false,
onApply,
onSave,
onRename,
onDelete,
onImport
}: Props = $props();
let open = $state(false);
let dialog = $state<'save' | 'rename' | null>(null);
let renameFrom = $state('');
let nameDraft = $state('');
let error = $state('');
let busy = $state(false);
let trigger = $state<HTMLButtonElement | undefined>();
let menuElement = $state<HTMLDivElement | undefined>();
let menuStyle = $state('');
let fileInput = $state<HTMLInputElement | undefined>();
let nameInput = $state<HTMLInputElement | undefined>();
const MENU_WIDTH = 288;
const MENU_HEIGHT = 320;
const MENU_GAP = 6;
const VIEWPORT_PAD = 8;
const isCustomActive = $derived(presets.some((preset) => preset.name === activeName));
$effect(() => {
if (dialog && nameInput) {
nameInput.focus();
nameInput.select();
}
});
// Панель редактирования — скроллящийся контейнер с transform, поэтому меню
// рендерится в portal и позиционируется от кнопки в координатах viewport.
$effect(() => {
if (!open) return;
const onScroll = (ev: Event) => {
if (menuElement && ev.target instanceof Node && menuElement.contains(ev.target)) return;
open = false;
};
const onResize = () => (open = false);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
});
function positionMenu() {
if (!trigger || typeof window === 'undefined') return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(MENU_WIDTH, window.innerWidth - VIEWPORT_PAD * 2);
const left = Math.min(
Math.max(VIEWPORT_PAD, rect.right - width),
window.innerWidth - width - VIEWPORT_PAD
);
const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP - VIEWPORT_PAD;
const spaceAbove = rect.top - MENU_GAP - VIEWPORT_PAD;
const openBelow = spaceBelow >= Math.min(MENU_HEIGHT, 220) || spaceBelow >= spaceAbove;
if (openBelow) {
menuStyle = `left:${left}px; top:${rect.bottom + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceBelow)}px;`;
} else {
menuStyle = `left:${left}px; bottom:${window.innerHeight - rect.top + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceAbove)}px;`;
}
}
function toggle() {
if (disabled) return;
if (!open) positionMenu();
open = !open;
error = '';
}
function apply(preset: CurvePreset) {
onApply(preset);
open = false;
error = '';
}
function startSave() {
nameDraft = isCustomActive ? activeName : '';
open = false;
error = '';
dialog = 'save';
}
function startRename(preset: CurvePreset) {
renameFrom = preset.name;
nameDraft = preset.name;
open = false;
error = '';
dialog = 'rename';
}
function closeDialog() {
if (busy) return;
dialog = null;
error = '';
}
async function submit(ev: Event) {
ev.preventDefault();
if (busy) return;
const name = normalizeCurvePresetName(nameDraft);
if (!name) {
error = `Введите название до ${MAX_CURVE_PRESET_NAME} символов`;
return;
}
if (dialog === 'save') {
if (
presets.some((preset) => preset.name === name) &&
!window.confirm(`Перезаписать пресет «${name}»?`)
) {
return;
}
busy = true;
error = '';
try {
const channels = {} as CurvePreset['channels'];
for (const ch of CURVE_CHANNELS) {
channels[ch] = curves[ch].map((point) => ({ ...point }));
}
await onSave({ name, channels });
dialog = null;
} catch (e) {
error = (e as Error).message || 'Не удалось сохранить пресет';
} finally {
busy = false;
}
return;
}
if (dialog === 'rename') {
if (name === renameFrom) {
dialog = null;
return;
}
if (presets.some((preset) => preset.name === name)) {
error = 'Пресет с таким названием уже есть';
return;
}
busy = true;
error = '';
try {
await onRename(renameFrom, name);
dialog = null;
} catch (e) {
error = (e as Error).message || 'Не удалось переименовать пресет';
} finally {
busy = false;
}
}
}
async function removePreset(preset: CurvePreset) {
if (busy) return;
if (!window.confirm(`Удалить пресет «${preset.name}»?`)) return;
busy = true;
error = '';
try {
await onDelete(preset.name);
} catch (e) {
error = (e as Error).message || 'Не удалось удалить пресет';
} finally {
busy = false;
}
}
function pickFile() {
fileInput?.click();
}
async function onFile(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || busy) return;
busy = true;
error = '';
try {
const presetsToImport = parseCurvePresetFile(await file.text());
await onImport(presetsToImport);
open = false;
} catch (e) {
error = (e as Error).message || 'Не удалось импортировать пресеты';
} finally {
busy = false;
input.value = '';
}
}
function exportPresets() {
try {
const blob = new Blob([exportCurvePresetFile(presets)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'galo-curve-presets.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
open = false;
error = '';
} catch (e) {
error = (e as Error).message || 'Не удалось экспортировать пресеты';
}
}
</script>
<svelte:window
onkeydown={(ev) => {
if (dialog && ev.key === 'Escape' && !busy) {
ev.preventDefault();
closeDialog();
}
}}
/>
<div class="relative">
<button
bind:this={trigger}
type="button"
class="text-text-muted hover:text-text flex min-w-0 items-center gap-1 text-xs disabled:opacity-40"
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<span class="max-w-[10rem] truncate">{activeName}</span>
<Icon name="expand_more" class="size-3" />
</button>
{#if open}
<button
use:portal
type="button"
class="fixed inset-0 z-[75] cursor-default"
aria-label="Закрыть меню пресетов"
onclick={() => (open = false)}
></button>
<div
use:portal
bind:this={menuElement}
class="border-border bg-bg-elevated fixed z-[80] overflow-y-auto rounded-md border p-1 shadow-lg"
style={menuStyle}
role="menu"
>
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">Встроенные</p>
{#each BUILTIN_CURVE_PRESETS as preset (preset.name)}
<button
type="button"
class="hover:bg-bg-muted flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-xs"
role="menuitem"
onclick={() => apply(preset)}
>
<span class="flex-1 truncate">{preset.name}</span>
{#if preset.name === activeName}
<Icon name="check" class="text-primary size-3.5" />
{/if}
</button>
{/each}
{#if presets.length}
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">
Пользовательские
</p>
{#each presets as preset (preset.name)}
<div class="hover:bg-bg-muted flex items-center rounded">
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-xs"
role="menuitem"
onclick={() => apply(preset)}
>
<span class="truncate">{preset.name}</span>
{#if preset.name === activeName}
<Icon name="check" class="text-primary ml-auto size-3.5" />
{/if}
</button>
<button
type="button"
class="text-text-muted hover:text-text px-1.5 py-1.5"
title="Переименовать пресет"
aria-label="Переименовать пресет {preset.name}"
onclick={() => startRename(preset)}
>
<Icon name="edit" class="size-3.5" />
</button>
<button
type="button"
class="text-text-muted hover:text-danger px-1.5 py-1.5"
title="Удалить пресет"
aria-label="Удалить пресет {preset.name}"
onclick={() => void removePreset(preset)}
>
<Icon name="delete" class="size-3.5" />
</button>
</div>
{/each}
{/if}
<div class="border-border mt-1 space-y-0.5 border-t pt-1">
<button
type="button"
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
onclick={startSave}
>
<Icon name="save" class="size-3.5" />
Сохранить…
</button>
<button
type="button"
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
onclick={pickFile}
>
<Icon name="cloud_upload" class="size-3.5" />
Импортировать…
</button>
<button
type="button"
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
onclick={exportPresets}
>
<Icon name="download" class="size-3.5" />
Экспортировать…
</button>
</div>
{#if error}
<p class="text-danger px-2 py-1 text-[11px]">{error}</p>
{/if}
</div>
{/if}
<input
bind:this={fileInput}
type="file"
accept="application/json,.json"
class="hidden"
tabindex="-1"
aria-hidden="true"
onchange={onFile}
/>
</div>
{#if dialog}
<div
use:portal
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-label={dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
tabindex="-1"
onclick={(ev) => {
if (ev.target === ev.currentTarget) closeDialog();
}}
onkeydown={(ev) => {
if (ev.key === 'Escape') closeDialog();
}}
>
<form
class="border-border bg-bg-elevated w-full max-w-sm rounded-lg border p-4 shadow-xl"
onsubmit={submit}
>
<h3 class="text-text text-sm font-semibold">
{dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
</h3>
<p class="text-text-muted mt-1 text-xs">
{dialog === 'save' ? 'Кривые всех каналов: RGB, R, G, B' : `Было: ${renameFrom}`}
</p>
<input
bind:this={nameInput}
bind:value={nameDraft}
maxlength={MAX_CURVE_PRESET_NAME}
class="border-border bg-bg text-text focus:border-primary mt-3 w-full rounded border px-2 py-1.5 text-sm outline-none"
placeholder="Название пресета"
aria-label="Название пресета"
/>
{#if error}
<p class="text-danger mt-1 text-xs">{error}</p>
{/if}
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
class="border-border text-text-muted hover:text-text rounded-md border px-3 py-1.5 text-xs disabled:opacity-40"
disabled={busy}
onclick={closeDialog}
>
Отмена
</button>
<button
type="submit"
class="bg-primary text-bg rounded-md px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
disabled={busy}
>
{busy ? 'Сохраняем…' : 'Сохранить'}
</button>
</div>
</form>
</div>
{/if}
@@ -0,0 +1,109 @@
<script lang="ts">
interface Props {
id?: string;
label: string;
value: number;
min: number;
max: number;
step: number;
disabled?: boolean;
/** Полный градиент дорожки (например, для цветовых ползунков). */
gradient?: string;
/** Значение при двойном клике. */
resetValue?: number;
format?: (value: number) => string;
oninput?: (value: number) => void;
onchange?: () => void;
}
let {
id,
label,
value,
min,
max,
step,
disabled = false,
gradient,
resetValue = 0,
format,
oninput,
onchange
}: Props = $props();
const percent = $derived(((value - min) / (max - min)) * 100);
const trackStyle = $derived(
gradient
? `background: ${gradient};`
: `background: linear-gradient(to right, #cfcfcf 0%, #cfcfcf ${percent}%, #4a4a4a ${percent}%, #4a4a4a 100%);`
);
const display = $derived(format ? format(value) : String(value));
function handleInput(event: Event) {
oninput?.(Number((event.currentTarget as HTMLInputElement).value));
}
function handleDoubleClick(event: MouseEvent) {
event.preventDefault();
if (disabled) return;
oninput?.(resetValue);
onchange?.();
}
</script>
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for={id}>{label}</label>
<span class="text-text-muted text-xs tabular-nums">{display}</span>
</div>
<input
{id}
type="range"
class="edit-slider"
style={trackStyle}
{min}
{max}
{step}
{disabled}
{value}
oninput={handleInput}
onchange={onchange}
ondblclick={handleDoubleClick}
/>
</div>
<style>
.edit-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2px;
border-radius: 999px;
outline: none;
cursor: pointer;
}
.edit-slider:disabled {
cursor: default;
opacity: 0.4;
}
.edit-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #f3f3f3;
border: 1px solid #9a9a9a;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
.edit-slider::-moz-range-thumb {
width: 15px;
height: 15px;
border-radius: 50%;
background: #f3f3f3;
border: 1px solid #9a9a9a;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
</style>
File diff suppressed because it is too large Load Diff
+25 -11
View File
@@ -19,19 +19,28 @@
photo: Photo; photo: Photo;
role: string; role: string;
viewer?: boolean; viewer?: boolean;
tab?: 'info' | 'edit';
onOpen?: () => void; onOpen?: () => void;
onRenamed?: (photo: Photo) => void; onRenamed?: (photo: Photo) => void;
onDeleted?: () => void; onDeleted?: () => void;
} }
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props(); let {
photo,
role,
viewer = false,
tab = $bindable('info'),
onOpen,
onRenamed,
onDeleted
}: Props = $props();
const canEdit = $derived(role === 'owner' || role === 'admin'); const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id); const canRename = $derived(canEdit || photo?.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id); const canDelete = $derived(canEdit || photo?.uploader_id === auth.user?.id);
// Правки может вносить и загрузивший фото, если у него есть право загрузки. // Правки может вносить и загрузивший фото, если у него есть право загрузки.
const canEditPhoto = $derived( const canEditPhoto = $derived(
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer') canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer')
); );
let editingName = $state(false); let editingName = $state(false);
@@ -39,8 +48,6 @@
let savingName = $state(false); let savingName = $state(false);
let nameError = $state(''); let nameError = $state('');
let tab = $state<'info' | 'edit'>('info');
let pendingDelete = $state(false); let pendingDelete = $state(false);
let deleting = $state(false); let deleting = $state(false);
@@ -68,15 +75,16 @@
// фото (например, отрендеренные правки) не должны сбрасывать вкладку. // фото (например, отрендеренные правки) не должны сбрасывать вкладку.
let lastPhotoId = ''; let lastPhotoId = '';
$effect(() => { $effect(() => {
const id = photo.id; const id = photo?.id;
if (id === lastPhotoId) return; if (!id || id === lastPhotoId) return;
lastPhotoId = id; lastPhotoId = id;
editingName = false; editingName = false;
nameError = ''; nameError = '';
showAll = false; showAll = false;
tagQuery = ''; tagQuery = '';
tagOpen = false; tagOpen = false;
tab = 'info'; // Вкладку не сбрасываем: при переходе между фото пользователь остаётся
// на том же разделе правой панели.
}); });
let tagQuery = $state(''); let tagQuery = $state('');
@@ -255,7 +263,12 @@ const tabClass =
{#if tab === 'edit'} {#if tab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit"> <div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} /> <PhotoEditPanel
{photo}
canEdit={canEditPhoto}
viewer={viewer}
onUpdated={(updated) => onRenamed?.(updated)}
/>
</div> </div>
{:else} {:else}
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info"> <div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
@@ -504,7 +517,8 @@ const tabClass =
labels={prefs.photo_info?.labels ?? {}} labels={prefs.photo_info?.labels ?? {}}
busy={savingPrefs} busy={savingPrefs}
onSave={savePreferences} onSave={savePreferences}
onReset={() => savePreferences({})} onReset={() =>
savePreferences({ photo_info: { visible: [...EXIF_BASE], labels: {} } })}
onClose={() => (settingsOpen = false)} onClose={() => (settingsOpen = false)}
/> />
{/if} {/if}
+198 -35
View File
@@ -10,9 +10,13 @@
import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte'; import { viewer } from '$lib/stores/viewer.svelte';
import { edit } from '$lib/stores/edit.svelte'; import { edit } from '$lib/stores/edit.svelte';
import { applyLight, initLightWasm } from '$lib/light-wasm'; import { eyedropper } from '$lib/stores/eyedropper.svelte';
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
import { WebGLPreview } from '$lib/webgl-preview';
import { import {
isEmptyColor,
isEmptyEditParams, isEmptyEditParams,
isEmptyLight,
photoFullUrl, photoFullUrl,
photoPreviewUrl, photoPreviewUrl,
readEditParams readEditParams
@@ -30,10 +34,15 @@
let fresh = $state<Photo | null>(null); let fresh = $state<Photo | null>(null);
let touchX = 0; let touchX = 0;
let previewLow = $state(false);
let useGL = $state(false);
const current = $derived(photos[index]); const current = $derived(photos[index]);
const MAX_ZOOM = 2; const MAX_ZOOM = 2;
// В зуме фильтруем уменьшенную копию вьюпорта: предпросмотр остаётся
// отзывчивым, а финальное качество даёт серверный рендер.
const PREVIEW_MAX = 560;
// Шаг зума клавишами `+`/`-`. // Шаг зума клавишами `+`/`-`.
const ZOOM_STEP = 1.25; const ZOOM_STEP = 1.25;
@@ -97,7 +106,15 @@
// Пока браузер рисует правки сам, под ним лежит исходная версия фото. // Пока браузер рисует правки сам, под ним лежит исходная версия фото.
const clientSource = $derived( const clientSource = $derived(
zoomed ? originalSrc || basePreviewSrc : basePreviewSrc useGL
? zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
: previewLow
? basePreviewSrc
: zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
); );
const displaySrc = $derived( const displaySrc = $derived(
clientPreview clientPreview
@@ -152,8 +169,9 @@
}); });
// In zoom mode, preload the full-resolution original and swap it in once loaded. // In zoom mode, preload the full-resolution original and swap it in once loaded.
// Во время правок full-res не грузим: превью рисуется из base preview.
$effect(() => { $effect(() => {
if (!zoomed || hiResLoaded) return; if (!zoomed || hiResLoaded || clientPreview) return;
const url = fullSrc; const url = fullSrc;
if (!url) return; if (!url) return;
const im = new Image(); const im = new Image();
@@ -179,25 +197,73 @@
// Пока сервер не отрендерил отредактированную версию, видимую область // Пока сервер не отрендерил отредактированную версию, видимую область
// фото рисует сам браузер: правки видны сразу, без ожидания рендера. // фото рисует сам браузер: правки видны сразу, без ожидания рендера.
let canvas: HTMLCanvasElement | undefined = $state(); let canvas: HTMLCanvasElement | undefined = $state();
let glCanvas: HTMLCanvasElement | undefined = $state();
let glPreview: WebGLPreview | null = null;
let viewportFrame: number | null = null; let viewportFrame: number | null = null;
$effect(() => {
const el = glCanvas;
if (!el) return;
const preview = WebGLPreview.create(el);
glPreview = preview;
useGL = !!preview;
return () => {
preview?.dispose();
glPreview = null;
useGL = false;
};
});
// Пока пользователь активно крутит контролы, фильтруем уменьшенный буфер,
// а после короткой паузы — полный кадр. Иначе тяжёлый фильтр блокирует UI.
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function markInteracting() {
previewLow = true;
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = null;
previewLow = false;
}, 180);
}
$effect(() => () => {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
});
/** /**
* Рисует видимую область фото с применёнными правками. Работает и в зуме, * Рисует видимую область фото с применёнными правками. Сначала пробует
* и в обычном виде: берём ту часть картинки, что попала во вьюпорт. * WebGL (кадр считает GPU), затем CPU-фолбэк.
*/ */
function renderViewport() { function renderViewport() {
const c = canvas;
const img = imgEl; const img = imgEl;
const el = scroller; const el = scroller;
if (!c || !img || !el) return; if (!img || !el) return;
const natural = img.naturalWidth; const natural = img.naturalWidth;
if (!natural) return; if (!natural) return;
const imgRect = img.getBoundingClientRect(); const imgRect = img.getBoundingClientRect();
const viewRect = el.getBoundingClientRect(); const viewRect = el.getBoundingClientRect();
if (!imgRect.width || !viewRect.width) return; if (!imgRect.width || !viewRect.width) return;
const ratio = imgRect.width / natural; const width = el.clientWidth;
const height = el.clientHeight;
if (!width || !height) return;
// Видимая часть картинки. // Кусок изображения и его место на холсте.
let crop: { x: number; y: number; width: number; height: number };
let dest: { x: number; y: number; width: number; height: number };
if (!zoomed) {
crop = { x: 0, y: 0, width: natural, height: img.naturalHeight };
dest = {
x: imgRect.left - viewRect.left,
y: imgRect.top - viewRect.top,
width: imgRect.width,
height: imgRect.height
};
} else {
const ratio = imgRect.width / natural;
const left = Math.max(imgRect.left, viewRect.left); const left = Math.max(imgRect.left, viewRect.left);
const top = Math.max(imgRect.top, viewRect.top); const top = Math.max(imgRect.top, viewRect.top);
const right = Math.min(imgRect.right, viewRect.right); const right = Math.min(imgRect.right, viewRect.right);
@@ -205,35 +271,61 @@
const visibleW = right - left; const visibleW = right - left;
const visibleH = bottom - top; const visibleH = bottom - top;
if (visibleW <= 0 || visibleH <= 0) return; if (visibleW <= 0 || visibleH <= 0) return;
crop = {
const width = el.clientWidth; x: (left - imgRect.left) / ratio,
const height = el.clientHeight; y: (top - imgRect.top) / ratio,
if (!width || !height) return; width: visibleW / ratio,
if (c.width !== width || c.height !== height) { height: visibleH / ratio
c.width = width; };
c.height = height; dest = {
x: left - viewRect.left,
y: top - viewRect.top,
width: visibleW,
height: visibleH
};
} }
c.style.width = `${width}px`;
c.style.height = `${height}px`;
// GPU-путь: фильтр целиком на видеокарте, main thread свободен.
if (useGL && glPreview) {
const ok = glPreview.render({
image: img,
imageVersion: `${img.currentSrc || img.src}|${natural}|${img.naturalHeight}`,
imageWidth: natural,
imageHeight: img.naturalHeight,
crop,
viewport: { width, height },
dest,
light: liveParams.light,
color: liveParams.color
});
if (ok) return;
useGL = false;
}
// CPU-фолбэк (если WebGL недоступен).
const c = canvas;
if (!c) return;
const ctx = c.getContext('2d'); const ctx = c.getContext('2d');
if (!ctx) return; if (!ctx) return;
ctx.clearRect(0, 0, width, height); const lowPx = isEmptyLight(liveParams.light) ? 900 : PREVIEW_MAX;
const maxPx = previewLow ? lowPx : 4096;
// Исходный кусок в пикселях картинки и его место на холсте. const k = Math.min(1, maxPx / Math.max(dest.width, dest.height));
const sx = (left - imgRect.left) / ratio; const bw = Math.max(1, Math.round(dest.width * k));
const sy = (top - imgRect.top) / ratio; const bh = Math.max(1, Math.round(dest.height * k));
const sw = visibleW / ratio; if (c.width !== bw || c.height !== bh) {
const sh = visibleH / ratio; c.width = bw;
const dx = Math.round(left - viewRect.left); c.height = bh;
const dy = Math.round(top - viewRect.top); }
const dw = Math.round(visibleW); c.style.left = `${Math.round(dest.x)}px`;
const dh = Math.round(visibleH); c.style.top = `${Math.round(dest.y)}px`;
c.style.width = `${Math.round(dest.width)}px`;
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); c.style.height = `${Math.round(dest.height)}px`;
const data = ctx.getImageData(dx, dy, dw, dh); ctx.clearRect(0, 0, bw, bh);
applyLight(data.data, liveParams.light); ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, bw, bh);
ctx.putImageData(data, dx, dy); const frame = ctx.getImageData(0, 0, bw, bh);
if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light);
if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color);
ctx.putImageData(frame, 0, 0);
} }
function scheduleViewport() { function scheduleViewport() {
@@ -250,10 +342,22 @@
zoomed; zoomed;
scale; scale;
liveParams.light; liveParams.light;
liveParams.color;
displaySrc; displaySrc;
previewLow;
useGL;
scheduleViewport(); scheduleViewport();
}); });
// Любое изменение параметров — это активное взаимодействие.
let lastLiveParams: typeof liveParams | null = null;
$effect(() => {
const p = liveParams;
if (p === lastLiveParams) return;
lastLiveParams = p;
markInteracting();
});
$effect(() => { $effect(() => {
const onFs = () => { const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl; isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
@@ -439,6 +543,7 @@
if (!current || !scroller) return; if (!current || !scroller) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
markInteracting();
const natW = baseWidth(); const natW = baseWidth();
const natH = baseHeight(); const natH = baseHeight();
@@ -454,6 +559,7 @@
} }
function onPointerDown(ev: PointerEvent) { function onPointerDown(ev: PointerEvent) {
if (eyedropper.mode !== 'none') return;
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return; if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
dragging = true; dragging = true;
startX = ev.clientX; startX = ev.clientX;
@@ -483,6 +589,46 @@
scheduleViewport(); scheduleViewport();
} }
/** Пипетка: сэмплирует пиксель базового изображения под курсором. */
async function onImageClick(ev: MouseEvent) {
const photo = current;
if (!photo || !imgEl || eyedropper.mode === 'none') return;
const rect = imgEl.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = (ev.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
ev.preventDefault();
ev.stopPropagation();
const src = (zoomed ? originalSrc : basePreviewSrc) || basePreviewSrc;
if (!src) return;
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const px = Math.min(
img.naturalWidth - 1,
Math.max(0, Math.round(x * (img.naturalWidth - 1)))
);
const py = Math.min(
img.naturalHeight - 1,
Math.max(0, Math.round(y * (img.naturalHeight - 1)))
);
const data = ctx.getImageData(px, py, 1, 1).data;
eyedropper.report(photo.id, data[0], data[1], data[2]);
} catch {
/* изображение не загрузилось — тихо выходим */
}
}
async function toggleViewerFullscreen() { async function toggleViewerFullscreen() {
if (!viewerEl) return; if (!viewerEl) return;
try { try {
@@ -730,7 +876,10 @@
oncontextmenu={onContextMenu} oncontextmenu={onContextMenu}
ontouchstart={onTouchStart} ontouchstart={onTouchStart}
ontouchend={onTouchEnd} ontouchend={onTouchEnd}
onscroll={scheduleViewport} onscroll={() => {
markInteracting();
scheduleViewport();
}}
> >
{#if current} {#if current}
<img <img
@@ -746,12 +895,26 @@
/> />
{/if} {/if}
</div> </div>
{#if eyedropper.mode !== 'none' && eyedropper.photoId === current?.id}
<button
type="button"
class="absolute inset-0 z-20 cursor-crosshair"
aria-label="Выберите нейтральную точку на фото"
onclick={onImageClick}
></button>
{/if}
{#if clientPreview} {#if clientPreview}
<!-- Правки видны сразу: браузер рисует видимую область, пока <!-- Правки видны сразу: браузер рисует видимую область, пока
сервер готовит отредактированную версию фото. --> сервер готовит отредактированную версию фото. -->
<canvas <canvas
bind:this={canvas} bind:this={canvas}
class="pointer-events-none absolute inset-0 z-10 select-none" class="pointer-events-none absolute inset-0 z-10 select-none"
class:hidden={useGL}
></canvas>
<canvas
bind:this={glCanvas}
class="pointer-events-none absolute inset-0 z-10 select-none"
class:hidden={!useGL}
></canvas> ></canvas>
{/if} {/if}
</div> </div>
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import {
BUILTIN_CURVE_PRESETS,
MAX_CURVE_PRESETS,
exportCurvePresetFile,
findActiveCurvePreset,
normalizeCurvePreset,
normalizeCurvePresets,
parseCurvePresetFile,
presetPointsEqual,
upsertCurvePresets
} from './curve-presets';
import type { CurvePreset } from './types';
const linear = () => [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
];
const medium = BUILTIN_CURVE_PRESETS.find((preset) => preset.name === 'Средний контраст')!;
const custom: CurvePreset = {
name: 'Мой',
channels: {
rgb: [
{ x: 0, y: 0 },
{ x: 0.5, y: 0.6 },
{ x: 1, y: 1 }
],
red: linear(),
green: linear(),
blue: linear()
}
};
describe('пресеты кривой', () => {
it('содержит встроенные пресеты со всеми четырьмя каналами', () => {
expect(BUILTIN_CURVE_PRESETS.map((preset) => preset.name)).toEqual([
'Линейный',
'Средний контраст',
'Сильный контраст'
]);
for (const preset of BUILTIN_CURVE_PRESETS) {
expect(Object.keys(preset.channels).sort()).toEqual(['blue', 'green', 'red', 'rgb']);
for (const channel of ['rgb', 'red', 'green', 'blue'] as const) {
expect(preset.channels[channel].length).toBeGreaterThanOrEqual(2);
}
}
// Контраст задаётся только мастер-кривой, остальные каналы линейные.
expect(medium.channels.rgb).not.toEqual(linear());
expect(medium.channels.red).toEqual(linear());
});
it('валидирует и нормализует пресет со всеми каналами', () => {
expect(normalizeCurvePreset(custom)).toEqual(custom);
expect(normalizeCurvePreset({ ...custom, name: ' Мой ' })).toEqual(custom);
expect(normalizeCurvePreset({ name: 'x', channels: { rgb: linear() } })).toBeNull();
expect(
normalizeCurvePreset({
...custom,
channels: { ...custom.channels, green: [{ x: 0, y: 0 }] }
})
).toBeNull();
expect(
normalizeCurvePreset({
...custom,
channels: { ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1.2, y: 1 }] }
})
).toBeNull();
expect(normalizeCurvePreset({ ...custom, name: 'a'.repeat(65) })).toBeNull();
});
it('конвертирует старый формат одного канала', () => {
const legacy = normalizeCurvePreset({
name: 'Старый',
channel: 'red',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0.4 }
]
});
expect(legacy?.channels.rgb).toEqual(linear());
expect(legacy?.channels.red).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0.4 }
]);
expect(legacy?.channels.green).toEqual(linear());
expect(legacy?.channels.blue).toEqual(linear());
});
it('сортирует точки и отбрасывает дубликаты', () => {
const preset = normalizeCurvePreset({
...custom,
channels: {
...custom.channels,
rgb: [
{ x: 1, y: 1 },
{ x: 0, y: 0 },
{ x: 0.5, y: 0.6 }
]
}
});
expect(preset?.channels.rgb.map((point) => point.x)).toEqual([0, 0.5, 1]);
});
it('нормализует список из профиля и убирает повторы имён', () => {
expect(normalizeCurvePresets([custom, custom, { name: 'bad' }])).toEqual([custom]);
expect(normalizeCurvePresets('nope')).toEqual([]);
});
it('разбирает и сериализует файл экспорта', () => {
const text = exportCurvePresetFile([custom]);
expect(parseCurvePresetFile(text)).toEqual([custom]);
expect(() => parseCurvePresetFile('not json')).toThrow();
expect(() => parseCurvePresetFile(JSON.stringify({ version: 2, presets: [] }))).toThrow();
expect(() => parseCurvePresetFile(JSON.stringify({ version: 1, presets: [{ name: 'x' }] }))).toThrow();
expect(() =>
parseCurvePresetFile(JSON.stringify({ version: 1, presets: [custom, custom] }))
).toThrow();
});
it('добавляет и перезаписывает пресеты по имени', () => {
const changed: CurvePreset = {
name: 'Мой',
channels: { ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] }
};
const next = upsertCurvePresets([custom], [changed]);
expect(next).toHaveLength(1);
expect(next[0].channels.blue).toEqual(changed.channels.blue);
// Копии не должны делить массивы с исходным пресетом.
expect(next[0].channels.red).not.toBe(custom.channels.red);
const other: CurvePreset = { ...custom, name: 'Другой' };
expect(upsertCurvePresets([custom], [other]).map((preset) => preset.name)).toEqual([
'Мой',
'Другой'
]);
expect(() =>
upsertCurvePresets(
Array.from({ length: MAX_CURVE_PRESETS }, (_, i) => ({ ...custom, name: `p${i}` })),
[other]
)
).toThrow();
});
it('находит активный пресет по кривым всех каналов', () => {
expect(findActiveCurvePreset(medium.channels, [])?.name).toBe('Средний контраст');
expect(findActiveCurvePreset(custom.channels, [custom])?.name).toBe('Мой');
expect(
findActiveCurvePreset(
{ ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] },
[custom]
)
).toBeUndefined();
expect(
findActiveCurvePreset(
{ rgb: linear(), red: linear(), green: linear(), blue: linear() },
[]
)?.name
).toBe('Линейный');
});
it('сравнивает точки с допуском', () => {
expect(presetPointsEqual([{ x: 0, y: 0 }], [{ x: 1e-9, y: -1e-9 }])).toBe(true);
expect(presetPointsEqual([{ x: 0, y: 0 }], [{ x: 0.1, y: 0 }])).toBe(false);
});
});
+239
View File
@@ -0,0 +1,239 @@
import {
CURVE_CHANNELS,
MAX_CURVE_POINTS,
normalizeCurve,
type CurveChannel,
type CurvePoint
} from './edit';
import type { CurvePreset, CurvePresetChannels } from './types';
export const MAX_CURVE_PRESETS = 100;
export const MAX_CURVE_PRESET_NAME = 64;
export const CURVE_PRESET_FILE_VERSION = 1;
function linearPoints(): CurvePoint[] {
return [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
];
}
function rgbContrastPoints(strong: boolean): CurvePoint[] {
return strong
? [
{ x: 0, y: 0 },
{ x: 0.2, y: 0.12 },
{ x: 0.5, y: 0.5 },
{ x: 0.8, y: 0.88 },
{ x: 1, y: 1 }
]
: [
{ x: 0, y: 0 },
{ x: 0.25, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.75, y: 0.8 },
{ x: 1, y: 1 }
];
}
function linearChannels(rgb: CurvePoint[]): CurvePresetChannels {
return {
rgb,
red: linearPoints(),
green: linearPoints(),
blue: linearPoints()
};
}
/** Встроенные пресеты доступны всегда и не сохраняются в профиле. */
export const BUILTIN_CURVE_PRESETS: CurvePreset[] = [
{ name: 'Линейный', channels: linearChannels(linearPoints()) },
{ name: 'Средний контраст', channels: linearChannels(rgbContrastPoints(false)) },
{ name: 'Сильный контраст', channels: linearChannels(rgbContrastPoints(true)) }
];
/** Обрезает имя пресета; null — имя пустое или слишком длинное. */
export function normalizeCurvePresetName(name: unknown): string | null {
if (typeof name !== 'string') return null;
const trimmed = name.trim();
if (!trimmed) return null;
if ([...trimmed].length > MAX_CURVE_PRESET_NAME) return null;
return trimmed;
}
function presetPoints(raw: unknown): CurvePoint[] | null {
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_CURVE_POINTS) return null;
const points: CurvePoint[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') return null;
const point = item as { x?: unknown; y?: unknown };
if (
typeof point.x !== 'number' ||
typeof point.y !== 'number' ||
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
point.x < 0 ||
point.x > 1 ||
point.y < 0 ||
point.y > 1
) {
return null;
}
points.push({ x: point.x, y: point.y });
}
const normalized = normalizeCurve(points);
if (!normalized || normalized.length < 2) return null;
return normalized;
}
function presetChannels(raw: unknown): CurvePresetChannels | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, unknown>;
const channels = {} as CurvePresetChannels;
for (const channel of CURVE_CHANNELS) {
const points = presetPoints(item[channel]);
if (!points) return null;
channels[channel] = points;
}
return channels;
}
/** Старый формат `{name, channel, points}`: остальные каналы линейные. */
function legacyChannels(item: Record<string, unknown>): CurvePresetChannels | null {
if (typeof item.channel !== 'string') return null;
if (!CURVE_CHANNELS.includes(item.channel as CurveChannel)) return null;
const points = presetPoints(item.points);
if (!points) return null;
const channels = {
rgb: linearPoints(),
red: linearPoints(),
green: linearPoints(),
blue: linearPoints()
};
channels[item.channel as CurveChannel] = points;
return channels;
}
/** Проверяет и нормализует один пресет; null — пресет некорректен. */
export function normalizeCurvePreset(raw: unknown): CurvePreset | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, unknown>;
const name = normalizeCurvePresetName(item.name);
if (!name) return null;
const channels =
item.channels !== undefined ? presetChannels(item.channels) : legacyChannels(item);
if (!channels) return null;
return { name, channels };
}
/** Нормализует список из профиля: некорректные пресеты отбрасываются. */
export function normalizeCurvePresets(raw: unknown): CurvePreset[] {
if (!Array.isArray(raw)) return [];
const out: CurvePreset[] = [];
const names = new Set<string>();
for (const item of raw) {
const preset = normalizeCurvePreset(item);
if (!preset || names.has(preset.name)) continue;
names.add(preset.name);
out.push(preset);
if (out.length >= MAX_CURVE_PRESETS) break;
}
return out;
}
/** Разбирает файл импорта. Любая ошибка отменяет импорт целиком. */
export function parseCurvePresetFile(text: string): CurvePreset[] {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error('Файл не является корректным JSON');
}
if (!data || typeof data !== 'object') {
throw new Error('Файл должен содержать объект с пресетами');
}
const file = data as Record<string, unknown>;
if (file.version !== CURVE_PRESET_FILE_VERSION) {
throw new Error('Неподдерживаемая версия файла пресетов');
}
if (!Array.isArray(file.presets)) {
throw new Error('В файле нет списка пресетов');
}
if (file.presets.length > MAX_CURVE_PRESETS) {
throw new Error(`В файле больше ${MAX_CURVE_PRESETS} пресетов`);
}
const presets: CurvePreset[] = [];
const names = new Set<string>();
for (const item of file.presets) {
const preset = normalizeCurvePreset(item);
if (!preset) throw new Error('В файле есть некорректный пресет');
if (names.has(preset.name)) throw new Error(`Пресет «${preset.name}» повторяется`);
names.add(preset.name);
presets.push(preset);
}
return presets;
}
/** Сериализует пользовательские пресеты в формат экспорта. */
export function exportCurvePresetFile(presets: CurvePreset[]): string {
return JSON.stringify({ version: CURVE_PRESET_FILE_VERSION, presets }, null, 2);
}
/** Добавляет или заменяет пресеты по имени. */
export function upsertCurvePresets(
current: CurvePreset[],
incoming: CurvePreset[]
): CurvePreset[] {
const out = current.map((preset) => ({
...preset,
channels: copyChannels(preset.channels)
}));
for (const preset of incoming) {
const index = out.findIndex((item) => item.name === preset.name);
const copy: CurvePreset = {
...preset,
channels: copyChannels(preset.channels)
};
if (index >= 0) out[index] = copy;
else out.push(copy);
}
if (out.length > MAX_CURVE_PRESETS) {
throw new Error(`Не больше ${MAX_CURVE_PRESETS} пресетов`);
}
return out;
}
function copyChannels(channels: CurvePresetChannels): CurvePresetChannels {
return {
rgb: channels.rgb.map((point) => ({ ...point })),
red: channels.red.map((point) => ({ ...point })),
green: channels.green.map((point) => ({ ...point })),
blue: channels.blue.map((point) => ({ ...point }))
};
}
/** Совпадают ли точки кривых (с допуском на округление). */
export function presetPointsEqual(
a: CurvePoint[],
b: CurvePoint[],
epsilon = 1e-6
): boolean {
if (a.length !== b.length) return false;
return a.every(
(point, index) =>
Math.abs(point.x - b[index].x) <= epsilon &&
Math.abs(point.y - b[index].y) <= epsilon
);
}
/** Активный пресет: кривые всех четырёх каналов совпадают с пресетом. */
export function findActiveCurvePreset(
curves: Record<CurveChannel, CurvePoint[]>,
custom: CurvePreset[]
): CurvePreset | undefined {
return [...custom, ...BUILTIN_CURVE_PRESETS].find((preset) =>
CURVE_CHANNELS.every((channel) =>
presetPointsEqual(curves[channel], preset.channels[channel])
)
);
}
+200 -1
View File
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
applyColorToPixels,
buildCurveLUT,
curveSvgPath,
effectiveCurvePoints,
emptyColor,
emptyEditParams, emptyEditParams,
isEmptyColor,
isEmptyEditParams, isEmptyEditParams,
normalizeEditParams, normalizeEditParams,
readEditParams readEditParams,
whiteBalanceFromRGB
} from './edit'; } from './edit';
import type { Photo } from './types'; import type { Photo } from './types';
@@ -56,3 +63,195 @@ describe('параметры правок', () => {
expect(points[1].x).toBeCloseTo(0.8); expect(points[1].x).toBeCloseTo(0.8);
}); });
}); });
describe('цветовые параметры', () => {
it('зажимает значения и оттенки колёс', () => {
const params = normalizeEditParams({
light: emptyEditParams().light,
color: {
...emptyColor(),
temperature: 200,
tint: -300,
saturation: -500,
grading: {
...emptyColor().grading,
shadows: { hue: 400, saturation: 150, luminance: 0 },
balance: -200
}
}
});
expect(params.color.temperature).toBe(100);
expect(params.color.tint).toBe(-100);
expect(params.color.saturation).toBe(-100);
expect(params.color.grading.shadows.hue).toBe(360);
expect(params.color.grading.shadows.saturation).toBe(100);
expect(params.color.grading.balance).toBe(-100);
});
it('считает пустыми нулевые цветовые параметры', () => {
expect(isEmptyColor(emptyColor())).toBe(true);
expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false);
const hueOnly = emptyColor();
hueOnly.grading.shadows.hue = 120;
expect(isEmptyColor(hueOnly)).toBe(true);
const grade = emptyColor();
grade.grading.highlights.saturation = 10;
expect(isEmptyColor(grade)).toBe(false);
});
it('читает цвет из метаданных и не теряет свет', () => {
const photo = {
edit_params: {
light: { exposure: 0.5 },
color: { temperature: 20, vibrance: 10 }
}
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.color.temperature).toBe(20);
expect(params.color.vibrance).toBe(10);
});
it('осветляет тёплым балансом белого', () => {
const pixels = new Uint8ClampedArray([100, 100, 100, 255]);
applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 });
expect(pixels[0]).toBeGreaterThan(pixels[1]);
expect(pixels[1]).toBeGreaterThan(pixels[2]);
});
it('обеспечивает серый цвет при насыщенности -100', () => {
const pixels = new Uint8ClampedArray([200, 50, 50, 255]);
applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 });
expect(pixels[0]).toBe(pixels[1]);
expect(pixels[1]).toBe(pixels[2]);
});
it('тонирует тени синим колесом', () => {
const color = emptyColor();
color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 };
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
applyColorToPixels(pixels, color);
expect(pixels[2]).toBeGreaterThan(pixels[0]);
});
it('повышает яркость теней', () => {
const color = emptyColor();
color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 };
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
applyColorToPixels(pixels, color);
expect(pixels[0]).toBeGreaterThan(40);
});
it('одинаково обрабатывает все строки однородного изображения', () => {
const color = emptyColor();
color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 };
const pixels = new Uint8ClampedArray(64 * 64 * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 40;
pixels[i + 1] = 40;
pixels[i + 2] = 40;
pixels[i + 3] = 255;
}
applyColorToPixels(pixels, color);
for (let i = 4; i < pixels.length; i += 4) {
expect(pixels[i]).toBe(pixels[0]);
expect(pixels[i + 1]).toBe(pixels[1]);
expect(pixels[i + 2]).toBe(pixels[2]);
}
});
it('не трогает прозрачные пиксели', () => {
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
applyColorToPixels(pixels, {
...emptyColor(),
temperature: 100,
saturation: 100
});
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
});
it('подбирает баланс белого по нейтральной точке', () => {
const neutral = whiteBalanceFromRGB(128, 128, 128);
expect(neutral.temperature).toBeCloseTo(0, 5);
expect(neutral.tint).toBeCloseTo(0, 5);
const wb = whiteBalanceFromRGB(150, 140, 120);
const temperature = wb.temperature / 100;
const tint = wb.tint / 100;
const r = 150 * (1 + 0.2 * temperature + 0.1 * tint);
const g = 140 * (1 - 0.2 * tint);
const b = 120 * (1 - 0.2 * temperature + 0.1 * tint);
expect(r).toBeCloseTo(g, 5);
expect(b).toBeCloseTo(g, 5);
});
});
describe('кривая', () => {
const medium = [
{ x: 0, y: 0 },
{ x: 0.25, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.75, y: 0.8 },
{ x: 1, y: 1 }
];
const strong = [
{ x: 0, y: 0 },
{ x: 0.2, y: 0.12 },
{ x: 0.5, y: 0.5 },
{ x: 0.8, y: 0.88 },
{ x: 1, y: 1 }
];
const inner = [
{ x: 0.1, y: 0.05 },
{ x: 0.4, y: 0.3 },
{ x: 0.9, y: 0.95 }
];
const samples = [0, 32, 64, 128, 192, 224, 255];
it('показывает крайние точки у пустого канала', () => {
expect(effectiveCurvePoints(undefined)).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]);
expect(effectiveCurvePoints([{ x: 0.5, y: 0.4 }])).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]);
expect(effectiveCurvePoints(inner)).toEqual(inner);
});
it('совпадает с серверной PCHIP-кривой', () => {
const values = (points: { x: number; y: number }[]) => {
const lut = buildCurveLUT(points);
return samples.map((i) => lut[i]);
};
expect(values([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([
0, 32, 64, 128, 192, 224, 255
]);
expect(values(medium)).toEqual([0, 23, 51, 128, 205, 233, 255]);
expect(values(strong)).toEqual([0, 17, 43, 128, 213, 239, 255]);
expect(values(inner)).toEqual([13, 17, 42, 104, 186, 234, 242]);
});
it('остаётся монотонной между точками', () => {
for (const points of [medium, strong]) {
const lut = buildCurveLUT(points);
for (let i = 1; i < lut.length; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]);
}
}
});
it('делает плоские полки вне крайних точек', () => {
const lut = buildCurveLUT(inner);
for (let i = 0; i <= Math.floor(0.1 * 255); i++) expect(lut[i]).toBe(13);
for (let i = Math.ceil(0.9 * 255); i < 256; i++) expect(lut[i]).toBe(242);
});
it('рисует плавный SVG-путь с полками', () => {
const path = curveSvgPath(inner);
expect(path.startsWith('M0.0000,95.0000 L10.0000,95.0000')).toBe(true);
expect(path).toContain('C');
expect(path.endsWith('L100.0000,5.0000')).toBe(true);
});
});
+521 -23
View File
@@ -30,10 +30,91 @@ export interface LightParams {
curve: CurveParams; curve: CurveParams;
} }
export interface MixerBand {
hue: number;
saturation: number;
luminance: number;
}
export interface ColorMixer {
red: MixerBand;
orange: MixerBand;
yellow: MixerBand;
green: MixerBand;
aqua: MixerBand;
blue: MixerBand;
purple: MixerBand;
magenta: MixerBand;
}
export interface ColorWheel {
hue: number;
saturation: number;
luminance: number;
}
export interface ColorGrading {
shadows: ColorWheel;
midtones: ColorWheel;
highlights: ColorWheel;
blending: number;
balance: number;
}
export interface ColorCorrection {
red: number;
green: number;
blue: number;
}
export interface ColorParams {
temperature: number;
tint: number;
vibrance: number;
saturation: number;
mixer: ColorMixer;
grading: ColorGrading;
correction: ColorCorrection;
}
export interface EditParams { export interface EditParams {
light: LightParams; light: LightParams;
color: ColorParams;
} }
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
export const MIXER_BANDS = [
{ key: 'red', label: 'Красное', center: 0 },
{ key: 'orange', label: 'Оранжевое', center: 30 },
{ key: 'yellow', label: 'Жёлтое', center: 60 },
{ key: 'green', label: 'Зелёное', center: 120 },
{ key: 'aqua', label: 'Бирюзовое', center: 180 },
{ key: 'blue', label: 'Синее', center: 240 },
{ key: 'purple', label: 'Фиолетовое', center: 280 },
{ key: 'magenta', label: 'Пурпурное', center: 320 }
] as const;
export type MixerBandKey = (typeof MIXER_BANDS)[number]['key'];
/** Ползунки базового блока «Цвет». */
export const COLOR_SLIDERS = [
{ key: 'temperature', label: 'Темп.', min: -100, max: 100, step: 1 },
{ key: 'tint', label: 'Оттенок', min: -100, max: 100, step: 1 },
{ key: 'vibrance', label: 'Сочность', min: -100, max: 100, step: 1 },
{ key: 'saturation', label: 'Насыщенность', min: -100, max: 100, step: 1 }
] as const;
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key'];
/** Колёса точечного цвета. */
export const GRADING_WHEELS = [
{ key: 'shadows', label: 'Тени' },
{ key: 'midtones', label: 'Средние тона' },
{ key: 'highlights', label: 'Света' }
] as const;
export type GradingWheelKey = (typeof GRADING_WHEELS)[number]['key'];
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const; export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
export type CurveChannel = (typeof CURVE_CHANNELS)[number]; export type CurveChannel = (typeof CURVE_CHANNELS)[number];
@@ -45,7 +126,8 @@ export const CHANNEL_LABELS: Record<CurveChannel, string> = {
}; };
export const CHANNEL_COLORS: Record<CurveChannel, string> = { export const CHANNEL_COLORS: Record<CurveChannel, string> = {
rgb: 'var(--text)', // --text в теме хранит компоненты HSL, поэтому цвет собирается через hsl().
rgb: 'hsl(var(--text))',
red: '#ef4444', red: '#ef4444',
green: '#22c55e', green: '#22c55e',
blue: '#3b82f6' blue: '#3b82f6'
@@ -72,6 +154,12 @@ export const MAX_EXPOSURE_STOPS = 5;
export const MAX_PERCENT = 100; export const MAX_PERCENT = 100;
export const MAX_CURVE_POINTS = 16; export const MAX_CURVE_POINTS = 16;
/** Крайние точки кривой по умолчанию: линейная кривая. */
export const DEFAULT_CURVE_POINTS: CurvePoint[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
];
export function emptyLight(): LightParams { export function emptyLight(): LightParams {
return { return {
exposure: 0, exposure: 0,
@@ -85,11 +173,42 @@ export function emptyLight(): LightParams {
} }
export function emptyEditParams(): EditParams { export function emptyEditParams(): EditParams {
return { light: emptyLight() }; return { light: emptyLight(), color: emptyColor() };
} }
function clamp(v: number, min: number, max: number): number { function emptyMixerBand(): MixerBand {
if (!Number.isFinite(v)) return 0; return { hue: 0, saturation: 0, luminance: 0 };
}
export function emptyColor(): ColorParams {
return {
temperature: 0,
tint: 0,
vibrance: 0,
saturation: 0,
mixer: {
red: emptyMixerBand(),
orange: emptyMixerBand(),
yellow: emptyMixerBand(),
green: emptyMixerBand(),
aqua: emptyMixerBand(),
blue: emptyMixerBand(),
purple: emptyMixerBand(),
magenta: emptyMixerBand()
},
grading: {
shadows: { hue: 0, saturation: 0, luminance: 0 },
midtones: { hue: 0, saturation: 0, luminance: 0 },
highlights: { hue: 0, saturation: 0, luminance: 0 },
blending: 50,
balance: 0
},
correction: { red: 0, green: 0, blue: 0 }
};
}
function clamp(v: number | undefined, min: number, max: number): number {
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
@@ -112,7 +231,10 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
} }
/** Приводит параметры к допустимым диапазонам. */ /** Приводит параметры к допустимым диапазонам. */
export function normalizeEditParams(params: EditParams): EditParams { export function normalizeEditParams(params: {
light?: Partial<LightParams>;
color?: Partial<ColorParams>;
}): EditParams {
const light = params?.light ?? emptyLight(); const light = params?.light ?? emptyLight();
return { return {
light: { light: {
@@ -128,13 +250,64 @@ export function normalizeEditParams(params: EditParams): EditParams {
green: normalizeCurve(light.curve?.green), green: normalizeCurve(light.curve?.green),
blue: normalizeCurve(light.curve?.blue) blue: normalizeCurve(light.curve?.blue)
} }
},
color: normalizeColor(params?.color)
};
}
function normalizeBand(band?: Partial<MixerBand>): MixerBand {
return {
hue: clamp(band?.hue ?? 0, -MAX_PERCENT, MAX_PERCENT),
saturation: clamp(band?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
luminance: clamp(band?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
};
}
function normalizeWheel(wheel?: Partial<ColorWheel>): ColorWheel {
return {
hue: clamp(wheel?.hue ?? 0, 0, 360),
saturation: clamp(wheel?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
luminance: clamp(wheel?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
};
}
/** Приводит цветовые параметры к допустимым диапазонам. */
export function normalizeColor(color?: Partial<ColorParams>): ColorParams {
const mixer: Partial<ColorMixer> = color?.mixer ?? {};
const grading: Partial<ColorGrading> = color?.grading ?? {};
const correction: Partial<ColorCorrection> = color?.correction ?? {};
return {
temperature: clamp(color?.temperature ?? 0, -MAX_PERCENT, MAX_PERCENT),
tint: clamp(color?.tint ?? 0, -MAX_PERCENT, MAX_PERCENT),
vibrance: clamp(color?.vibrance ?? 0, -MAX_PERCENT, MAX_PERCENT),
saturation: clamp(color?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
mixer: {
red: normalizeBand(mixer.red),
orange: normalizeBand(mixer.orange),
yellow: normalizeBand(mixer.yellow),
green: normalizeBand(mixer.green),
aqua: normalizeBand(mixer.aqua),
blue: normalizeBand(mixer.blue),
purple: normalizeBand(mixer.purple),
magenta: normalizeBand(mixer.magenta)
},
grading: {
shadows: normalizeWheel(grading.shadows),
midtones: normalizeWheel(grading.midtones),
highlights: normalizeWheel(grading.highlights),
blending: clamp(grading.blending ?? 50, 0, MAX_PERCENT),
balance: clamp(grading.balance ?? 0, -MAX_PERCENT, MAX_PERCENT)
},
correction: {
red: clamp(correction.red ?? 0, -MAX_PERCENT, MAX_PERCENT),
green: clamp(correction.green ?? 0, -MAX_PERCENT, MAX_PERCENT),
blue: clamp(correction.blue ?? 0, -MAX_PERCENT, MAX_PERCENT)
} }
}; };
} }
/** Пустые ли параметры: фотография не тронута. */ /** Пустой ли блок «Свет». */
export function isEmptyEditParams(params: EditParams): boolean { export function isEmptyLight(light: LightParams): boolean {
const light = params.light;
if ( if (
light.exposure !== 0 || light.exposure !== 0 ||
light.contrast !== 0 || light.contrast !== 0 ||
@@ -153,16 +326,76 @@ export function isEmptyEditParams(params: EditParams): boolean {
return true; return true;
} }
/**
* Цвет можно посчитать быстрым путём (без HSL): не задействованы микшер и
* насыщенность/вибрация — только колёса, яркость зон и температура/оттенок.
*/
export function isColorFast(color: ColorParams): boolean {
if (color.vibrance !== 0 || color.saturation !== 0) return false;
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
if (value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0) return false;
}
return true;
}
/** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): boolean {
return isEmptyLight(params.light) && isEmptyColor(params.color);
}
/** Пусты ли цветовые параметры: цвет не тронут. */
export function isEmptyColor(color?: ColorParams): boolean {
if (!color) return true;
if (color.temperature || color.tint || color.vibrance || color.saturation) return false;
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
if (value?.hue || value?.saturation || value?.luminance) return false;
}
for (const wheel of GRADING_WHEELS) {
const value = color.grading[wheel.key];
// Оттенок колеса без силы или яркости не влияет на картинку.
if (value?.saturation || value?.luminance) return false;
}
const correction = color.correction;
return !correction?.red && !correction?.green && !correction?.blue;
}
/**
* Подбирает баланс белого, при котором нейтральный пиксель (r, g, b) станет
* серым. Возвращает температуру и оттенок в диапазоне -100..100.
*/
export function whiteBalanceFromRGB(
r: number,
g: number,
b: number
): { temperature: number; tint: number } {
const rr = Math.max(1, r);
const gg = Math.max(1, g);
const bb = Math.max(1, b);
const sum = gg * (1 / rr + 1 / bb);
const tint = (sum - 2) / (0.2 * (1 + sum));
const factor = 1 - 0.2 * tint;
const temperature = (gg * (1 / rr - 1 / bb) * factor) / 0.4;
return {
temperature: clamp(temperature * 100, -MAX_PERCENT, MAX_PERCENT),
tint: clamp(tint * 100, -MAX_PERCENT, MAX_PERCENT)
};
}
/** Читает параметры правок из метаданных фото. */ /** Читает параметры правок из метаданных фото. */
export function readEditParams(photo: Photo | null | undefined): EditParams { export function readEditParams(photo: Photo | null | undefined): EditParams {
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined; const raw = photo?.edit_params as
if (!raw?.light) return emptyEditParams(); | { light?: Partial<LightParams>; color?: Partial<ColorParams> }
| undefined;
if (!raw?.light && !raw?.color) return emptyEditParams();
return normalizeEditParams({ return normalizeEditParams({
light: { light: {
...emptyLight(), ...emptyLight(),
...raw.light, ...raw?.light,
curve: raw.light.curve ?? {} curve: raw?.light?.curve ?? {}
} },
color: raw?.color ?? emptyColor()
}); });
} }
@@ -184,6 +417,58 @@ export function photoFullUrl(photo: Photo): string | undefined {
return photo.edit_url ?? photo.original_url; return photo.edit_url ?? photo.original_url;
} }
/**
* Точки для отображения: у пустого или некорректного канала показываем
* линейную кривую с крайними точками. Сохранённые точки остаются источником
* истины, если их хотя бы две.
*/
export function effectiveCurvePoints(points?: CurvePoint[]): CurvePoint[] {
const pts = normalizeCurve(points);
if (!pts || pts.length < 2) return DEFAULT_CURVE_POINTS.map((p) => ({ ...p }));
return pts;
}
/**
* Наклоны в контрольных точках для монотонного кубического Эрмита
* (Fritsch–Carlson). Формула совпадает с серверной реализацией.
*/
function curveTangents(points: CurvePoint[]): number[] {
const n = points.length;
if (n < 2) return [];
const h = new Array<number>(n - 1);
const delta = new Array<number>(n - 1);
for (let i = 0; i < n - 1; i++) {
h[i] = points[i + 1].x - points[i].x;
delta[i] = (points[i + 1].y - points[i].y) / h[i];
}
const m = new Array<number>(n).fill(0);
if (n === 2) {
m[0] = delta[0];
m[1] = delta[0];
return m;
}
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1]);
m[n - 1] = endpointTangent(h[n - 2], h[n - 3], delta[n - 2], delta[n - 3]);
for (let i = 1; i < n - 1; i++) {
if (delta[i - 1] * delta[i] <= 0) {
m[i] = 0;
continue;
}
const w1 = 2 * h[i] + h[i - 1];
const w2 = h[i] + 2 * h[i - 1];
m[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
}
return m;
}
/** Односторонняя касательная крайней точки с защитой от выбросов. */
function endpointTangent(h0: number, h1: number, d0: number, d1: number): number {
const m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
if (m * d0 <= 0) return 0;
if (d0 * d1 < 0 && Math.abs(m) > Math.abs(3 * d0)) return 3 * d0;
return m;
}
/** /**
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика * Строит 256-элементную таблицу соответствия по точкам кривой. Математика
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр * повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
@@ -196,25 +481,63 @@ export function buildCurveLUT(points?: CurvePoint[]): Uint8Array {
for (let i = 0; i < 256; i++) lut[i] = i; for (let i = 0; i < 256; i++) lut[i] = i;
return lut; return lut;
} }
const tangents = curveTangents(pts);
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255); lut[i] = Math.round(clamp01(curveEval(pts, tangents, i / 255)) * 255);
} }
return lut; return lut;
} }
function curveEval(points: CurvePoint[], x: number): number { /** Значение плавной кривой в точке x; вне крайних точек — плоская полка. */
function curveEval(points: CurvePoint[], tangents: number[], x: number): number {
if (x <= points[0].x) return points[0].y; if (x <= points[0].x) return points[0].y;
const last = points[points.length - 1]; const last = points[points.length - 1];
if (x >= last.x) return last.y; if (x >= last.x) return last.y;
for (let i = 1; i < points.length; i++) { let i = 1;
if (x > points[i].x) continue; while (i < points.length - 1 && x > points[i].x) i++;
const prev = points[i - 1]; const p0 = points[i - 1];
const cur = points[i]; const p1 = points[i];
if (cur.x - prev.x < 1e-9) return cur.y; const h = p1.x - p0.x;
const t = (x - prev.x) / (cur.x - prev.x); if (h < 1e-9) return p1.y;
return prev.y + t * (cur.y - prev.y); const t = (x - p0.x) / h;
const t2 = t * t;
const t3 = t2 * t;
const h00 = 2 * t3 - 3 * t2 + 1;
const h10 = t3 - 2 * t2 + t;
const h01 = -2 * t3 + 3 * t2;
const h11 = t3 - t2;
return h00 * p0.y + h10 * h * tangents[i - 1] + h01 * p1.y + h11 * h * tangents[i];
}
/**
* SVG-путь кривой в координатах 0..100 (ось Y перевёрнута). Между точками —
* кубические Безье с теми же касательными, что и у LUT; вне крайних точек —
* горизонтальные полки.
*/
export function curveSvgPath(points?: CurvePoint[]): string {
const pts = effectiveCurvePoints(points);
if (pts.length < 2) return 'M0,100 L100,0';
const tangents = curveTangents(pts);
const sx = (x: number) => (clamp01(x) * 100).toFixed(4);
const sy = (y: number) => ((1 - clamp01(y)) * 100).toFixed(4);
const parts: string[] = [`M${sx(0)},${sy(pts[0].y)}`];
if (pts[0].x > 1e-9) parts.push(`L${sx(pts[0].x)},${sy(pts[0].y)}`);
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i];
const p1 = pts[i + 1];
const h = p1.x - p0.x;
if (h < 1e-9) continue;
const c1x = p0.x + h / 3;
const c1y = p0.y + (tangents[i] * h) / 3;
const c2x = p1.x - h / 3;
const c2y = p1.y - (tangents[i + 1] * h) / 3;
parts.push(
`C${sx(c1x)},${sy(c1y)} ${sx(c2x)},${sy(c2y)} ${sx(p1.x)},${sy(p1.y)}`
);
} }
return last.y; const last = pts[pts.length - 1];
if (last.x < 1 - 1e-9) parts.push(`L${sx(1)},${sy(last.y)}`);
return parts.join(' ');
} }
function adjustChannel( function adjustChannel(
@@ -260,3 +583,178 @@ export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams):
data[i + 2] = adjustChannel(data[i + 2], light, master, blue); data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
} }
} }
/** RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1. */
function rgbToHSL(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === r) {
h = (g - b) / d + (g < b ? 6 : 0);
} else if (max === g) {
h = (b - r) / d + 2;
} else {
h = (r - g) / d + 4;
}
return [h * 60, s, l];
}
function hueToRGB(p: number, q: number, t: number): number {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
/** HSL -> RGB (0..255). */
export function hslToRGB(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const v = l * 255;
return [v, v, v];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hk = h / 360;
return [
hueToRGB(p, q, hk + 1 / 3) * 255,
hueToRGB(p, q, hk) * 255,
hueToRGB(p, q, hk - 1 / 3) * 255
];
}
/** Треугольный вес оттенка вокруг центра полосы микшера. */
function bandWeight(h: number, center: number): number {
let d = Math.abs(h - center);
if (d > 180) d = 360 - d;
return clamp01(1 - d / 60);
}
/**
* Применяет правки «Цвет» к пикселям (в том же массиве). Порядок операций
* повторяет серверный рендер: баланс белого, HSL, насыщенность/вибрация,
* микшер, точечный цвет, цветокоррекция.
*/
export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams): void {
const temperature = color.temperature / 100;
const tint = color.tint / 100;
const vibrance = color.vibrance / 100;
const saturation = color.saturation / 100;
const blending = color.grading.blending / 100;
const balance = color.grading.balance / 100;
const correction = color.correction;
const tints = GRADING_WHEELS.map((wheel) =>
hslToRGB(color.grading[wheel.key].hue, 1, 0.5)
);
const blend = blending;
const hasMixer = MIXER_BANDS.some((band) => {
const value = color.mixer[band.key];
return value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0;
});
for (let i = 0; i + 3 < data.length; i += 4) {
if (data[i + 3] === 0) continue;
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
// Баланс белого.
r *= 1 + 0.2 * temperature + 0.1 * tint;
g *= 1 - 0.2 * tint;
b *= 1 - 0.2 * temperature + 0.1 * tint;
r = clamp(r, 0, 255);
g = clamp(g, 0, 255);
b = clamp(b, 0, 255);
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
// не нужен HSL-переход, lightness считается напрямую.
const fast = !hasMixer && saturation === 0 && vibrance === 0;
let weights: number[];
let rr: number;
let gg: number;
let bb: number;
if (fast) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 510;
const lb = clamp01(l + balance * 0.5);
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
const highlightW = lb * lb * lb * lb;
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
let targetL = l;
GRADING_WHEELS.forEach((wheel, index) => {
targetL +=
((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index];
});
targetL = clamp01(targetL);
const delta = (targetL - l) * 255;
rr = r + delta;
gg = g + delta;
bb = b + delta;
} else {
// RGB -> HSL.
let [h, s, l] = rgbToHSL(r, g, b);
// Насыщенность и вибрация.
if (saturation !== 0 || vibrance !== 0) {
s = clamp01(s * (1 + saturation + vibrance * (1 - s)));
}
// Цветовой микшер.
if (hasMixer) {
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
const w = bandWeight(h, band.center);
if (w === 0) continue;
h += (value.hue / 100) * 30 * w;
s = clamp01(s * (1 + (value.saturation / 100) * w));
l = clamp01(l + (value.luminance / 100) * 0.2 * w);
}
}
h = ((h % 360) + 360) % 360;
// Зоны яркости: веса в сумме дают 1, более узкие у теней и светов.
const lb = clamp01(l + balance * 0.5);
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
const highlightW = lb * lb * lb * lb;
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
GRADING_WHEELS.forEach((wheel, index) => {
l = clamp01(
l + ((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index]
);
});
// HSL -> RGB.
[rr, gg, bb] = hslToRGB(h, s, l);
}
// Точечный цвет: три колеса по зонам яркости.
GRADING_WHEELS.forEach((wheel, index) => {
const strength =
(color.grading[wheel.key].saturation / 100) * 0.5 * blend * weights[index];
if (strength === 0) return;
const tintRGB = tints[index];
rr = rr * (1 - strength) + tintRGB[0] * strength;
gg = gg * (1 - strength) + tintRGB[1] * strength;
bb = bb * (1 - strength) + tintRGB[2] * strength;
});
// Цветокоррекция.
rr *= 1 + (correction.red / 100) * 0.5;
gg *= 1 + (correction.green / 100) * 0.5;
bb *= 1 + (correction.blue / 100) * 0.5;
data[i] = Math.round(clamp(rr, 0, 255));
data[i + 1] = Math.round(clamp(gg, 0, 255));
data[i + 2] = Math.round(clamp(bb, 0, 255));
}
}
+7 -1
View File
@@ -55,7 +55,13 @@ export const icons = {
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' }, check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' }, uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' }, left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' } left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
visibility: { path: '<path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5m0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10m0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>' },
colorize: { path: '<path fill="currentColor" d="M17.66 5.41l.92.92-2.69 2.69-.92-.92zM17.67 3c-.26 0-.51.1-.71.29l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42l-2.34-2.34c-.2-.19-.45-.29-.7-.29M6.92 19L5 17.08l8.06-8.06 1.92 1.92z"/>' },
tune: { path: '<path fill="currentColor" d="M3 17v2h6v-2zm0-12v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"/>' },
blur_on: { path: '<path fill="currentColor" d="M12 3a5 5 0 0 1 0 10 5 5 0 0 1 0-10m-5 6a5 5 0 1 1 0 10 5 5 0 0 1 0-10m10 0a5 5 0 1 1 0 10 5 5 0 0 1 0-10"/>' },
circle: { path: '<path fill="currentColor" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16"/>' },
show_chart: { path: '<path fill="currentColor" d="M3 3v18h18v-2H5V3zm16.7 3.3l-1.4-1.4-6.3 6.3-3-3L5 15v-2.8l3.6-3.6 3 3z"/>' }
} satisfies Record<string, IconDef>; } satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
+241 -18
View File
@@ -1,13 +1,23 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit'; import {
applyColorToPixels,
applyLightToPixels,
buildCurveLUT,
emptyColor,
emptyEditParams,
GRADING_WHEELS,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
interface LightExports { interface LightExports {
memory: WebAssembly.Memory; memory: WebAssembly.Memory;
build_lut(points: number, count: number, channel: number): void;
apply( apply(
data: number, data: number,
len: number, len: number,
luts: number,
exposureMul: number, exposureMul: number,
contrastK: number, contrastK: number,
highlights: number, highlights: number,
@@ -15,10 +25,16 @@ interface LightExports {
whites: number, whites: number,
blacks: number blacks: number
): void; ): void;
apply_color(data: number, len: number, params: number): void;
} }
let wasm: LightExports; let wasm: LightExports;
const LUTS_OFFSET = 0;
const COLOR_PARAMS_OFFSET = 2048;
const COLOR_PARAM_COUNT = 42;
const PIXELS_OFFSET = 4096;
beforeAll(async () => { beforeAll(async () => {
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url)); const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, {}); const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -27,24 +43,27 @@ beforeAll(async () => {
/** Применяет правки модулем WASM к копии пикселей. */ /** Применяет правки модулем WASM к копии пикселей. */
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray { function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
const points = light.curve?.rgb ?? [];
const floats = new Float32Array(points.length * 2);
points.forEach((p, i) => {
floats[i * 2] = p.x;
floats[i * 2 + 1] = p.y;
});
const mem = wasm.memory; const mem = wasm.memory;
const need = Math.ceil((4096 + pixels.length) / 65536); const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536); const have = mem.buffer.byteLength / 65536;
new Float32Array(mem.buffer, 0, floats.length).set(floats); if (need > have) mem.grow(need - have);
wasm.build_lut(0, points.length, 0);
wasm.build_lut(0, 0, 1); const curve = light.curve ?? {};
wasm.build_lut(0, 0, 2); const tables = [
wasm.build_lut(0, 0, 3); buildCurveLUT(curve.rgb),
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels); buildCurveLUT(curve.red),
buildCurveLUT(curve.green),
buildCurveLUT(curve.blue)
];
const luts = new Uint8Array(256 * tables.length);
tables.forEach((table, i) => luts.set(table, i * 256));
new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
wasm.apply( wasm.apply(
4096, PIXELS_OFFSET,
pixels.length, pixels.length,
LUTS_OFFSET,
Math.pow(2, light.exposure), Math.pow(2, light.exposure),
1 + light.contrast / 100, 1 + light.contrast / 100,
(light.highlights / 100) * 80, (light.highlights / 100) * 80,
@@ -52,7 +71,45 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
(light.whites / 100) * 80, (light.whites / 100) * 80,
(light.blacks / 100) * 80 (light.blacks / 100) * 80
); );
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice(); return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
}
/** Применяет цветовые правки модулем WASM к копии пикселей. */
function applyColorWasm(pixels: Uint8ClampedArray, color: ColorParams): Uint8ClampedArray {
const mem = wasm.memory;
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
const have = mem.buffer.byteLength / 65536;
if (need > have) mem.grow(need - have);
const params = new Float64Array(COLOR_PARAM_COUNT);
params[0] = color.temperature / 100;
params[1] = color.tint / 100;
params[2] = color.vibrance / 100;
params[3] = color.saturation / 100;
MIXER_BANDS.forEach((band, index) => {
const value = color.mixer[band.key];
const base = 4 + index * 3;
params[base] = value.hue / 100;
params[base + 1] = value.saturation / 100;
params[base + 2] = value.luminance / 100;
});
GRADING_WHEELS.forEach((wheel, index) => {
const value = color.grading[wheel.key];
const base = 28 + index * 2;
params[base] = value.hue;
params[base + 1] = value.saturation / 100;
params[34 + index] = (value.luminance ?? 0) / 100;
});
params[37] = color.grading.blending / 100;
params[38] = color.grading.balance / 100;
params[39] = color.correction.red / 100;
params[40] = color.correction.green / 100;
params[41] = color.correction.blue / 100;
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
wasm.apply_color(PIXELS_OFFSET, pixels.length, COLOR_PARAMS_OFFSET);
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
} }
describe('WASM-модуль фильтров', () => { describe('WASM-модуль фильтров', () => {
@@ -86,4 +143,170 @@ describe('WASM-модуль фильтров', () => {
const out = applyWasm(pixels, light); const out = applyWasm(pixels, light);
expect(Array.from(out)).toEqual([10, 20, 30, 0]); expect(Array.from(out)).toEqual([10, 20, 30, 0]);
}); });
it('осветляет при положительной экспозиции', () => {
const light: LightParams = { ...emptyEditParams().light, exposure: 1 };
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
expect(out[0]).toBeGreaterThan(100);
});
it('совпадает с JS на большом градиенте (свет)', () => {
const light: LightParams = {
...emptyEditParams().light,
exposure: 0.4,
contrast: 20,
highlights: -20,
shadows: 30
};
const width = 256;
const height = 256;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 20 + (y % 200);
source[i + 1] = 30 + (x % 150);
source[i + 2] = 40 + ((x + y) % 120);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyLightToPixels(expected, light);
const actual = applyWasm(source.slice(), light);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS-реализацией цвета', () => {
const color = emptyColor();
color.temperature = 30;
color.tint = -10;
color.vibrance = 20;
color.saturation = -15;
color.mixer.red = { hue: 20, saturation: 30, luminance: -10 };
color.mixer.blue = { hue: -15, saturation: -20, luminance: 10 };
color.grading.shadows = { hue: 240, saturation: 40, luminance: 10 };
color.grading.highlights = { hue: 40, saturation: 20, luminance: -10 };
color.grading.blending = 30;
color.grading.balance = -20;
color.correction = { red: 10, green: -5, blue: 8 };
const source = new Uint8ClampedArray(4 * 64);
for (let i = 0; i < source.length; i += 4) {
source[i] = (i * 7) % 256;
source[i + 1] = (i * 13) % 256;
source[i + 2] = (i * 29) % 256;
source[i + 3] = 255;
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('не меняет прозрачные пиксели при цветовых правках', () => {
const color = emptyColor();
color.saturation = 100;
color.temperature = 100;
const out = applyColorWasm(new Uint8ClampedArray([10, 20, 30, 0]), color);
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
});
it('совпадает с JS на большом градиенте', () => {
const color = emptyColor();
color.temperature = 25;
color.vibrance = 15;
color.mixer.blue = { hue: -10, saturation: 20, luminance: 10 };
color.grading.shadows = { hue: 240, saturation: 80, luminance: 10 };
color.grading.highlights = { hue: 40, saturation: 30, luminance: -10 };
const width = 256;
const height = 256;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 20 + (y % 200);
source[i + 1] = 30 + (x % 150);
source[i + 2] = 40 + ((x + y) % 120);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS на быстром пути (только колёса)', () => {
const color = emptyColor();
color.temperature = 20;
color.tint = -10;
color.grading.blending = 50;
color.grading.balance = -20;
color.grading.shadows = { hue: 240, saturation: 70, luminance: 25 };
color.grading.midtones = { hue: 40, saturation: 20, luminance: -15 };
color.grading.highlights = { hue: 200, saturation: 30, luminance: 10 };
const width = 128;
const height = 128;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 10 + (y % 220);
source[i + 1] = 20 + (x % 180);
source[i + 2] = 30 + ((x + y) % 160);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS на случайных цветовых параметрах', () => {
for (let n = 0; n < 100; n++) {
const color = emptyColor();
color.temperature = Math.random() * 200 - 100;
color.tint = Math.random() * 200 - 100;
color.vibrance = Math.random() * 200 - 100;
color.saturation = Math.random() * 200 - 100;
color.grading.blending = Math.random() * 100;
color.grading.balance = Math.random() * 200 - 100;
color.grading.shadows = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
color.grading.midtones = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
color.grading.highlights = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
const source = new Uint8ClampedArray(64);
for (let i = 0; i < source.length; i += 4) {
source[i] = (i * 7) % 256;
source[i + 1] = (i * 13) % 256;
source[i + 2] = (i * 29) % 256;
source[i + 3] = 255;
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) {
throw new Error(
`mismatch n=${n} i=${i} expected=${expected[i]} actual=${actual[i]} color=${JSON.stringify(color)}`
);
}
}
}
});
}); });
+132 -25
View File
@@ -1,23 +1,35 @@
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit'; import {
applyColorToPixels,
applyLightToPixels,
buildCurveLUT,
GRADING_WHEELS,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
/** /**
* WASM-модуль фильтров «Свет». * WASM-модуль фильтров «Свет» и «Цвет».
* *
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm. * Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
* Он считает правки по пикселям — это горячий путь предпросмотра в * Он попиксельно применяет правки — это горячий путь предпросмотра в
* просмотрщике. Если модуль недоступен, используется JS-реализация. * просмотрщике. Если модуль недоступен или считает иначе, чем эталонная
* JS-реализация, используется JS.
*/ */
const POINTS_OFFSET = 0; const LUTS_OFFSET = 0;
const COLOR_PARAMS_OFFSET = 2048;
const COLOR_PARAM_COUNT = 42;
const PIXELS_OFFSET = 4096; const PIXELS_OFFSET = 4096;
const PAGE = 65536; const PAGE = 65536;
const LUT_LEN = 256;
interface LightExports { interface LightExports {
memory: WebAssembly.Memory; memory: WebAssembly.Memory;
build_lut(points: number, count: number, channel: number): void;
apply( apply(
data: number, data: number,
len: number, len: number,
luts: number,
exposureMul: number, exposureMul: number,
contrastK: number, contrastK: number,
highlights: number, highlights: number,
@@ -25,6 +37,7 @@ interface LightExports {
whites: number, whites: number,
blacks: number blacks: number
): void; ): void;
apply_color(data: number, len: number, params: number): void;
} }
let exportsRef: LightExports | null = null; let exportsRef: LightExports | null = null;
@@ -36,7 +49,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
if (!loading) { if (!loading) {
loading = (async () => { loading = (async () => {
try { try {
const res = await fetch(url); const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return false; if (!res.ok) return false;
const bytes = await res.arrayBuffer(); const bytes = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, {}); const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -67,17 +80,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
return mem; return mem;
} }
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */ /** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void { function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
const ex = exportsRef; const curve = light.curve ?? {};
if (!ex) return; const tables = [
const floats = new Float32Array(points.length * 2); buildCurveLUT(curve.rgb),
for (let i = 0; i < points.length; i++) { buildCurveLUT(curve.red),
floats[i * 2] = points[i].x; buildCurveLUT(curve.green),
floats[i * 2 + 1] = points[i].y; buildCurveLUT(curve.blue)
} ];
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats); const bytes = new Uint8Array(LUT_LEN * tables.length);
ex.build_lut(POINTS_OFFSET, points.length, channel); tables.forEach((table, i) => bytes.set(table, i * LUT_LEN));
new Uint8Array(mem.buffer, LUTS_OFFSET, bytes.length).set(bytes);
} }
/** /**
@@ -91,16 +105,12 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
const mem = ensureMemory(bytes); const mem = ensureMemory(bytes);
if (!mem) return false; if (!mem) return false;
const curve = light.curve ?? {}; writeLuts(mem, light);
buildLut(mem, curve.rgb ?? [], 0);
buildLut(mem, curve.red ?? [], 1);
buildLut(mem, curve.green ?? [], 2);
buildLut(mem, curve.blue ?? [], 3);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data); new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
ex.apply( ex.apply(
PIXELS_OFFSET, PIXELS_OFFSET,
data.length, data.length,
LUTS_OFFSET,
Math.pow(2, light.exposure), Math.pow(2, light.exposure),
1 + light.contrast / 100, 1 + light.contrast / 100,
(light.highlights / 100) * 80, (light.highlights / 100) * 80,
@@ -112,8 +122,105 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
return true; return true;
} }
/**
* Сверяет модуль с эталонной JS-реализацией на маленьком образце.
*
* Модуль и JS должны считать одинаково; если модуль расходится (другая
* сборка, битая версия в кэше) — доверяем JS, чтобы предпросмотр не был
* испорчен.
*/
function wasmMatchesReference(light: LightParams): boolean {
const probe = new Uint8ClampedArray(16 * 4);
for (let i = 0; i < probe.length; i += 4) {
probe[i] = (i * 7) % 256;
probe[i + 1] = (i * 13) % 256;
probe[i + 2] = (i * 29) % 256;
probe[i + 3] = 255;
}
const expected = new Uint8ClampedArray(probe);
applyLightToPixels(expected, light);
const actual = new Uint8ClampedArray(probe);
if (!applyLightWasm(actual, light)) return false;
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) return false;
}
return true;
}
/** Применяет правки: сначала WASM, при его недоступности — JS. */ /** Применяет правки: сначала WASM, при его недоступности — JS. */
export function applyLight(data: Uint8ClampedArray, light: LightParams): void { export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
if (applyLightWasm(data, light)) return; if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
applyLightToPixels(data, light); applyLightToPixels(data, light);
} }
/** Пишет f64-блок цветовых параметров в память модуля. */
function writeColorParams(mem: WebAssembly.Memory, color: ColorParams): void {
const params = new Float64Array(COLOR_PARAM_COUNT);
params[0] = color.temperature / 100;
params[1] = color.tint / 100;
params[2] = color.vibrance / 100;
params[3] = color.saturation / 100;
MIXER_BANDS.forEach((band, index) => {
const value = color.mixer[band.key];
const base = 4 + index * 3;
params[base] = value.hue / 100;
params[base + 1] = value.saturation / 100;
params[base + 2] = value.luminance / 100;
});
GRADING_WHEELS.forEach((wheel, index) => {
const value = color.grading[wheel.key];
const base = 28 + index * 2;
params[base] = value.hue;
params[base + 1] = value.saturation / 100;
params[34 + index] = (value.luminance ?? 0) / 100;
});
params[37] = color.grading.blending / 100;
params[38] = color.grading.balance / 100;
params[39] = color.correction.red / 100;
params[40] = color.correction.green / 100;
params[41] = color.correction.blue / 100;
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
}
/**
* Применяет цветовые правки к пикселям. Возвращает false, если модуль не
* готов и вызывающий код должен использовать JS-реализацию.
*/
export function applyColorWasm(data: Uint8ClampedArray, color: ColorParams): boolean {
const ex = exportsRef;
if (!ex) return false;
const bytes = PIXELS_OFFSET + data.length;
const mem = ensureMemory(bytes);
if (!mem) return false;
writeColorParams(mem, color);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
ex.apply_color(PIXELS_OFFSET, data.length, COLOR_PARAMS_OFFSET);
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
return true;
}
/** Сверяет цветовой модуль с эталонной JS-реализацией на образце. */
function colorWasmMatchesReference(color: ColorParams): boolean {
const probe = new Uint8ClampedArray(16 * 4);
for (let i = 0; i < probe.length; i += 4) {
probe[i] = (i * 7) % 256;
probe[i + 1] = (i * 13) % 256;
probe[i + 2] = (i * 29) % 256;
probe[i + 3] = 255;
}
const expected = new Uint8ClampedArray(probe);
applyColorToPixels(expected, color);
const actual = new Uint8ClampedArray(probe);
if (!applyColorWasm(actual, color)) return false;
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) return false;
}
return true;
}
/** Применяет цвет: сначала WASM, при его недоступности — JS. */
export function applyColor(data: Uint8ClampedArray, color: ColorParams): void {
if (colorWasmMatchesReference(color) && applyColorWasm(data, color)) return;
applyColorToPixels(data, color);
}
@@ -0,0 +1,39 @@
/**
* Режим пипетки: панель редактирования включает его, а просмотрщик сообщает
* цвет пикселя, по которому кликнули.
*/
export type EyedropperMode = 'none' | 'wb';
export interface EyedropperSample {
photoId: string;
r: number;
g: number;
b: number;
seq: number;
}
class EyedropperStore {
mode = $state<EyedropperMode>('none');
photoId = $state('');
sample = $state<EyedropperSample | null>(null);
private seq = 0;
start(mode: EyedropperMode, photoId: string) {
this.mode = mode;
this.photoId = photoId;
}
stop() {
this.mode = 'none';
this.photoId = '';
}
report(photoId: string, r: number, g: number, b: number) {
if (this.mode === 'none' || this.photoId !== photoId) return;
this.seq += 1;
this.sample = { photoId, r, g, b, seq: this.seq };
this.stop();
}
}
export const eyedropper = new EyedropperStore();
+19
View File
@@ -3,8 +3,27 @@ export interface PhotoInfoPreferences {
labels?: Record<string, string>; labels?: Record<string, string>;
} }
export interface CurvePresetPoint {
x: number;
y: number;
}
export interface CurvePresetChannels {
rgb: CurvePresetPoint[];
red: CurvePresetPoint[];
green: CurvePresetPoint[];
blue: CurvePresetPoint[];
}
/** Пользовательский пресет тоновой кривой: кривые всех четырёх каналов. */
export interface CurvePreset {
name: string;
channels: CurvePresetChannels;
}
export interface Preferences { export interface Preferences {
photo_info?: PhotoInfoPreferences; photo_info?: PhotoInfoPreferences;
curve_presets?: CurvePreset[];
} }
export interface User { export interface User {
+470
View File
@@ -0,0 +1,470 @@
import {
buildCurveLUT,
GRADING_WHEELS,
hslToRGB,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
/**
* GPU-превью правок на WebGL2.
*
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст,
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL,
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра,
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс.
*/
const VERT = `#version 300 es
in vec2 a_pos;
uniform vec2 u_uvOffset;
uniform vec2 u_uvScale;
out vec2 v_uv;
void main() {
vec2 uv = a_pos * 0.5 + 0.5;
v_uv = u_uvOffset + uv * u_uvScale;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform sampler2D u_image;
uniform sampler2D u_luts;
uniform float u_exposureMul;
uniform float u_contrastK;
uniform float u_highlights;
uniform float u_shadows;
uniform float u_whites;
uniform float u_blacks;
uniform float u_temperature;
uniform float u_tint;
uniform float u_vibrance;
uniform float u_saturation;
uniform float u_blending;
uniform float u_balance;
uniform vec3 u_correction;
uniform float u_mixerHue[8];
uniform float u_mixerSat[8];
uniform float u_mixerLum[8];
uniform float u_gradeSat[3];
uniform float u_gradeLum[3];
uniform vec3 u_gradeTint[3];
const float CENTERS[8] = float[8](0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0);
vec4 lutAt(float v) {
float idx = clamp(floor(v + 0.5), 0.0, 255.0);
return texture(u_luts, vec2((idx + 0.5) / 256.0, 0.5));
}
float lightChannel(float v, int ch) {
v *= u_exposureMul;
v = 128.0 + (v - 128.0) * u_contrastK;
float w = clamp((v - 128.0) / 127.0, 0.0, 1.0);
v += u_highlights * w * w;
w = clamp((128.0 - v) / 128.0, 0.0, 1.0);
v += u_shadows * w * w;
w = clamp((v - 192.0) / 63.0, 0.0, 1.0);
v += u_whites * w;
w = clamp((64.0 - v) / 64.0, 0.0, 1.0);
v += u_blacks * w;
vec4 l1 = lutAt(v);
float master = l1.r * 255.0;
vec4 l2 = lutAt(master);
float value = ch == 0 ? l2.g : (ch == 1 ? l2.b : l2.a);
return value * 255.0;
}
vec3 rgb2hsl(vec3 c) {
float maxc = max(c.r, max(c.g, c.b));
float minc = min(c.r, min(c.g, c.b));
float l = (maxc + minc) * 0.5;
if (maxc == minc) return vec3(0.0, 0.0, l);
float d = maxc - minc;
float s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
float h;
if (maxc == c.r) {
h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
} else if (maxc == c.g) {
h = (c.b - c.r) / d + 2.0;
} else {
h = (c.r - c.g) / d + 4.0;
}
return vec3(h * 60.0, s, l);
}
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0 / 2.0) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
float h = hsl.x / 360.0;
float s = hsl.y;
float l = hsl.z;
if (s == 0.0) return vec3(l);
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
float p = 2.0 * l - q;
return vec3(
hue2rgb(p, q, h + 1.0 / 3.0),
hue2rgb(p, q, h),
hue2rgb(p, q, h - 1.0 / 3.0)
);
}
float bandWeight(float h, float center) {
float d = abs(h - center);
if (d > 180.0) d = 360.0 - d;
return clamp(1.0 - d / 60.0, 0.0, 1.0);
}
void main() {
vec4 tex = texture(u_image, v_uv);
if (tex.a == 0.0) {
outColor = vec4(0.0);
return;
}
vec3 c = tex.rgb * 255.0;
c.r = lightChannel(c.r, 0);
c.g = lightChannel(c.g, 1);
c.b = lightChannel(c.b, 2);
// Баланс белого.
c.r *= 1.0 + 0.2 * u_temperature + 0.1 * u_tint;
c.g *= 1.0 - 0.2 * u_tint;
c.b *= 1.0 - 0.2 * u_temperature + 0.1 * u_tint;
c = clamp(c, 0.0, 255.0);
vec3 hsl = rgb2hsl(c / 255.0);
float h = hsl.x;
float s = hsl.y;
float l = hsl.z;
if (u_saturation != 0.0 || u_vibrance != 0.0) {
s = clamp(s * (1.0 + u_saturation + u_vibrance * (1.0 - s)), 0.0, 1.0);
}
for (int i = 0; i < 8; i++) {
float w = bandWeight(h, CENTERS[i]);
if (w > 0.0) {
h += u_mixerHue[i] * 30.0 * w;
s = clamp(s * (1.0 + u_mixerSat[i] * w), 0.0, 1.0);
l = clamp(l + u_mixerLum[i] * 0.2 * w, 0.0, 1.0);
}
}
h = mod(h, 360.0);
if (h < 0.0) h += 360.0;
float lb = clamp(l + u_balance * 0.5, 0.0, 1.0);
float sw = (1.0 - lb) * (1.0 - lb);
sw *= sw;
float hw = lb * lb;
hw *= hw;
float weights[3] = float[3](sw, 1.0 - sw - hw, hw);
l = clamp(
l +
u_gradeLum[0] * 0.2 * weights[0] +
u_gradeLum[1] * 0.2 * weights[1] +
u_gradeLum[2] * 0.2 * weights[2],
0.0, 1.0);
vec3 outc = hsl2rgb(vec3(h, s, l)) * 255.0;
for (int i = 0; i < 3; i++) {
float strength = u_gradeSat[i] * 0.5 * u_blending * weights[i];
if (strength != 0.0) {
outc = outc * (1.0 - strength) + u_gradeTint[i] * strength;
}
}
outc *= 1.0 + u_correction * 0.5;
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a);
}`;
export interface PreviewRenderOptions {
image: TexImageSource;
imageVersion: string;
imageWidth: number;
imageHeight: number;
crop: { x: number; y: number; width: number; height: number };
viewport: { width: number; height: number };
dest: { x: number; y: number; width: number; height: number };
light: LightParams;
color: ColorParams;
}
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('webgl preview shader:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildLuts(light: LightParams): Uint8Array {
const master = buildCurveLUT(light.curve?.rgb);
const red = buildCurveLUT(light.curve?.red);
const green = buildCurveLUT(light.curve?.green);
const blue = buildCurveLUT(light.curve?.blue);
const out = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
out[i * 4] = master[i];
out[i * 4 + 1] = red[i];
out[i * 4 + 2] = green[i];
out[i * 4 + 3] = blue[i];
}
return out;
}
export class WebGLPreview {
private readonly gl: WebGL2RenderingContext;
private readonly canvas: HTMLCanvasElement;
private readonly program: WebGLProgram;
private readonly vao: WebGLVertexArrayObject;
private readonly buffer: WebGLBuffer;
private readonly imageTex: WebGLTexture;
private readonly lutTex: WebGLTexture;
private readonly u: Record<string, WebGLUniformLocation | null>;
private imageVersion = '';
private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
this.canvas = canvas;
this.gl = gl;
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) throw new Error('shader compile failed');
const program = gl.createProgram();
if (!program) throw new Error('program create failed');
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`program link failed: ${log ?? ''}`);
}
this.program = program;
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vao || !buffer) throw new Error('buffer create failed');
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW
);
const aPos = gl.getAttribLocation(program, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.vao = vao;
this.buffer = buffer;
const imageTex = gl.createTexture();
const lutTex = gl.createTexture();
if (!imageTex || !lutTex) throw new Error('texture create failed');
gl.bindTexture(gl.TEXTURE_2D, imageTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.bindTexture(gl.TEXTURE_2D, lutTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.imageTex = imageTex;
this.lutTex = lutTex;
const loc = (name: string) => gl.getUniformLocation(program, name);
this.u = {
u_image: loc('u_image'),
u_luts: loc('u_luts'),
u_uvOffset: loc('u_uvOffset'),
u_uvScale: loc('u_uvScale'),
u_exposureMul: loc('u_exposureMul'),
u_contrastK: loc('u_contrastK'),
u_highlights: loc('u_highlights'),
u_shadows: loc('u_shadows'),
u_whites: loc('u_whites'),
u_blacks: loc('u_blacks'),
u_temperature: loc('u_temperature'),
u_tint: loc('u_tint'),
u_vibrance: loc('u_vibrance'),
u_saturation: loc('u_saturation'),
u_blending: loc('u_blending'),
u_balance: loc('u_balance'),
u_correction: loc('u_correction'),
u_mixerHue: loc('u_mixerHue[0]'),
u_mixerSat: loc('u_mixerSat[0]'),
u_mixerLum: loc('u_mixerLum[0]'),
u_gradeSat: loc('u_gradeSat[0]'),
u_gradeLum: loc('u_gradeLum[0]'),
u_gradeTint: loc('u_gradeTint[0]')
};
}
static create(canvas: HTMLCanvasElement): WebGLPreview | null {
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false
});
if (!gl) return null;
try {
return new WebGLPreview(canvas, gl);
} catch (err) {
console.error('webgl preview init:', err);
return null;
}
}
dispose() {
const gl = this.gl;
gl.deleteTexture(this.imageTex);
gl.deleteTexture(this.lutTex);
gl.deleteBuffer(this.buffer);
gl.deleteVertexArray(this.vao);
gl.deleteProgram(this.program);
}
render(opts: PreviewRenderOptions): boolean {
const gl = this.gl;
const { width, height } = opts.viewport;
if (!width || !height) return false;
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
}
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.program);
if (this.imageVersion !== opts.imageVersion) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, opts.image);
this.imageVersion = opts.imageVersion;
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
gl.uniform1i(this.u.u_image, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
buildLuts(opts.light)
);
gl.uniform1i(this.u.u_luts, 1);
const light = opts.light;
gl.uniform1f(this.u.u_exposureMul, Math.pow(2, light.exposure));
gl.uniform1f(this.u.u_contrastK, 1 + light.contrast / 100);
gl.uniform1f(this.u.u_highlights, (light.highlights / 100) * 80);
gl.uniform1f(this.u.u_shadows, (light.shadows / 100) * 80);
gl.uniform1f(this.u.u_whites, (light.whites / 100) * 80);
gl.uniform1f(this.u.u_blacks, (light.blacks / 100) * 80);
const color = opts.color;
gl.uniform1f(this.u.u_temperature, color.temperature / 100);
gl.uniform1f(this.u.u_tint, color.tint / 100);
gl.uniform1f(this.u.u_vibrance, color.vibrance / 100);
gl.uniform1f(this.u.u_saturation, color.saturation / 100);
gl.uniform1f(this.u.u_blending, color.grading.blending / 100);
gl.uniform1f(this.u.u_balance, color.grading.balance / 100);
gl.uniform3f(
this.u.u_correction,
color.correction.red / 100,
color.correction.green / 100,
color.correction.blue / 100
);
const hue = new Float32Array(8);
const sat = new Float32Array(8);
const lum = new Float32Array(8);
for (let i = 0; i < 8; i++) {
const band = color.mixer[MIXER_BANDS[i].key];
hue[i] = band.hue / 100;
sat[i] = band.saturation / 100;
lum[i] = band.luminance / 100;
}
gl.uniform1fv(this.u.u_mixerHue, hue);
gl.uniform1fv(this.u.u_mixerSat, sat);
gl.uniform1fv(this.u.u_mixerLum, lum);
const gradeSat = new Float32Array(3);
const gradeLum = new Float32Array(3);
const gradeTint = new Float32Array(9);
for (let i = 0; i < 3; i++) {
const wheel = color.grading[GRADING_WHEELS[i].key];
gradeSat[i] = wheel.saturation / 100;
gradeLum[i] = wheel.luminance / 100;
const [r, g, b] = hslToRGB(wheel.hue, 1, 0.5);
gradeTint[i * 3] = r;
gradeTint[i * 3 + 1] = g;
gradeTint[i * 3 + 2] = b;
}
gl.uniform1fv(this.u.u_gradeSat, gradeSat);
gl.uniform1fv(this.u.u_gradeLum, gradeLum);
gl.uniform3fv(this.u.u_gradeTint, gradeTint);
const u0 = opts.crop.x / opts.imageWidth;
const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth;
const vTop = opts.crop.y / opts.imageHeight;
const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight;
gl.uniform2f(this.u.u_uvOffset, u0, 1 - vBottom);
gl.uniform2f(this.u.u_uvScale, u1 - u0, vBottom - vTop);
gl.viewport(
Math.round(opts.dest.x),
Math.round(height - opts.dest.y - opts.dest.height),
Math.max(1, Math.round(opts.dest.width)),
Math.max(1, Math.round(opts.dest.height))
);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindVertexArray(null);
return true;
}
}
+12
View File
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */ /** Статика приложения: сначала кеш, затем сеть с дозаписью. */
async function handleAsset(request: Request): Promise<Response> { async function handleAsset(request: Request): Promise<Response> {
const cache = await caches.open(SHELL_CACHE); const cache = await caches.open(SHELL_CACHE);
// wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы
// предпросмотр правок, поэтому его всегда берём из сети.
if (new URL(request.url).pathname.endsWith('.wasm')) {
try {
const fresh = await fetch(request, { cache: 'no-store' });
if (fresh.ok) await cache.put(request, fresh.clone());
return fresh;
} catch {
const stale = await cache.match(request);
if (stale) return stale;
}
}
const cached = await cache.match(request); const cached = await cache.match(request);
if (cached) return cached; if (cached) return cached;
const response = await fetch(request); const response = await fetch(request);
Binary file not shown.
+292 -91
View File
@@ -1,8 +1,12 @@
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка. //! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
//! //!
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается //! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного //! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go). //! рендера.
//!
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
//! функции, поэтому работает без математической библиотеки.
#![no_std] #![no_std]
use core::panic::PanicInfo; use core::panic::PanicInfo;
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
loop {} loop {}
} }
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий. /// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
const CHANNELS: usize = 4; const CHANNELS: usize = 4;
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS]; /// Длина таблицы соответствия одного канала.
const LUT_LEN: usize = 256;
#[inline(always)] #[inline(always)]
fn clamp01(v: f32) -> f32 { fn clamp01(v: f32) -> f32 {
@@ -39,57 +44,16 @@ fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
} }
} }
/// Строит таблицу соответствия 0..255 по точкам кривой.
///
/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x.
/// Точки вне диапазона обрезаются, дубликаты по x пропускаются.
#[no_mangle]
pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) {
unsafe {
let luts = &mut *core::ptr::addr_of_mut!(LUTS);
let ch = (channel as usize).min(CHANNELS - 1);
let lut = &mut luts[ch];
if count < 2 {
for (i, slot) in lut.iter_mut().enumerate() {
*slot = i as u8;
}
return;
}
let n = count as usize;
let mut seg = 0usize;
for v in 0..256 {
let x = v as f32 / 255.0;
while seg + 1 < n && x > *points.add((seg + 1) * 2) {
seg += 1;
}
let x0 = *points.add(seg * 2);
let y0 = *points.add(seg * 2 + 1);
let x1 = *points.add((seg + 1) * 2);
let y1 = *points.add((seg + 1) * 2 + 1);
let y = if x <= x0 {
y0
} else if x >= x1 {
y1
} else if x1 - x0 < 1e-6 {
y1
} else {
let t = (x - x0) / (x1 - x0);
y0 + t * (y1 - y0)
};
lut[v] = (clamp01(y) * 255.0 + 0.5) as u8;
}
}
}
/// Применяет правки «Свет» к пикселям RGBA в памяти. /// Применяет правки «Свет» к пикселям RGBA в памяти.
/// ///
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental /// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
/// функции, поэтому работает без математической библиотеки. /// синий), подготовленные вызывающим кодом.
#[no_mangle] #[no_mangle]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub extern "C" fn apply( pub extern "C" fn apply(
data: *mut u8, data: *mut u8,
len: u32, len: u32,
luts: *const u8,
exposure_mul: f32, exposure_mul: f32,
contrast_k: f32, contrast_k: f32,
highlights: f32, highlights: f32,
@@ -97,53 +61,21 @@ pub extern "C" fn apply(
whites: f32, whites: f32,
blacks: f32, blacks: f32,
) { ) {
unsafe { let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
let luts = &*core::ptr::addr_of!(LUTS); let master = &tables[0..LUT_LEN];
let master = &luts[0]; let red = &tables[LUT_LEN..2 * LUT_LEN];
let red = &luts[1]; let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
let green = &luts[2]; let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
let blue = &luts[3]; let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
let buf = core::slice::from_raw_parts_mut(data, len as usize);
let mut i = 0usize; let mut i = 0usize;
while i + 3 < buf.len() { while i + 3 < buf.len() {
if buf[i + 3] != 0 { if buf[i + 3] != 0 {
buf[i] = adjust( buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red);
buf[i] as f32, buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green);
exposure_mul, buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue);
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
red,
);
buf[i + 1] = adjust(
buf[i + 1] as f32,
exposure_mul,
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
green,
);
buf[i + 2] = adjust(
buf[i + 2] as f32,
exposure_mul,
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
blue,
);
} }
i += 4; i += 4;
} }
}
} }
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные /// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
@@ -158,8 +90,8 @@ fn adjust(
shadows: f32, shadows: f32,
whites: f32, whites: f32,
blacks: f32, blacks: f32,
master: &[u8; 256], master: &[u8],
channel: &[u8; 256], channel: &[u8],
) -> u8 { ) -> u8 {
let mut v = value * exposure_mul; let mut v = value * exposure_mul;
v = 128.0 + (v - 128.0) * contrast_k; v = 128.0 + (v - 128.0) * contrast_k;
@@ -176,3 +108,272 @@ fn adjust(
let idx = clampi((v + 0.5) as i32, 0, 255) as usize; let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
channel[idx] channel[idx]
} }
/// Количество f64-параметров цвета.
const COLOR_PARAM_COUNT: usize = 42;
/// Центры полос цветового микшера в градусах.
const BAND_CENTERS: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0];
#[inline(always)]
fn clampf(v: f64, lo: f64, hi: f64) -> f64 {
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
#[inline(always)]
fn clamp01d(v: f64) -> f64 {
clampf(v, 0.0, 1.0)
}
#[inline(always)]
fn to_u8(v: f64) -> u8 {
(clampf(v, 0.0, 255.0) + 0.5) as u8
}
/// RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1.
fn rgb_to_hsl(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
let r = r / 255.0;
let g = g / 255.0;
let b = b / 255.0;
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let mut h;
if max == r {
h = (g - b) / d;
if g < b {
h += 6.0;
}
} else if max == g {
h = (b - r) / d + 2.0;
} else {
h = (r - g) / d + 4.0;
}
(h * 60.0, s, l)
}
fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
if t < 0.0 {
t += 1.0;
}
if t > 1.0 {
t -= 1.0;
}
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 1.0 / 2.0 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
}
/// HSL -> RGB (0..255).
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
if s == 0.0 {
let v = l * 255.0;
return (v, v, v);
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
let hk = h / 360.0;
(
hue_to_rgb(p, q, hk + 1.0 / 3.0) * 255.0,
hue_to_rgb(p, q, hk) * 255.0,
hue_to_rgb(p, q, hk - 1.0 / 3.0) * 255.0,
)
}
fn band_weight(h: f64, center: f64) -> f64 {
let mut d = h - center;
if d < 0.0 {
d = -d;
}
if d > 180.0 {
d = 360.0 - d;
}
clamp01d(1.0 - d / 60.0)
}
/// Применяет правки «Цвет» к пикселям RGBA в памяти.
///
/// `params` — блок из `COLOR_PARAM_COUNT` значений f64, подготовленный
/// вызывающим кодом. Порядок операций повторяет серверный рендер и JS.
#[no_mangle]
pub extern "C" fn apply_color(data: *mut u8, len: u32, params: *const f64) {
let p = unsafe { core::slice::from_raw_parts(params, COLOR_PARAM_COUNT) };
let temperature = p[0];
let tint = p[1];
let vibrance = p[2];
let saturation = p[3];
let blending = p[37];
let balance = p[38];
let blend = blending;
// Микшер часто пуст (правят только колёса) — тогда не тратим на него время.
let mut has_mixer = false;
let mut band = 0usize;
while band < 8 {
let base = 4 + band * 3;
if p[base] != 0.0 || p[base + 1] != 0.0 || p[base + 2] != 0.0 {
has_mixer = true;
break;
}
band += 1;
}
let mut tints = [(0.0f64, 0.0f64, 0.0f64); 3];
let mut w = 0usize;
while w < 3 {
tints[w] = hsl_to_rgb(p[28 + w * 2], 1.0, 0.5);
w += 1;
}
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
let mut i = 0usize;
while i + 3 < buf.len() {
let alpha = unsafe { *buf.get_unchecked(i + 3) };
if alpha != 0 {
let mut r = unsafe { *buf.get_unchecked(i) } as f64;
let mut g = unsafe { *buf.get_unchecked(i + 1) } as f64;
let mut b = unsafe { *buf.get_unchecked(i + 2) } as f64;
// Баланс белого.
r *= 1.0 + 0.2 * temperature + 0.1 * tint;
g *= 1.0 - 0.2 * tint;
b *= 1.0 - 0.2 * temperature + 0.1 * tint;
r = clampf(r, 0.0, 255.0);
g = clampf(g, 0.0, 255.0);
b = clampf(b, 0.0, 255.0);
// Быстрый путь: только колёса/яркость, без микшера и насыщенности.
// Тогда HSL-переход не нужен — lightness считается напрямую.
let fast = !has_mixer && saturation == 0.0 && vibrance == 0.0;
let weights: [f64; 3];
let mut rr: f64;
let mut gg: f64;
let mut bb: f64;
if fast {
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
let l = (max + min) / 510.0;
let lb = clamp01d(l + balance * 0.5);
let shadow_w = {
let t = (1.0 - lb) * (1.0 - lb);
t * t
};
let highlight_w = {
let t = lb * lb;
t * t
};
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
let mut target_l = l;
let mut wheel = 0usize;
while wheel < 3 {
target_l += p[34 + wheel] * 0.2 * weights[wheel];
wheel += 1;
}
target_l = clamp01d(target_l);
let delta = (target_l - l) * 255.0;
rr = r + delta;
gg = g + delta;
bb = b + delta;
} else {
// RGB -> HSL.
let (mut h, mut s, mut l) = rgb_to_hsl(r, g, b);
// Насыщенность и вибрация.
if saturation != 0.0 || vibrance != 0.0 {
s = clamp01d(s * (1.0 + saturation + vibrance * (1.0 - s)));
}
// Цветовой микшер.
if has_mixer {
let mut band = 0usize;
while band < 8 {
let base = 4 + band * 3;
let weight = band_weight(h, BAND_CENTERS[band]);
if weight != 0.0 {
h += p[base] * 30.0 * weight;
s = clamp01d(s * (1.0 + p[base + 1] * weight));
l = clamp01d(l + p[base + 2] * 0.2 * weight);
}
band += 1;
}
}
h = h % 360.0;
if h < 0.0 {
h += 360.0;
}
let lb = clamp01d(l + balance * 0.5);
let shadow_w = {
let t = (1.0 - lb) * (1.0 - lb);
t * t
};
let highlight_w = {
let t = lb * lb;
t * t
};
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
let mut wheel = 0usize;
while wheel < 3 {
l = clamp01d(l + p[34 + wheel] * 0.2 * weights[wheel]);
wheel += 1;
}
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
rr = r2;
gg = g2;
bb = b2;
}
// Точечный цвет: три колеса по зонам яркости.
let mut wheel = 0usize;
while wheel < 3 {
let strength = p[28 + wheel * 2 + 1] * 0.5 * blend * weights[wheel];
if strength != 0.0 {
let t = tints[wheel];
rr = rr * (1.0 - strength) + t.0 * strength;
gg = gg * (1.0 - strength) + t.1 * strength;
bb = bb * (1.0 - strength) + t.2 * strength;
}
wheel += 1;
}
// Цветокоррекция.
rr *= 1.0 + p[39] * 0.5;
gg *= 1.0 + p[40] * 0.5;
bb *= 1.0 + p[41] * 0.5;
unsafe {
*buf.get_unchecked_mut(i) = to_u8(rr);
*buf.get_unchecked_mut(i + 1) = to_u8(gg);
*buf.get_unchecked_mut(i + 2) = to_u8(bb);
} }
i += 4;
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-21
@@ -0,0 +1,120 @@
# Design
## Context
См. `proposal.md` — Why. Ограничения, важные для подхода:
- `Store.Clone` (Postgres) и `SQLiteStore.Clone` (SQLite) копируют привязки
`photo_objects` одной вставкой `INSERT ... SELECT` без фильтра по ролям, поэтому
копия наследует и обычные варианты, и edit-варианты.
- Обычные варианты действительно должны разделяться: воркер создаёт их через
`createObject(ctx, siblings, ...)` , где `siblings` — все фото, делящие оригинал
(`PhotosByOriginalObject`). Edit-варианты, наоборот, создаются с владельцем
`[]string{photoID}` и привязаны только к одному фото.
- Параметры правок лежат в колонке `photos.edit_params` и при клонировании не
переносятся: `INSERT INTO photos (...)` в `Clone` её не включает. То есть рассинхрон
возникает именно на уровне привязок объектов.
- `ResetEdit` удаляет и параметры, и привязки edit-объектов
(`DeleteObjectBindings`). Значит, у фото с пустыми `edit_params` edit-привязок быть
не должно — кроме как последствия копирования.
- Выдача URL смотрит только на привязки (`attachURLs`), а фронтенд выбирает
`edit_thumb_url ?? thumb_url`. Привязка — источник правды о том, какие варианты
есть у фото; чинить выдачу URL вместо привязки нельзя.
- `ClaimGarbage` собирает готовые объекты без привязок, поэтому удаление привязки
само по себе освобождает объект — отдельная уборка не нужна.
## Goals / Non-Goals
**Goals:**
- Копия фото не наследует отредактированную версию: показывается исходник, параметры
правок пусты.
- Postgres и SQLite ведут себя одинаково, список исключаемых ролей задан один раз.
- Уже существующие копии в базе перестают показывать отредактированную версию без
ручного вмешательства.
**Non-Goals:**
- Не переносим параметры правок на копию: правка принадлежит фото, а не копии.
- Не рендерим отредактированную версию заново для копии.
- Не меняем поведение переноса (`MoveBatch`) и разделяемых обычных вариантов.
- Не трогаем клиентов и HTTP API: формат ответа не меняется.
## Decisions
### 1. Фильтровать роли в `Clone`, а не в выдаче URL
Копирование исключает edit-роли из переносимых привязок:
```
INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects
WHERE photo_id = $2 AND role <> ALL($3)
```
- **Альтернатива: прятать edit-URL, если у фото нет `edit_params`.** Отклонено:
привязка осталась бы в базе, GC считал бы объект живым, а модель и привязка
расходились бы. Привязка — источник правды.
- **Альтернатива: копировать и параметры, и готовую версию.** Отклонено: меняет
семантику «правка принадлежит фото» и требует отдельного рендера для копии.
### 2. Список edit-ролей — в одном месте
Роли уже объявлены в `backend/internal/photos/edit_render.go`
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`), оба хранилища
находятся в том же пакете. Добавляется общий список ролей и хелпер проверки, чтобы
Postgres и SQLite не расходились и в SQL не попадали строковые литералы.
- **Альтернатива: писать роли литералами в каждом `Clone`.** Отклонено: список
размножится, добавление новой edit-роли забудет одно из хранилищ.
### 3. Разовая чистка существующих копий — миграцией
В базе могут остаться привязки `edit*` у фото с пустыми `edit_params` — это и есть
следы бага копирования. Чистка выполняется миграцией в обоих диалектах
(Postgres и SQLite нумеруются независимо):
```
DELETE FROM photo_objects
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
AND photo_id IN (SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}')
```
- **Альтернатива: чистить лениво при чтении.** Отклонено: правило прячется в двух
местах и не отражается в данных.
- **Альтернатива: ничего не чистить.** Отклонено: старые копии продолжат показывать
отредактированную версию, и расхождение останется в данных.
- Удалённые привязки не осиротевают объекты: отредактированная версия остаётся
привязана к своему фото, поэтому её объекты живут, пока живёт фото, и обычный
`ClaimGarbage` уберёт их вместе с ним.
### 4. Проверка — в существующем тесте копирования
`backend/internal/photos/copy_test.go` уже проверяет, что копия делит объекты с
оригиналом. Добавляется сценарий: после рендера правок копия получает обычные
варианты, но не edit-объекты, и её `edit_params` пусты.
## Risks / Trade-offs
- [Копии, созданные до фикса, продолжат показывать правки до применения миграции] →
Миграция чистит привязки у фото с пустыми `edit_params`; после неё поведение
единообразно.
- [Пользователь может потерять «неожиданно» отредактированную копию] → Копия без
параметров правок всё равно не позволяла их править осмысленно: первая новая правка
перезаписывала обработку с нуля.
- [Опечатка в списке ролей оставит edit-привязку в копии] → Список ролей берётся из
общих констант, а тест проверяет конкретные роли.
- [Миграция удалит привязки у фото, которые потеряли `edit_params` не из-за копирования]
→ Таких фото нет: `ResetEdit` удаляет и параметры, и привязки одновременно.
## Migration Plan
1. Миграция Postgres (`000016_*`) и SQLite (`000008_*`) удаляют «чужие» edit-привязки.
Обе идемпотентны (`DELETE` без `IF EXISTS` по данным).
2. Изменения в `Clone` — только код, схема не меняется.
3. Откат: миграции `down` возвращают удалённые привязки не могут — откат выполняется
только откатом кода; правки фото при этом сохраняются у исходных фото.
## Open Questions
Нет.
@@ -0,0 +1,46 @@
# Proposal
## Why
Отредактированная версия фото задумана принадлежащей только тому фото, чьи правки её
породили: воркер создаёт edit-объекты с владельцем `[]string{photoID}` и комментирует
это прямо. Но копирование фото (`Clone`) переносит **все** привязки `photo_objects`,
включая роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`. Копия фото, сделанная
после рендера правок, поэтому показывает отредактированную картинку, но не наследует
`edit_params`: в панели правок у копии параметров нет, и первая же новая правка
перезаписывает исходную обработку с нуля. Поведение расходится с замыслом и с
описанием в спеке.
## What Changes
- Копирование фото перестаёт переносить отредактированную версию: в копию
попадают только обычные роли (`original`, `thumb`, `medium`, `preview`, `playback`).
- Копия фото с правками показывается в исходном виде, параметры правок копии
остаются пустыми — как и сейчас.
- Обычные варианты по-прежнему разделяются между копиями одного оригинала: правка
принадлежит фото, а не объекту.
- Спека `media-objects` приводится к этому правилу: уточняется, кому принадлежит
отредактированная версия и что происходит при копировании.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `media-objects`: копирование фото не переносит отредактированную версию на копию;
отредактированная версия остаётся привязанной только к фото, чьи правки её создали.
## Impact
- `backend/internal/photos/store.go` — `Store.Clone` (Postgres): при копировании
`photo_objects` исключаются роли `edit*`.
- `backend/internal/photos/sqlite_store.go` — `SQLiteStore.Clone` (SQLite): то же
поведение.
- `backend/internal/photos/edit_render.go` — константы ролей `edit*` используются как
список исключений (без дублирования строк в двух хранилищах).
- `backend/internal/photos/copy_test.go` — тест на копию фото с отрендеренной
отредактированной версией.
- Схема БД, HTTP API и клиенты не меняются; миграций нет.
@@ -0,0 +1,30 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Совместные объекты
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
виде и SHALL NOT наследовать параметры правок.
#### Scenario: Копирование
- **WHEN** пользователь копирует фото в другой альбом
- **THEN** создаётся новая запись, ссылающаяся на те же объекты, без операций с хранилищем
#### Scenario: Перенос
- **WHEN** пользователь переносит фото в другой альбом
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
#### Scenario: Правка не распространяется на копии
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
остаётся в исходном виде
#### Scenario: Копия отредактированного фото
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
не наследует параметры правок
@@ -0,0 +1,35 @@
# Tasks
## 1. Список edit-ролей
- [x] 1.1 В `backend/internal/photos/edit_render.go` собрать общий список ролей
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`) и хелпер проверки
роли; проверить, что список используется в обоих хранилищах и не дублируется
строковыми литералами
## 2. Копирование без отредактированной версии
- [x] 2.1 В `Store.Clone` (`backend/internal/photos/store.go`) исключить edit-роли из
переносимых привязок `photo_objects`; проверить тестом `copy_test.go`: копия
получает обычные варианты и не получает edit-объекты
- [x] 2.2 В `SQLiteStore.Clone` (`backend/internal/photos/sqlite_store.go`) сделать то же
для SQLite; проверить тем же тестом, прогнанным на SQLite-хранилище
## 3. Разовая чистка существующих копий
- [x] 3.1 Добавить миграцию Postgres `backend/migrations/000016_*.up.sql` и `.down.sql`,
удаляющую edit-привязки у фото с пустыми `edit_params`; проверить на тестовой базе,
что привязки исходных фото с правками остались, а «чужие» удалены
- [x] 3.2 Добавить миграцию SQLite `backend/migrations/sqlite/000008_*.up.sql` и
`.down.sql` с тем же действием; проверить тем же сценарием на SQLite
- [x] 3.3 Убедиться, что удалённые объекты подбираются сборщиком мусора
(`ClaimGarbage` не возвращает объекты с привязками); проверить существующим тестом
`objects_test.go`
## 4. Интеграционная проверка
- [x] 4.1 Прогнать `cd backend && go test ./internal/photos/ -v`; убедиться, что тесты
зелёные
- [x] 4.2 Прогнать `make test`; убедиться, что весь бэкенд зелёный
- [x] 4.3 Проверить вручную: отредактировать фото, скопировать его в другой альбом и
убедиться, что копия показывает исходник, а панель правок у копии пустая
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23
@@ -0,0 +1,112 @@
# Design
## Context
Неразрушающее редактирование фото уже работает: параметры хранятся в
`photo.edit_params` (`map[string]any` JSON-блоб), сервер рендерит копию в роли
`edit`, `edit_thumb`, `edit_medium`, `edit_preview`, оригинал не меняется.
Исторически поддерживался только блок «Свет» (`LightParams`), живой превью шёл
через WASM (`frontend/wasm/light/`). Теперь добавлен блок «Цвет» и GPU-превью:
основной live-предпросмотр считает WebGL2, а CPU-путь (TypeScript/WASM) остаётся
фолбэком и эталоном паритета. Хранение в JSON-блобе миграции не требует: фото без
ключа `color` рендерятся как прежде.
## Goals / Non-Goals
**Goals:**
- Добавить «Цвет»: баланс белого (температура/оттенок + пипетка), сочность
(vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекция
(тени/средние тона/света + яркость зон, наложение, баланс), поканальная
коррекция.
- Показывать правки в просмотрщике сразу, не дожидаясь серверного рендера.
- Сохранить неразрушающий серверный рендер как источник истины и обратную
совместимость со старыми `edit_params`.
**Non-Goals:**
- Синхронизация правок между устройствами и Android-клиент.
- Изменение ролей хранения или формата объектов.
- Миграция схемы базы данных.
## Decisions
**Цвет хранится в том же `edit_params`, рядом с `light`.**
- Обоснование: `EditParams` расширяется полем `Color ColorParams`; JSON-блоб и
клиентская модель принимают новый ключ без схемных изменений.
- Альтернатива: отдельная колонка/таблица — отвергнута, потребовала бы миграцию.
**Порядок операций: свет, затем цвет.**
- Обоснование: цветовые коррекции логично применять к уже скорректированной по
тональности картинке; сервер и клиент следуют одному порядку.
- Альтернатива: цвет до света — отвергнуто, расходится с поведением Lightroom и
текущим конвейером.
**Живой предпросмотр считается на GPU (WebGL2), CPU/WASM — фолбэк.**
- Обоснование: попиксельный CPU-фильтр на полном viewport блокировал main thread
(замеры: десятки мс на кадр). Шейдер повторяет свет/цвет и считает кадр на
видеокарте; текстура источника загружается один раз на смену изображения.
- Фолбэк: если WebGL2 недоступен или шейдер не собрался, используется прежний
CPU-путь (WASM, затем JS) с адаптивным буфером. Это медленнее, но сохраняет
функциональность.
- Альтернатива: вынести CPU-фильтр в Web Worker/OffscreenCanvas — отвергнуто как
более сложное при уже готовом GPU-пути.
**Паритет реализаций ограничен явно.**
- Go (сервер) — источник истины для сохранённого фото; покрыт Go-тестами.
- TypeScript и Rust/WASM — эталонная пара для CPU-фолбэка; паритет проверяется
`wasmMatchesReference` на пробе и больших градиентах.
- GLSL/WebGL2 — быстрый предпросмотр; проверяется компиляцией/линковкой шейдера,
пиксельный паритет с сервером не гарантируется. Финальное качество даёт сервер.
**Тональные зоны и быстрый путь.**
- Веса зон: тени `(1-l)^4`, света `l^4`, средние тона — остаток; это не даёт
колесу теней заметно задевать средние тона и уменьшает резкую границу на
контрастных снимках.
- `blending` по умолчанию 50 (диапазон 0..100), `balance` — -100..100.
- Яркость зон применяется аддитивным сдвигом, без деления на пиксель.
- Если микшер и насыщенность/вибрация не задействованы, цвет считается быстрым
путём без RGB→HSL→RGB; иначе — полный HSL-путь.
**Пустота цветовых правок.**
- Наложение/баланс и оттенок колеса без силы и яркости не считаются правкой:
фото не получает цветовую версию, сброс остаётся доступным по другим полям.
**Пипетка баланса белого.**
- Клиент сэмплирует пиксель из исходного (неотредактированного) превью и решает
обратную задачу баланса белого: подбирает температуру/оттенок, при которых
точка становится нейтральной. Пресет переходит в «Пользовательский».
**Поканальная коррекция — в модели и рендере, но не в UI.**
- `ColorCorrection` (red/green/blue) принимается API и учитывается рендером, но в
панели не выведена: в макете «Цветокоррекция» — это колёса тональных зон.
**UI.**
- Раздел «Цвет»: баланс белого с пресетами и пипеткой, сочность/насыщенность,
микшер (8 оттенков × тон/насыщенность/яркость), цветокоррекция (3 колеса,
яркость зон, наложение, баланс).
- Слайдеры с градиентными дорожками, цветовые колёса, двойной клик по контролу
сбрасывает значение.
## Risks / Trade-offs
[WebGL2 недоступен] → фолбэк на CPU/WASM с адаптивным буфером; функциональность
сохраняется, скорость ниже.
[Расхождение шейдера и сервера] → серверный рендер остаётся источником истины;
шейдер проходит проверку компиляции, а CPU-пара JS/WASM — тесты паритета.
[Большая текстура оригинала] → загрузка один раз на смену источника; при
переключении зума/источника возможна разовая пауза.
[Резкая граница на контрастных снимках] → более узкие веса зон и аддитивная
яркость; полностью убрать границу нельзя, если она есть в самом снимке.
[Обратная совместимость] → отсутствие `color` трактуется как нулевые правки.
## Migration Plan
Миграция схемы не требуется: новый ключ `color` дополняет существующий `light`.
Откат — удаление ключа `color` из `edit_params`.
## Open Questions
Нет.
@@ -0,0 +1,61 @@
# Proposal
## Why
В инструменте правок фото сейчас только раздел «Свет» (экспозиция, контраст, света,
тени, белые, чёрные, точечная кривая). Пользователи не могут скорректировать цвет:
баланс белого, вибрацию, насыщенность, отдельные оттенки и тонирование. Добавить
цветовые правки — востребованное расширение неразрушающего редактирования, которое
уже работает в Galo.
## What Changes
- Расширить `EditParams` новым блоком `Color`: баланс белого (температура/оттенок
+ пипетка по нейтральной точке), сочность (vibrance), насыщенность, цветовой
микшер (8 оттенков × тон/насыщенность/яркость), цветокоррекция (тени/средние
тона/света + яркость зон, наложение, баланс) и поканальная коррекция.
- Сервер (Go): рендер цветовых правок на пикселях исходного объекта, порядок
операций — свет, затем цвет; результат записывается в те же роли `edit`,
`edit_thumb`, `edit_medium`, `edit_preview`.
- Клиент (TypeScript/Svelte): раздел «Цвет» с ползунками, цветовыми колёсами и
микшером; живой предпросмотр на WebGL2, CPU/WASM-фолбэк с тестом паритета
(`wasmMatchesReference`); пипетка баланса белого; двойной клик по контролу
сбрасывает значение.
- WASM (Rust): расширение `frontend/wasm/light/` цветовыми операциями и
пересборка `static/light.wasm` (фолбэк предпросмотра).
- Хранение: `photo.edit_params` остаётся `map[string]any` JSON-блобом — старые
фото без ключа `color` рендерятся без цветовых правок, миграций схемы не
требуется.
## Capabilities
### Modified Capabilities
- `photos`: требование «Неразрушающее редактирование фотографии» расширяется
цветовыми параметрами правок. Потребуется дельта-спека
`specs/photos/spec.md` в рамках этого изменения.
### New Capabilities
Нет — цветовое редактирование входит в существующую способность `photos`.
## Impact
- `backend/internal/photos/edit.go` — `ColorParams`, `EditParams.Color`,
`Normalized`/`IsEmpty`/`JSON`.
- `backend/internal/photos/edit_render.go` — `applyColor` / `applyColorRows`,
`decodeAndAdjust` применяет свет и цвет.
- `backend/internal/photos/handlers.go` — приём `color` в `PATCH /photos/:id/edit`.
- `frontend/src/lib/edit.ts` — `ColorParams`, цветовые ползунки,
`readEditParams`/`isEmptyEditParams`/`normalizeEditParams`, `applyColorToPixels`,
`whiteBalanceFromRGB`.
- `frontend/src/lib/light-wasm.ts` — вызов WASM для цвета, поддержка паритета.
- `frontend/src/lib/webgl-preview.ts` — WebGL2-шейдер и рендер превью.
- `frontend/src/lib/stores/eyedropper.svelte.ts` — режим пипетки.
- `frontend/src/lib/components/PhotoEditPanel.svelte` — раздел «Цвет».
- `frontend/src/lib/components/EditSlider.svelte`, `ColorWheel.svelte` — контролы.
- `frontend/src/lib/components/Viewer.svelte` — live-превью (WebGL/CPU) и сэмплирование пипетки.
- `frontend/src/lib/components/PhotoPanel.svelte` — передача признака просмотрщика.
- `frontend/wasm/light/` (Rust) + `static/light.wasm` — цветовые операции.
- Тесты: `edit_test.go`, `edit.test.ts`, `light-wasm.test.ts`,
`backend/internal/e2e/color_edit_test.go`.
@@ -0,0 +1,70 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Неразрушающее редактирование фотографии
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок (свет и цвет) SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с исходными вариантами. Рендер SHALL выполняться по исходному объекту фото, а не по его уменьшенным вариантам. Исходный объект SHALL оставаться неизменным, а скачивание SHALL отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию. Правки SHALL применяться только к изображениям.
#### Scenario: Сохранение правок
- **WHEN** пользователь сохраняет правки фото (свет и/или цвет)
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
#### Scenario: Фоновая подготовка версий
- **WHEN** правки фото сохранены
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
#### Scenario: Рендер по оригиналу
- **WHEN** готовится отредактированная версия фото
- **THEN** световые и цветовые правки применяются к пикселям исходного объекта, а не к его уменьшенной копии
#### Scenario: Неразрушачность
- **WHEN** фото отредактировано
- **THEN** исходный объект и его варианты остаются неизменными
#### Scenario: Сброс правок
- **WHEN** пользователь сбрасывает правки
- **THEN** параметры и отредактированная версия удаляются, а фото снова показывается в исходном виде
#### Scenario: Права на правки
- **WHEN** правки сохраняет пользователь без прав на это фото
- **THEN** система отклоняет запрос
#### Scenario: Редактирование видео
- **WHEN** пользователь пытается отредактировать видео
- **THEN** система отклоняет запрос
#### Scenario: Цветовые правки сохраняются и рендерятся
- **WHEN** пользователь сохраняет цветовые правки фото: баланс белого (температура/оттенок), сочность (vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекцию (тени/средние тона/света, яркость зон, наложение, баланс) или поканальную коррекцию
- **THEN** параметры цвета сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
#### Scenario: Цветовые правки применяются к пикселям исходного объекта
- **WHEN** готовится отредактированная версия фото с цветовыми правками
- **THEN** цветовые правки применяются к пикселям исходного объекта после световых правок, а не к его уменьшенной копии
#### Scenario: Пустые цветовые правки не сохраняются
- **WHEN** в цветовых параметрах заданы только наложение/баланс или оттенок колеса без силы и яркости
- **THEN** правки считаются пустыми и фото не получает цветовую версию
## ADDED Requirements
### Requirement: Пипетка баланса белого
Клиент SHALL позволять выбрать нейтральную точку на фото и SHALL устанавливать температуру и оттенок так, чтобы цвет этой точки стал нейтральным. Выборка SHALL браться из исходного (неотредактированного) превью, а пресет баланса белого SHALL переходить в «Пользовательский».
#### Scenario: Выбор нейтральной точки
- **WHEN** пользователь включает пипетку и кликает по нейтральному участку фото
- **THEN** температура и оттенок обновляются, и цвет выбранной точки становится нейтральным
#### Scenario: Пипетка доступна только в просмотрщике
- **WHEN** фото не открыто в просмотрщике
- **THEN** пипетка недоступна
### Requirement: Живой предпросмотр цветовых правок
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. После завершения рендера SHALL показываться серверная версия.
#### Scenario: Правки видны сразу
- **WHEN** пользователь двигает цветовой контрол
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
#### Scenario: Серверная версия заменяет предпросмотр
- **WHEN** серверный рендер цветовых правок завершён
- **THEN** просмотрщик показывает отрендеренную версию
@@ -0,0 +1,59 @@
# Tasks
## 1. Backend: цветовая модель и рендер (Go)
- [x] 1.1 Добавить `ColorParams` и `EditParams.Color` в `backend/internal/photos/edit.go`, обновить `Normalized`/`IsEmpty`/`JSON` с учетом цвета — проверить `go build ./internal/photos/`
- [x] 1.2 Реализовать `applyColor` / `applyColorRows` и включить цвет в `decodeAndAdjust` после света (`backend/internal/photos/edit_render.go`) — проверить `go test ./internal/photos/ -run TestColor -v`
- [x] 1.3 Добавить тесты цветовых параметров (`edit_test.go`): валидация диапазонов, `IsEmpty`, рендер с цветом — проверить `go test ./internal/photos/ -v`
## 2. Фронтенд: цветовая модель (TypeScript)
- [x] 2.1 Добавить `ColorParams`, цветовые ползунки, `emptyColor`, обновить `readEditParams`/`isEmptyEditParams`/`normalizeEditParams` в `frontend/src/lib/edit.ts` — проверить `npm run check` (в `frontend`)
- [x] 2.2 Реализовать `applyColorToPixels` в `edit.ts`, зеркальное серверной математике — проверить `npm run test` (в `frontend`, `edit.test.ts`)
- [x] 2.3 Поддержать цвет в `light-wasm.ts` и тест паритета `wasmMatchesReference` — проверить `npm run test` (в `frontend`, `light-wasm.test.ts`)
## 3. WASM модуль цвета (Rust)
- [x] 3.1 Реализовать цветовые операции (баланс белого, вибрация, насыщенность, микшер, точечный цвет) в `frontend/wasm/light/` — проверить сборку модуля
- [x] 3.2 Пересобрать `static/light.wasm` — проверить файл обновился
## 4. UI: панель и превью цвета
- [x] 4.1 Добавить раздел «Цвет» в `PhotoEditPanel.svelte` (баланс белого, сочность, насыщенность, микшер, цветокоррекция) — проверить открытие/сворачивание панели
- [x] 4.2 Поддержать цвет в live-превью `Viewer.svelte` — проверить изменение превью при движении ползунков
- [x] 4.3 Подключить сохранение цветовых параметров через `photosApi.edit` — проверить сохранение на сервере
## 5. Интеграция и проверка
- [x] 5.1 Проверить эндпоинт правки (`PATCH /api/photos/:id/edit`) с цветовыми параметрами — проверить ответ с `edit_url`
- [x] 5.2 Проверить сброс цветовых правок — проверить возврат к оригиналу
- [x] 5.3 Проверить обратную совместимость: фото без ключа `color` в `edit_params` рендерятся без цветовых правок — проверить `make test` (бэкенд)
## 6. WebGL-предпросмотр
- [x] 6.1 Реализовать `frontend/src/lib/webgl-preview.ts`: WebGL2-шейдер света/цвета, LUT-текстура кривых, загрузка текстуры изображения — проверить сборку `npm run build`
- [x] 6.2 Подключить GL-first и CPU/WASM-фолбэк в `Viewer.svelte` (два канваса, `useGL`) — проверить переключение при отсутствии WebGL2
- [x] 6.3 Проверить компиляцию и линковку шейдера в headless Firefox — зелёный экран `OK`
- [x] 6.4 Пропускать свет, если он не менялся, и микшер, если он пуст — проверить `go test ./internal/photos/ ./internal/e2e/`
- [x] 6.5 Снизить стоимость CPU-фолбэка: адаптивный буфер, беззвучный быстрый путь без HSL и аддитивная яркость — проверить замер WASM/визуально
## 7. Пипетка баланса белого
- [x] 7.1 Добавить `frontend/src/lib/stores/eyedropper.svelte.ts` (режим, фото, сэмпл) — проверить `npm run check`
- [x] 7.2 Добавить `whiteBalanceFromRGB` в `edit.ts` (обратная задача баланса белого) — проверить `npm run test` (`edit.test.ts`)
- [x] 7.3 Сэмплировать пиксель базового превью в `Viewer.svelte` по клику — проверить ручным сценарием
- [x] 7.4 Подключить кнопку пипетки и подсказку в `PhotoEditPanel.svelte`, пресет «Пользовательский» — проверить `npm run check`
- [x] 7.5 Покрыть расчёт нейтральной точки тестом — проверить `npm run test`
## 8. UI и тональные правки
- [x] 8.1 Добавить `EditSlider.svelte` (градиентная/заполненная дорожка, двойной клик — сброс) — проверить `npm run check`
- [x] 8.2 Добавить `ColorWheel.svelte` (оттенок/насыщенность, двойной клик — сброс) — проверить открытие/вращение
- [x] 8.3 Переименовать «Красивость» → «Сочность» — проверить подпись ползунка
- [x] 8.4 Сузить веса зон теней/светов и сделать яркость аддитивной — проверить отсутствие резкой границы на контрастном снимке
- [x] 8.5 Добавить пресеты баланса белого и «Пользовательский» — проверить переключение пресетов
## Workflow follow-up
- Архивировать изменение после удовлетворения требований ревью проекта.
- Проверить архивированный результат.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23
@@ -0,0 +1,148 @@
# Design
## Context
Сейчас кривая хранится в `light.curve` как точки на канал, `normalizeCurve`
сортирует/зажимает/дедуплицирует их, а по умолчанию массив пуст. UI рисует
статичные углы и прямые сегменты; `buildCurveLUT` (TS) и `curveLUT` (Go)
интерполируют линейно. Пресетов нет. Настройки профиля лежат в
`users.preferences` (JSON-блоб); `PATCH /auth/me/preferences` сейчас **заменяет**
весь объект и знает только `photo_info`, поэтому добавление пресетов без мержа
затирало бы другие разделы.
См. `proposal.md` — Why и `specs/photos/spec.md`, `specs/auth/spec.md` — требования.
## Goals / Non-Goals
**Goals:**
- Крайние узлы как обычные точки; первый клик вставляет между ними; узлы двигаются
по x/y; вне них кривая плоская.
- Плавная монотонная интерполяция в SVG-пути и в LUT; Go и TS считают одинаково.
- Встроенные пресеты контраста и пользовательские пресеты (кривые всех четырёх
каналов) в профиле; сохранение/применение/переименование/удаление;
импорт/экспорт JSON.
- Серверный мерж разделов настроек с валидацией.
**Non-Goals:**
- Пресеты как «look» из четырёх кривых; Android-клиент; шаринг пресетов между
пользователями (только файл); undo/redo.
## Decisions
**Крайние точки — обычные узлы.**
- Первая и последняя точки массива являются крайними; по умолчанию это `(0,0)` и
`(1,1)`. Пустой канал в UI показывает эти две точки; первое взаимодействие
записывает их в `light.curve`.
- Вне крайних точек значение постоянно (текущее поведение `curveEval`), поэтому
сдвинутый по x узел даёт плоскую «полку», как на скрине.
- При перетаскивании точки остаются отсортированными по x; UI не даёт точке
пересечь соседнюю (x ограничивается между соседями), на коммите — `normalizeCurve`.
- Альтернатива: фиксировать x крайних на 0/1 — отвергнуто, пользователь хочет
двигать и по x.
**Интерполяция — монотонный кубический Эрмит (PCHIP).**
- Наклоны отрезков `d_i`, касательные по Fritsch–Carlson (нулевые при смене
знака/нулевом наклоне, ограничение против выбросов). Участок между точками
считается по кубической формуле Эрмита.
- SVG-путь: те же касательные переводятся в кубические Безье
(`P1 = P0 + (h/3, m_i*h/3)`, `P2 = P3 - (h/3, m_{i+1}*h/3)`), плюс плоские
сегменты от x=0 до первой и от последней до x=1.
- LUT 256: те же значения; `buildCurveLUT` (TS) и `curveLUT` (Go) обязаны
совпадать. WASM и WebGL получают готовый LUT из TS — их менять не нужно.
- Альтернативы: Catmull-Rom (выбросы/гало на тоновой кривой), линейная
интерполяция (текущая, отвергнута).
**Пресет — кривые всех четырёх каналов.**
- `CurvePreset { name: string; channels: { rgb: CurvePoint[]; red: CurvePoint[];
green: CurvePoint[]; blue: CurvePoint[] } }`.
- Каждый канал содержит 2..16 точек; пустых каналов в пресете нет — линейный
канал хранится как `(0,0),(1,1)`.
- Встроенные `Линейный`, `Средний контраст`, `Сильный контраст` — клиентские
константы. Контраст задаётся кривой RGB, каналы R/G/B линейные. Предлагаемые
точки RGB:
- Средний: `(0,0),(0.25,0.2),(0.5,0.5),(0.75,0.8),(1,1)`
- Сильный: `(0,0),(0.2,0.12),(0.5,0.5),(0.8,0.88),(1,1)`
- Сохранение берёт `effectiveCurvePoints` каждого канала, поэтому в пресет
всегда попадают все четыре кривые.
- Применение заменяет кривые всех четырёх каналов; текущий выбранный канал не
меняется.
- Подпись: имя пресета, если кривые всех четырёх каналов совпадают с пресетом
(с допуском), иначе `Пользовательский`.
**Хранение и мерж настроек.**
- `preferencesPayload` расширяется `curve_presets`; поля-указатели позволяют
отличить «не передано» от «пусто».
- Handler читает текущие `users.preferences`, мержит переданные разделы
(`photo_info`, `curve_presets`), валидирует, пишет и возвращает полный объект.
Сохранение «Сведений» с `{photo_info}` сохраняет пресеты; сохранение пресетов
сохраняет `photo_info`.
- Валидация: не больше 100 пресетов, имя 1..64, все четыре канала присутствуют
и содержат 2..16 точек, `x,y` конечные в 0..1. Некорректный раздел
отклоняется целиком.
- Альтернативы: отдельный endpoint (больше поверхности), клиент шлёт весь объект
(устаревшие вкладки затирают чужие изменения).
**Переключение фото.**
- Вкладка правой панели хранится в `AlbumPanel` и передаётся в `PhotoPanel`
через `bind:tab`, поэтому переживает перемонтирование панели при загрузке
следующего фото.
- Cleanup эффекта `PhotoEditPanel` регистрируется на каждом запуске; при смене
`photo.id` накопленные правки отправляются до инициализации нового фото.
- Ответы сохранений проверяются по id фото и номеру операции: устаревшее
сохранение не подменяет текущее фото и его параметры. Панель рендерит только
фото, совпадающее с текущим маршрутом.
**Пользовательские действия и файлы.**
- Сохранить: диалог имени, кривые всех четырёх каналов; при совпадении имени —
подтверждение перезаписи.
- Переименовать/удалить: действия в меню пользовательского пресета.
- Экспорт: JSON `{ version: 1, presets: CurvePreset[] }` с кривыми всех каналов,
только пользовательские пресеты (встроенные всегда доступны).
- Импорт: файл-пикер, валидация, upsert по имени, сохранение через preferences;
при некорректном файле профиль не меняется.
**Совместимость формата.**
- Старый формат `{ name, channel, points }` читается из профиля и импорта:
точки попадают в указанный канал, остальные становятся линейными.
- При следующем сохранении список перезаписывается новым форматом со всеми
четырьмя каналами.
**UI.**
- Статичный текст «Линейный» заменяется кнопкой, открывающей popover-меню:
встроенные, пользовательские, «Сохранить…», «Импортировать…», «Экспортировать…».
- В SVG крайние точки — такие же перетаскиваемые кружки, как остальные; первый
клик по свободному полю вставляет точку между крайними; двойной клик сбрасывает.
- Сохраняется существующая клавиатурная правка точек.
## Risks / Trade-offs
[Старые кривые начнут выглядеть иначе (плавно вместо линейно)] → это осознанное
изменение рендера; точки не мигрируются, плоские участки вне крайних сохраняются.
[Расхождение PCHIP в Go и TS] → одна формула, тесты с захардкоженными значениями
LUT в обоих языках; WASM/WebGL используют готовый LUT.
[Гонка при мерже preferences] → мерж на сервере по разделам: разные разделы не
затирают друг друга; одновременная запись одного раздела — last-write-wins,
приемлемо для настроек одного пользователя.
[Рост списка пресетов / большой импорт] → лимиты количества, размера имени и
точек; handler уже ограничивает тело 1 МБ.
[Старые пресеты хранятся в формате одного канала] → клиент конвертирует их при
чтении/импорте, а первое сохранение пресетов перезаписывает профиль новым
форматом.
[Пересечение точек при перетаскивании] → ограничение x соседями и нормализация
на коммите.
## Migration Plan
Схема не меняется: `curve_presets` — ключ в JSON-блобе `users.preferences`.
Старый формат пресетов конвертируется на клиенте при чтении. Откат: удалить
ключ; старые кривые остаются в `light.curve` без изменений данных.
## Open Questions
Нет. Точные точки «Среднего/Сильного контраста» — настраиваемый дефолт, не влияет
на контракт.
@@ -0,0 +1,73 @@
# Proposal
## Why
Тоновая кривая сейчас ведёт себя не как инструмент из Lightroom: крайние узлы не
существуют до первых кликов, линия строится по прямым сегментам, а готовых
пресетов контраста и пользовательских пресетов нет. Пользователь не может быстро
применить типовой контраст или сохранить свою кривую. Дополнительно
`PATCH /auth/me/preferences` заменяет весь объект настроек, поэтому хранение
пресетов в профиле без мержа затирало бы другие разделы.
## What Changes
- **Кривая.** Крайние узлы `(0,0)` и `(1,1)` существуют сразу и являются обычными
точками; первый клик добавляет точку между ними. Крайние узлы двигаются по x и
y, вне них кривая постоянна («полки», как на скрине). Линия и рендер используют
плавную монотонную кубическую интерполяцию (PCHIP): SVG-путь и LUT в Go/TS.
«Сбросить» возвращает линейную кривую.
- **Встроенные пресеты.** `Линейный`, `Средний контраст`, `Сильный контраст` —
каждый описывает все четыре канала: контраст задаётся кривой RGB, каналы
R/G/B остаются линейными. Подпись показывает активный пресет; при ручной
правке любого канала — `Пользовательский`.
- **Пользовательские пресеты.** Пресет описывает кривые всех каналов
(`name`, `channels: { rgb, red, green, blue }`). Сохранение текущих кривых
всех каналов, применение заменяет все четыре кривые, переименование и
удаление. Хранение в `users.preferences.curve_presets` (профиль пользователя),
приватно для пользователя.
- **Мерж настроек.** `PATCH /auth/me/preferences` принимает `photo_info` и
`curve_presets` по отдельности, мержит переданные разделы с сохранёнными,
валидирует и возвращает полный merged-объект. Сохранение «Сведений» больше не
затирает пресеты, и наоборот.
- **Импорт/экспорт.** JSON-файл `{ version: 1, presets: [...] }`: экспорт
пользовательских пресетов, импорт с добавлением в профиль (при совпадении имени
— перезапись).
- **Совместимость.** Миграций схемы нет: пресеты лежат в JSON-блобе
`preferences`. Старые пресеты формата `{ channel, points }` конвертируются в
четыре канала при чтении и перезаписываются при следующем сохранении.
Существующие сохранённые кривые начнут интерполироваться плавно, поэтому их
визуальный результат может немного измениться.
## Capabilities
### New Capabilities
Нет — поведение входит в существующие способности `photos` и `auth`.
### Modified Capabilities
- `photos`: добавляются требования «Тоновая кривая» (крайние узлы, плавная
интерполяция, встроенные пресеты, сброс) и «Пользовательские пресеты кривой»
(пресет из четырёх каналов, сохранение/применение/переименование/удаление,
импорт/экспорт).
- `auth`: требование «Хранение пользовательских настроек интерфейса»
расширяется мержем разделов и хранением пользовательских пресетов кривой;
добавляется сценарий «сохранение одного раздела не затирает другие».
## Impact
- `backend/internal/auth/handlers.go` — `preferencesPayload` с `curve_presets`,
мерж разделов, валидация имени и кривых всех четырёх каналов.
- `backend/internal/auth/store.go` / `sqlite_store.go` — без изменений схемы;
запись merged-JSON через существующий `UpdatePreferences`.
- `backend/internal/photos/edit.go` — `curveEval`/`curveLUT` переходят на PCHIP;
тесты Go на гладкость/LUT.
- `frontend/src/lib/edit.ts` — PCHIP в `buildCurveLUT`, типы и нормализация
пресетов, точки кривой.
- `frontend/src/lib/components/PhotoEditPanel.svelte` — узлы-углы, плавный SVG-путь,
меню пресетов, сохранение/переименование/удаление, импорт/экспорт.
- `frontend/src/lib/types.ts`, `stores/auth.svelte.ts`, `api.ts` — тип
`Preferences.curve_presets`, сохранение через существующий endpoint.
- Новые вспомогательные модули: меню/диалоги пресетов, импорт/экспорт JSON.
- Тесты: `edit_test.go`, `edit.test.ts`, тесты мержа/валидации preferences,
e2e сохранения пресетов в профиле.
@@ -0,0 +1,26 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Хранение пользовательских настроек интерфейса
Система SHALL хранить пользовательские настройки (отображение интерфейса и пресеты кривой) в профиле пользователя и SHALL возвращать их при получении текущего пользователя. Настройки SHALL быть приватными для пользователя. Отсутствие настроек SHALL трактоваться как значения по умолчанию. Сохранение одного раздела настроек SHALL не затирать остальные разделы.
#### Scenario: Получение настроек
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его настройки отображения и пресеты кривой (или пустой объект по умолчанию)
#### Scenario: Сохранение настроек
- **WHEN** пользователь сохраняет настройки
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
#### Scenario: Сохранение раздела не затирает другие
- **WHEN** пользователь сохраняет только один раздел настроек (например, сведения о фото или пресеты кривой)
- **THEN** остальные разделы профиля сохраняются без изменений
#### Scenario: Изоляция настроек
- **WHEN** другой пользователь запрашивает свой профиль
- **THEN** он не получает настройки первого пользователя
#### Scenario: Некорректные настройки
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
@@ -0,0 +1,84 @@
# Spec Delta
## ADDED Requirements
### Requirement: Тоновая кривая
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
#### Scenario: Крайние узлы существуют с самого начала
- **WHEN** пользователь открывает инструмент «Кривая»
- **THEN** у каждого канала уже есть крайние точки, и их можно перетаскивать
#### Scenario: Добавление точки
- **WHEN** пользователь кликает по свободному полю кривой
- **THEN** между крайними точками добавляется новая точка, а крайние остаются на месте
#### Scenario: Подвижные крайние узлы
- **WHEN** пользователь двигает крайнюю точку по x или y
- **THEN** вне крайних точек кривая остаётся плоской, а между точками — плавной
#### Scenario: Плавная интерполяция
- **WHEN** кривая сохранена и отрендерена
- **THEN** предпросмотр и итоговое фото используют одну и ту же плавную монотонную кривую
#### Scenario: Встроенные пресеты
- **WHEN** пользователь выбирает «Линейный», «Средний контраст» или «Сильный контраст»
- **THEN** кривые всех четырёх каналов заменяются кривыми пресета (контраст — в RGB, R/G/B — линейные), а подпись показывает выбранный пресет
#### Scenario: Пользовательская правка
- **WHEN** пользователь вручную меняет точки кривой
- **THEN** подпись показывает «Пользовательский»
#### Scenario: Сброс
- **WHEN** пользователь сбрасывает кривую
- **THEN** текущий канал возвращается к линейной кривой с крайними точками
#### Scenario: Независимые каналы
- **WHEN** пользователь выбирает канал R, G или B
- **THEN** он меняет только выбранный канал, остальные каналы остаются прежними
### Requirement: Переключение фото в редакторе
При переключении фото правая панель SHALL сохранять выбранную вкладку. Несохранённые правки SHALL сохраняться тому фото, для которого они сделаны, в том числе если пользователь уже переключился на другое фото. Ответы фоновых запросов SHALL NOT подменять текущее фото или его параметры.
#### Scenario: Вкладка не меняется
- **WHEN** пользователь переключает фото
- **THEN** выбранная вкладка правой панели остаётся прежней
#### Scenario: Правки уходят текущему фото
- **WHEN** пользователь меняет настройки после переключения
- **THEN** изменения сохраняются текущему фото, а не предыдущему
#### Scenario: Фоновое сохранение не подменяет фото
- **WHEN** сохранение предыдущего фото завершается после переключения
- **THEN** панель продолжает показывать текущее фото, а его параметры не меняются
### Requirement: Пользовательские пресеты кривой
Пользователь SHALL сохранять текущие кривые всех каналов (RGB, R, G и B) как именованный пресет, применять, переименовывать и удалять его. Пресет SHALL содержать кривую для каждого из четырёх каналов. Пресеты SHALL храниться в профиле пользователя и SHALL быть приватными. Импорт и экспорт SHALL выполняться JSON-файлом; импорт SHALL добавлять пресеты в профиль.
#### Scenario: Сохранение пресета
- **WHEN** пользователь сохраняет текущие кривые под именем
- **THEN** пресет появляется в списке и сохраняется в профиле вместе с кривыми RGB, R, G и B
#### Scenario: Применение пресета
- **WHEN** пользователь выбирает пользовательский пресет
- **THEN** кривые всех четырёх каналов заменяются сохранёнными, а текущий канал не меняется
#### Scenario: Переименование и удаление
- **WHEN** пользователь переименовывает или удаляет пресет
- **THEN** список пресетов и профиль обновляются
#### Scenario: Приватность пресетов
- **WHEN** другой пользователь открывает свои пресеты
- **THEN** он не видит пресеты первого пользователя
#### Scenario: Экспорт пресетов
- **WHEN** пользователь экспортирует пресеты
- **THEN** он получает JSON-файл с пользовательскими пресетами и кривыми всех четырёх каналов
#### Scenario: Импорт пресетов
- **WHEN** пользователь импортирует корректный JSON-файл
- **THEN** пресеты добавляются или обновляются в его профиле
#### Scenario: Некорректный файл или пресет
- **WHEN** имя или кривая хотя бы одного канала недопустимы
- **THEN** система отклоняет пресет и не меняет профиль
@@ -0,0 +1,49 @@
# Tasks
## 1. Общая математика кривой (TS + Go)
- [x] 1.1 Заменить линейную интерполяцию в `frontend/src/lib/edit.ts` на монотонный кубический Эрмит (PCHIP): наклоны, касательные, оценка между точками, плоские участки вне крайних — проверить `npm run test` (`edit.test.ts`, значения LUT и монотонность)
- [x] 1.2 Повторить ту же формулу в `backend/internal/photos/edit.go` (`curveEval`/`curveLUT`) — проверить `go test ./internal/photos/ -run TestCurve -v`
- [x] 1.3 Сверить Go и TS на нескольких кривых (линейная, средний/сильный контраст, кривая с крайними внутри) захардкоженными значениями LUT — проверить оба набора тестов
## 2. Backend: мерж настроек и пресеты
- [x] 2.1 Расширить `preferencesPayload` в `backend/internal/auth/handlers.go` полем `curve_presets` и валидацией (лимит количества, имя 1..64, 2..16 точек на канал, x/y в 0..1) — проверить `go test ./internal/auth/ -v`
- [x] 2.2 Сделать серверный мерж разделов: читать текущие `preferences`, мержить `photo_info`/`curve_presets`, писать и возвращать merged-объект — проверить тестом «сохранение photo_info не затирает пресеты и наоборот»
- [x] 2.3 Добавить e2e-проверку сохранения пресетов в профиле и их приватности — проверить `go test ./internal/e2e/ -run TestCurvePresets -v`
## 3. UI: узлы и плавная кривая
- [x] 3.1 Показывать крайние точки `(0,0)` и `(1,1)` по умолчанию; первый клик вставляет точку между ними; крайние двигаются по x/y, вне них — плоские участки — проверить вручную и `npm run check`
- [x] 3.2 Рисовать SVG-путь кубическими Безье по тем же касательным (плоские сегменты до/после крайних) — проверить визуально, что линия плавная
- [x] 3.3 Сохранить клавиатурную правку точек, двойной клик-сброс и ограничение x между соседями — проверить `npm run check` и ручным сценарием
## 4. UI: пресеты и файлы
- [x] 4.1 Заменить статичный «Линейный» на меню встроенных пресетов (`Линейный`, `Средний контраст`, `Сильный контраст`) с активной подписью/«Пользовательский» — проверить `npm run check`
- [x] 4.2 Добавить тип `Preferences.curve_presets` и сохранение/применение/переименование/удаление пользовательских пресетов через существующий endpoint — проверить `npm run test` и сохранение в профиле
- [x] 4.3 Реализовать импорт/экспорт JSON `{ version: 1, presets: [...] }` с валидацией и upsert по имени — проверить ручным импортом/экспортом и `npm run check`
## 5. Интеграция и проверка
- [x] 5.1 Прогнать `npm run check`, `npm run test`, `go test ./...`, `make build` — всё зелёное
- [x] 5.2 Проверить, что WebGL и WASM используют новый LUT, а старые сохранённые кривые рендерятся плавно без миграции данных — визуально и тестами
- [x] 5.3 Проверить приватность и лимиты пресетов (чужой профиль, слишком длинное имя, >16 точек) — тестами `internal/auth`/`internal/e2e`
## 6. Правка: пресет описывает все каналы
- [x] 6.1 Обновить `CurvePreset` в `frontend/src/lib/types.ts` и `curve-presets.ts`: `channels` для RGB/R/G/B, встроенные пресеты и поиск активного пресета по всем каналам — проверить `npm run test`
- [x] 6.2 Обновить серверную модель и валидацию `curve_presets` в `backend/internal/auth/handlers.go`: объект `channels` со всеми четырьмя каналами — проверить `go test ./internal/auth/ -v`
- [x] 6.3 Обновить сохранение и применение в `PhotoEditPanel.svelte` и `CurvePresetMenu.svelte`: сохранять и заменять все четыре канала, не переключать текущий канал — проверить `npm run check`
- [x] 6.4 Обновить импорт/экспорт и совместимость со старым форматом `{name, channel, points}`, тесты и e2e — проверить `npm run test` и `go test ./internal/e2e/ -run TestCurvePresets -v`
## 7. Переключение фото
- [x] 7.1 Сохранять вкладку правой панели при смене фото, вынеся её состояние в `AlbumPanel` — проверить в браузере
- [x] 7.2 Исправить регистрацию cleanup в `PhotoEditPanel`, чтобы накопленные правки отправлялись при переключении — проверить в браузере
- [x] 7.3 Защитить панель от устаревших ответов сохранения/загрузки и кратковременного `photo = null` — проверить `npm run check`
## Workflow follow-up
- Архивировать изменение после удовлетворения требований ревью проекта.
- Проверить архивированный результат.
+7 -5
View File
@@ -59,18 +59,20 @@ E.164 — и паролю.
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется - **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
### Requirement: Хранение пользовательских настроек интерфейса ### Requirement: Хранение пользовательских настроек интерфейса
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при Система SHALL хранить пользовательские настройки (отображение интерфейса и пресеты кривой) в профиле пользователя и SHALL возвращать их при получении текущего пользователя. Настройки SHALL быть приватными для пользователя. Отсутствие настроек SHALL трактоваться как значения по умолчанию. Сохранение одного раздела настроек SHALL не затирать остальные разделы.
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
#### Scenario: Получение настроек #### Scenario: Получение настроек
- **WHEN** аутентифицированный пользователь запрашивает свой профиль - **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию) - **THEN** ответ содержит его настройки отображения и пресеты кривой (или пустой объект по умолчанию)
#### Scenario: Сохранение настроек #### Scenario: Сохранение настроек
- **WHEN** пользователь сохраняет настройки отображения - **WHEN** пользователь сохраняет настройки
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние - **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
#### Scenario: Сохранение раздела не затирает другие
- **WHEN** пользователь сохраняет только один раздел настроек (например, сведения о фото или пресеты кривой)
- **THEN** остальные разделы профиля сохраняются без изменений
#### Scenario: Изоляция настроек #### Scenario: Изоляция настроек
- **WHEN** другой пользователь запрашивает свой профиль - **WHEN** другой пользователь запрашивает свой профиль
- **THEN** он не получает настройки первого пользователя - **THEN** он не получает настройки первого пользователя
+19
View File
@@ -53,3 +53,22 @@ Android-клиента и устанавливает его на выбранн
#### Scenario: Явная цель #### Scenario: Явная цель
- **WHEN** `make` запущен с именем цели, например `make frontend` - **WHEN** `make` запущен с именем цели, например `make frontend`
- **THEN** выполняется именно эта цель - **THEN** выполняется именно эта цель
### Requirement: Сборка WASM-модуля фильтров
Фильтры правок в браузере SHALL считаться WASM-модулем, собираемым из исходников на
Rust в каталоге `frontend/wasm/light`. Сборка фронтенда SHALL обновлять модуль перед
сборкой статики. Если инструмент сборки модуля недоступен, SHALL использоваться готовый
модуль из репозитория, а сборка SHALL NOT прерываться. Готовый модуль SHALL храниться
в репозитории, чтобы фронтенд собирался без Rust-инструментов.
#### Scenario: Сборка фронтенда с модулем
- **WHEN** выполняется сборка фронтенда
- **THEN** перед сборкой статики обновляется WASM-модуль фильтров
#### Scenario: Нет Rust-инструментов
- **WHEN** инструмент сборки модуля недоступен
- **THEN** используется готовый модуль из репозитория, а сборка завершается успешно
#### Scenario: Готовый модуль в репозитории
- **WHEN** репозиторий проверяется на чистоту
- **THEN** собранный модуль фильтров присутствует в нём
+30 -3
View File
@@ -25,7 +25,8 @@
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом, Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
ролям original, thumb, medium, preview и playback. ролям original, thumb, medium, preview, playback, edit, edit_thumb, edit_medium и
edit_preview.
#### Scenario: Привязка ролей #### Scenario: Привязка ролей
@@ -42,10 +43,20 @@ MIME-типом, размером, габаритами и состоянием;
- **WHEN** видео обработано - **WHEN** видео обработано
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером - **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
#### Scenario: Привязка отредактированной версии
- **WHEN** фото отредактировано и отредактированная версия отрендерена
- **THEN** фото ссылается на объекты edit, edit_thumb, edit_medium и edit_preview рядом с
исходными вариантами
### Requirement: Совместные объекты ### Requirement: Совместные объекты
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
объекты хранилища. объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
виде и SHALL NOT наследовать параметры правок.
#### Scenario: Копирование #### Scenario: Копирование
- **WHEN** пользователь копирует фото в другой альбом - **WHEN** пользователь копирует фото в другой альбом
@@ -55,6 +66,16 @@ MIME-типом, размером, габаритами и состоянием;
- **WHEN** пользователь переносит фото в другой альбом - **WHEN** пользователь переносит фото в другой альбом
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются - **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
#### Scenario: Правка не распространяется на копии
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
остаётся в исходном виде
#### Scenario: Копия отредактированного фото
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
не наследует параметры правок
### Requirement: Сборка мусора по ссылкам ### Requirement: Сборка мусора по ссылкам
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты. аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
@@ -93,7 +114,9 @@ MIME-типом, размером, габаритами и состоянием;
однократным выполнением и повтором при сбое; готовый результат SHALL однократным выполнением и повтором при сбое; готовый результат SHALL
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
SHALL различать короткие задания изображений и длительные задания видео. SHALL различать короткие задания изображений и длительные задания видео. Рендер
отредактированных версий SHALL использовать отдельную очередь, чтобы правки не
блокировали генерацию обычных вариантов.
#### Scenario: Повтор после рестарта #### Scenario: Повтор после рестарта
- **WHEN** процесс перезапускается с незавершёнными заданиями - **WHEN** процесс перезапускается с незавершёнными заданиями
@@ -115,6 +138,10 @@ SHALL различать короткие задания изображений
- **WHEN** выполняется длительная подготовка видео - **WHEN** выполняется длительная подготовка видео
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений - **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
#### Scenario: Рендер правок не блокирует варианты
- **WHEN** в фоне выполняется рендер отредактированной версии
- **THEN** генерация обычных вариантов продолжается независимо от него
### Requirement: Аватар пользователя как объект журнала ### Requirement: Аватар пользователя как объект журнала
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям, Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
+121 -7
View File
@@ -283,21 +283,20 @@ SHALL быть доступно и для фото без альбома (нап
- **THEN** контрольные суммы входят в данные полученных фото - **THEN** контрольные суммы входят в данные полученных фото
### Requirement: Неразрушающее редактирование фотографии ### Requirement: Неразрушающее редактирование фотографии
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок (свет и цвет) SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с исходными вариантами. Рендер SHALL выполняться по исходному объекту фото, а не по его уменьшенным вариантам. Исходный объект SHALL оставаться неизменным, а скачивание SHALL отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию. Правки SHALL применяться только к изображениям.
SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как
отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с
исходными вариантами. Исходный объект SHALL оставаться неизменным, а скачивание SHALL
отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию.
Правки SHALL применять только к изображениям.
#### Scenario: Сохранение правок #### Scenario: Сохранение правок
- **WHEN** пользователь сохраняет правки фото - **WHEN** пользователь сохраняет правки фото (свет и/или цвет)
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов - **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
#### Scenario: Фоновая подготовка версий #### Scenario: Фоновая подготовка версий
- **WHEN** правки фото сохранены - **WHEN** правки фото сохранены
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности - **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
#### Scenario: Рендер по оригиналу
- **WHEN** готовится отредактированная версия фото
- **THEN** световые и цветовые правки применяются к пикселям исходного объекта, а не к его уменьшенной копии
#### Scenario: Неразрушачность #### Scenario: Неразрушачность
- **WHEN** фото отредактировано - **WHEN** фото отредактировано
- **THEN** исходный объект и его варианты остаются неизменными - **THEN** исходный объект и его варианты остаются неизменными
@@ -313,3 +312,118 @@ SHALL храниться в метаданных фото, а отредакти
#### Scenario: Редактирование видео #### Scenario: Редактирование видео
- **WHEN** пользователь пытается отредактировать видео - **WHEN** пользователь пытается отредактировать видео
- **THEN** система отклоняет запрос - **THEN** система отклоняет запрос
#### Scenario: Цветовые правки сохраняются и рендерятся
- **WHEN** пользователь сохраняет цветовые правки фото: баланс белого (температура/оттенок), сочность (vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекцию (тени/средние тона/света, яркость зон, наложение, баланс) или поканальную коррекцию
- **THEN** параметры цвета сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
#### Scenario: Цветовые правки применяются к пикселям исходного объекта
- **WHEN** готовится отредактированная версия фото с цветовыми правками
- **THEN** цветовые правки применяются к пикселям исходного объекта после световых правок, а не к его уменьшенной копии
#### Scenario: Пустые цветовые правки не сохраняются
- **WHEN** в цветовых параметрах заданы только наложение/баланс или оттенок колеса без силы и яркости
- **THEN** правки считаются пустыми и фото не получает цветовую версию
### Requirement: Пипетка баланса белого
Клиент SHALL позволять выбрать нейтральную точку на фото и SHALL устанавливать температуру и оттенок так, чтобы цвет этой точки стал нейтральным. Выборка SHALL браться из исходного (неотредактированного) превью, а пресет баланса белого SHALL переходить в «Пользовательский».
#### Scenario: Выбор нейтральной точки
- **WHEN** пользователь включает пипетку и кликает по нейтральному участку фото
- **THEN** температура и оттенок обновляются, и цвет выбранной точки становится нейтральным
#### Scenario: Пипетка доступна только в просмотрщике
- **WHEN** фото не открыто в просмотрщике
- **THEN** пипетка недоступна
### Requirement: Живой предпросмотр цветовых правок
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. После завершения рендера SHALL показываться серверная версия.
#### Scenario: Правки видны сразу
- **WHEN** пользователь двигает цветовой контрол
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
#### Scenario: Серверная версия заменяет предпросмотр
- **WHEN** серверный рендер цветовых правок завершён
- **THEN** просмотрщик показывает отрендеренную версию
### Requirement: Тоновая кривая
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
#### Scenario: Крайние узлы существуют с самого начала
- **WHEN** пользователь открывает инструмент «Кривая»
- **THEN** у каждого канала уже есть крайние точки, и их можно перетаскивать
#### Scenario: Добавление точки
- **WHEN** пользователь кликает по свободному полю кривой
- **THEN** между крайними точками добавляется новая точка, а крайние остаются на месте
#### Scenario: Подвижные крайние узлы
- **WHEN** пользователь двигает крайнюю точку по x или y
- **THEN** вне крайних точек кривая остаётся плоской, а между точками — плавной
#### Scenario: Плавная интерполяция
- **WHEN** кривая сохранена и отрендерена
- **THEN** предпросмотр и итоговое фото используют одну и ту же плавную монотонную кривую
#### Scenario: Встроенные пресеты
- **WHEN** пользователь выбирает «Линейный», «Средний контраст» или «Сильный контраст»
- **THEN** кривые всех четырёх каналов заменяются кривыми пресета (контраст — в RGB, R/G/B — линейные), а подпись показывает выбранный пресет
#### Scenario: Пользовательская правка
- **WHEN** пользователь вручную меняет точки кривой
- **THEN** подпись показывает «Пользовательский»
#### Scenario: Сброс
- **WHEN** пользователь сбрасывает кривую
- **THEN** текущий канал возвращается к линейной кривой с крайними точками
#### Scenario: Независимые каналы
- **WHEN** пользователь выбирает канал R, G или B
- **THEN** он меняет только выбранный канал, остальные каналы остаются прежними
### Requirement: Переключение фото в редакторе
При переключении фото правая панель SHALL сохранять выбранную вкладку. Несохранённые правки SHALL сохраняться тому фото, для которого они сделаны, в том числе если пользователь уже переключился на другое фото. Ответы фоновых запросов SHALL NOT подменять текущее фото или его параметры.
#### Scenario: Вкладка не меняется
- **WHEN** пользователь переключает фото
- **THEN** выбранная вкладка правой панели остаётся прежней
#### Scenario: Правки уходят текущему фото
- **WHEN** пользователь меняет настройки после переключения
- **THEN** изменения сохраняются текущему фото, а не предыдущему
#### Scenario: Фоновое сохранение не подменяет фото
- **WHEN** сохранение предыдущего фото завершается после переключения
- **THEN** панель продолжает показывать текущее фото, а его параметры не меняются
### Requirement: Пользовательские пресеты кривой
Пользователь SHALL сохранять текущие кривые всех каналов (RGB, R, G и B) как именованный пресет, применять, переименовывать и удалять его. Пресет SHALL содержать кривую для каждого из четырёх каналов. Пресеты SHALL храниться в профиле пользователя и SHALL быть приватными. Импорт и экспорт SHALL выполняться JSON-файлом; импорт SHALL добавлять пресеты в профиль.
#### Scenario: Сохранение пресета
- **WHEN** пользователь сохраняет текущие кривые под именем
- **THEN** пресет появляется в списке и сохраняется в профиле вместе с кривыми RGB, R, G и B
#### Scenario: Применение пресета
- **WHEN** пользователь выбирает пользовательский пресет
- **THEN** кривые всех четырёх каналов заменяются сохранёнными, а текущий канал не меняется
#### Scenario: Переименование и удаление
- **WHEN** пользователь переименовывает или удаляет пресет
- **THEN** список пресетов и профиль обновляются
#### Scenario: Приватность пресетов
- **WHEN** другой пользователь открывает свои пресеты
- **THEN** он не видит пресеты первого пользователя
#### Scenario: Экспорт пресетов
- **WHEN** пользователь экспортирует пресеты
- **THEN** он получает JSON-файл с пользовательскими пресетами и кривыми всех четырёх каналов
#### Scenario: Импорт пресетов
- **WHEN** пользователь импортирует корректный JSON-файл
- **THEN** пресеты добавляются или обновляются в его профиле
#### Scenario: Некорректный файл или пресет
- **WHEN** имя или кривая хотя бы одного канала недопустимы
- **THEN** система отклоняет пресет и не меняет профиль
+46 -11
View File
@@ -263,9 +263,9 @@ SHALL выдвигать правую панель поверх содержим
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения. вписанный вид, а из вписанного — в 100% от оригинала с центрированием по центру кадра.
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL При зуме SHALL догружаться оригинал изображения. Зум SHALL работать и в полноэкранном
работать и в полноэкранном режиме. режиме.
#### Scenario: Зум колесом к указателю #### Scenario: Зум колесом к указателю
- **WHEN** пользователь вращает колесо мыши над фото - **WHEN** пользователь вращает колесо мыши над фото
@@ -278,16 +278,27 @@ SHALL выдвигать правую панель поверх содержим
#### Scenario: Пробел #### Scenario: Пробел
- **WHEN** пользователь нажимает `Space` - **WHEN** пользователь нажимает `Space`
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный - **THEN** из вписанного вида изображение переходит в 100% от оригинала по центру кадра, а из
любого зума возвращается во вписанный
#### Scenario: Оригинал в зуме #### Scenario: Оригинал в зуме
- **WHEN** пользователь входит в зум - **WHEN** пользователь входит в зум
- **THEN** догружается оригинал изображения и подменяется по факту загрузки - **THEN** догружается оригинал изображения и подменяется по факту загрузки
### Requirement: Навигация в зуме
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Клавиши
со стрелками в зуме SHALL перемещать изображение, а переключение соседних фото SHALL
оставаться за мышью и кнопками у краёв. В зуме пользователь SHALL перемещать изображение
перетаскиванием и прокруткой.
#### Scenario: Стрелки соседних фото #### Scenario: Стрелки соседних фото
- **WHEN** курсор находится у левой или правой кромки области просмотра - **WHEN** курсор находится у левой или правой кромки области просмотра
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты - **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
#### Scenario: Стрелки в зуме
- **WHEN** фото увеличено, и пользователь жмёт стрелки клавиатуры
- **THEN** изображение перемещается, а переключение соседних фото не происходит
#### Scenario: Панорамирование #### Scenario: Панорамирование
- **WHEN** фото увеличено - **WHEN** фото увеличено
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой - **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
@@ -782,27 +793,29 @@ SHALL очищаться. При отсутствии сети приложен
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
показывать отредактированную версию фото, когда она отрендерена. показывать отредактированную версию фото, когда она отрендерена. Обновление фото SHALL
сохранять открытую вкладку панели.
#### Scenario: Отложенная отправка
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
#### Scenario: Вкладки панели #### Scenario: Вкладки панели
- **WHEN** открыта правая панель фото - **WHEN** открыта правая панель фото
- **THEN** видны вкладки «Сведения» и «Редактирование» - **THEN** видны вкладки «Сведения» и «Редактирование»
#### Scenario: Отложенная отправка
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
#### Scenario: Сохранение правок #### Scenario: Сохранение правок
- **WHEN** пользователь отпускает ползунок - **WHEN** пользователь отпускает ползунок
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия - **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
#### Scenario: Правки видны сразу #### Scenario: Правки видны сразу
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото - **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера - **THEN** видимая область фото в просмотре показывает правки без ожидания рендера — и во
вписанном виде, и в зуме
#### Scenario: Фильтры считаются в браузере #### Scenario: Фильтры считаются в браузере
- **WHEN** браузер рисует предпросмотр правок - **WHEN** браузер рисует предпросмотр правок
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом - **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом
#### Scenario: Сброс правок #### Scenario: Сброс правок
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование» - **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
@@ -811,3 +824,25 @@ SHALL очищаться. При отсутствии сети приложен
#### Scenario: Зум при правках #### Scenario: Зум при правках
- **WHEN** фото отредактировано, и пользователь увеличивает его - **WHEN** фото отредактировано, и пользователь увеличивает его
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото - **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
### Requirement: График тональной кривой
График кривой SHALL показывать гистограмму каналов фото: выбранный канал — своим
цветом, мастер-канал — всеми тремя. Гистограмма SHALL пересчитываться с учётом
текущих правок. Точки кривой SHALL добавляться кликом по полю графика, перетаскиваться
и перемещаться стрелками клавиатуры. Блок «Кривая» SHALL сворачиваться и разворачиваться
по клику на заголовок, по умолчанию SHALL быть свёрнут, а кнопки выбора канала SHALL
оставаться доступными.
#### Scenario: Живая гистограмма
- **WHEN** пользователь двигает ползунок или точку кривой
- **THEN** гистограмма на графике пересчитывается с учётом текущих правок
#### Scenario: Точки кривой
- **WHEN** пользователь кликает по полю графика, перетаскивает точку или жмёт стрелки с
фокусом на точке
- **THEN** точка добавляется, перемещается и меняет кривую
#### Scenario: Сворачивание кривой
- **WHEN** пользователь открывает инструмент «Свет»
- **THEN** блок «Кривая» свёрнут, кнопки выбора канала доступны, а по клику на заголовок
график разворачивается и сворачивается обратно
@@ -0,0 +1,159 @@
# Отчёт: изменения, сделанные без спецификаций
- **Дата:** 2026-10-21
- **Диапазон:** `45a953f^..eb2d478` — 21 коммит, 2026-10-20 — 2026-10-21
- **Ветка:** `develop`
- **Предмет:** правки веб-интерфейса просмотра и нового инструмента «Свет» (редактирование фото), сделанные без обновления спецификаций OpenSpec
- **Результат:** спеки дописаны (`748143c`), заведено изменение `fix-copy-edit-variant-sharing`
## 1. Метод
1. Разобран диапазон коммитов `45a953f^..eb2d478`: для каждого проверено, затронул ли
он `openspec/specs`.
2. Для коммитов без спек — прочитаны затронутые участки кода
(`frontend/src/lib/components/Viewer.svelte`, `PhotoEditPanel.svelte`,
`PhotoPanel.svelte`, `frontend/src/lib/edit.ts`, `backend/internal/photos/worker.go`,
`store.go`, `sqlite_store.go`).
3. Сопоставлено фактическое поведение с текстом существующих требований
`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`.
4. Каждое найденное расхождение зафиксировано как требование (SHALL) со сценариями
WHEN/THEN; после правок спеки проверены `openspec validate --specs`.
## 2. Сводка
| Показатель | Значение |
| --- | --- |
| Коммитов в диапазоне | 21 |
| Из них со спеками | 11 |
| Из них без спек | 10 |
| Затронутых спецификаций | 4 (`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`) |
| Новых требований | 3 («График тональной кривой», «Навигация в зуме», «Сборка WASM-модуля фильтров») |
| Уточнённых требований | 6 |
| Новых сценариев | 12 |
| Ошибок валидации | 0 (`23 passed, 0 failed`) |
## 3. Покрытие по коммитам
| Коммит | Изменение | Где описано |
| --- | --- | --- |
| `45a953f` | скрытие боковых панелей, хоткеи размера миниатюр | `ui-workspace` → «Скрытие боковых панелей» (уже было) |
| `50b9983` | размер миниатюр не меняется при скрытых панелях | там же, сценарий «Размер миниатюр при скрытых панелях» |
| `35f8439` | зум клавишами `+` / `-` | `ui-workspace` → «Зум в просмотре» (уже было) |
| `0a9e527` | лента миниатюр следует за текущим фото | уже было |
| `961dff1` | `Ctrl+F` — полноэкранный режим приложения | уже было |
| `afaaaab` | офлайн-режим с кешем эскизов и превью | `ui-workspace` → «Офлайн-режим веб-приложения» (уже было) |
| `0548b1a` | вкладка «Редактирование», инструмент «Свет» | `ui-workspace` → «Вкладка «Редактирование»» (уточнено) |
| `cd3d17c` | правки применяются сразу; вкладка не сбрасывается при обновлении фото | уточнено: «Обновление фото SHALL сохранять открытую вкладку панели» |
| `6d3d7cb` | ускорение рендера правок в 9 раз | сознательно не специфицировано (внутренняя оптимизация) |
| `4d2069d`, `520886a` | клиентский рендер правок, WASM-фильтры | «Фильтры считаются в браузере» (уже было) |
| `2adcb13` | предпросмотр правок работает и без зума | уточнено: «Правки видны сразу — и во вписанном виде, и в зуме» |
| `500ec10`, `f8ee937` | починка зума, центрирование | «Зум в просмотре»: `Space` = 100% **от оригинала по центру кадра** |
| `b2a509f` | в зуме стрелки панорамируют фото | **новое** требование «Навигация в зуме» |
| `2668d87` | искажение цветов в предпросмотре | уже было (сценарий про сверку с эталоном) |
| `d904b2f` | невидимая линия тональной кривой | сознательно не специфицировано (багфикс) |
| `b64574b` | гистограммы каналов в редакторе кривой | **новое** требование «График тональной кривой» |
| `48ed0bb` | гистограмма пересчитывается при правке | там же, сценарий «Живая гистограмма» |
| `332f35b`, `eb2d478` | сворачивание блока «Кривая», свёрнут по умолчанию | там же, сценарий «Сворачивание кривой» |
| `520886a` | сборка WASM-модуля фильтров (`npm run wasm`, cargo) | `dev-make-workflow` → **новое** требование «Сборка WASM-модуля фильтров» |
| `520886a`, `worker.go` | роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` | `media-objects` → «Журнал объектов» + сценарий «Привязка отредактированной версии» |
| `worker.go` | отдельная очередь рендера правок (`edit_jobs`) | `media-objects` → «Генерация вариантов» + «Рендер правок не блокирует варианты» |
| `worker.go` | рендер правок по исходному объекту | `photos` → «Неразрушающее редактирование фотографии» + «Рендер по оригиналу» |
## 4. Новые и уточнённые требования
**Новые требования:**
- `ui-workspace` → «График тональной кривой»: гистограмма каналов, живой пересчёт,
добавление и перетаскивание точек, сворачиваемый блок «Кривая» (по умолчанию свёрнут,
кнопки выбора канала остаются доступными).
- `ui-workspace` → «Навигация в зуме»: клавиши-стрелки панорамируют фото и не
переключают соседние снимки, переключение остаётся за мышью и кнопками у краёв.
- `dev-make-workflow` → «Сборка WASM-модуля фильтров»: Rust-исходники в
`frontend/wasm/light`, обновление модуля перед сборкой статики, откат на готовый
модуль из репозитория, если cargo недоступен.
**Уточнённые требования:**
- `ui-workspace` → «Вкладка «Редактирование»»: добавлено сохранение вкладки при
обновлении фото; предпросмотр правок работает в любом режиме просмотра.
- `ui-workspace` → «Зум в просмотре»: `Space` показывает 100% **от оригинала** по
центру кадра.
- `media-objects` → «Журнал объектов»: перечислены роли `edit`, `edit_thumb`,
`edit_medium`, `edit_preview`.
- `media-objects` → «Совместные объекты»: уточнено, что отредактированная версия
создаётся и привязывается только к фото, чьи правки её породили.
- `media-objects` → «Генерация вариантов»: рендер правок идёт в отдельной очереди.
- `photos` → «Неразрушающее редактирование фотографии»: рендер идёт по исходному
объекту, а не по уменьшенной копии.
## 5. Что сознательно осталось без спецификаций
| Изменение | Почему |
| --- | --- |
| `6d3d7cb` — ускорение рендера в 9 раз | внутренняя оптимизация, внешнее поведение не изменилось |
| `d904b2f` — видимость линии кривой | багфикс: требование «кривая показывается» уже было |
| `500ec10` — контейнер прокрутки зума | деталь вёрстки, покрыта требованием «Панорамирование» |
| правки переноса/центрирования внутри `f8ee937` | описаны пользовательские последствия, а не механизм |
## 6. Найденное расхождение кода и замысла
`Store.Clone` и `SQLiteStore.Clone` копируют **все** привязки `photo_objects`,
включая роли `edit*`, хотя воркер создаёт edit-объекты с владельцем
`[]string{photoID}` и комментирует это так: «The edit belongs to this photo only, so
the variants are not shared with the copies of the same original».
Последствия:
| Сценарий | Фактическое поведение | Ожидаемое |
| --- | --- | --- |
| Копия **до** правки | копия в исходном виде | так и должно быть |
| Копия **после** рендера | копия показывает отредактированную картинку, но `edit_params` пусты: в панели правок параметров нет, первая же новая правка перезаписывает обработку с нуля | копия в исходном виде |
Оформлено как изменение `fix-copy-edit-variant-sharing`
(`openspec/changes/fix-copy-edit-variant-sharing/`):
- `proposal.md` — зачем;
- `design.md` — фильтр ролей в `Clone` вместо правки выдачи URL, единый список
edit-ролей, разовая чистка существующих копий миграцией в обоих диалектах;
- `specs/media-objects/spec.md` — дельта требования «Совместные объекты»;
- `tasks.md` — 4 группы задач (список ролей → копирование → миграции → проверки).
## 7. Проверка
```
openspec validate --specs → 23 passed, 0 failed
openspec validate fix-copy-edit-variant-sharing --type change → valid
```
## 8. Реализация `fix-copy-edit-variant-sharing`
Изменение реализовано полностью (9/9 задач):
| Что | Где |
| --- | --- |
| Общий список edit-ролей `EditRoles()` и предикат `IsEditRole()` | `backend/internal/photos/edit_render.go` |
| `Clone` не переносит edit-привязки (Postgres: `role <> ALL($3::text[])`) | `backend/internal/photos/store.go` |
| То же для SQLite (`role NOT IN (...)`) | `backend/internal/photos/sqlite_store.go` |
| Сброс правок использует общий список ролей | `backend/internal/photos/edit_service.go` |
| Разовая чистка копий, созданных до фикса | `backend/migrations/000016_*.sql`, `backend/migrations/sqlite/000008_*.sql` |
| Контрактная проверка копирования (оба хранилища) | `backend/internal/repo/contract/contract.go`, кейс `Clone` |
| Сервисные тесты: копия и миграция | `backend/internal/photos/copy_test.go`, `backend/internal/e2e/copy_edit_test.go` |
Проверки:
- `make test` (с Postgres) — весь бэкенд зелёный;
- контрактный кейс `Clone` проходит на SQLite и Postgres; без фикса он падает
(«clone shares the edited version»);
- e2e на SQLite: копия показывает исходник, `edit_params` пусты, миграция убирает
«чужие» привязки, не трогая привязки владельца;
- ручная проверка через HTTP и браузер: в просмотре копии грузится `thumb.webp`
(не `edit_preview`), все ползунки панели «Редактирование» в нуле.
## 9. Что дальше
- Ревью и архивация изменения `/opsx-archive` — дельта `media-objects` переедет в
основную спеку.
- Изменение требует Postgres для полного прогона: в этом окружении поднимался
временный контейнер `postgres:16-alpine` на порту 55432
(`TEST_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:55432/galo?sslmode=disable'`).
@@ -0,0 +1,280 @@
# Описание релиза Galo — 21 октября 2026
**База релиза:** `main` (`84ce4d1`, 10 октября 2026) → `develop` (`afe85f7`, 21 октября 2026)
**Объём:** 138 коммитов, 4 спецификации обновлены, 1 изменение реализовано
---
## Коротко о главном
1. **Локальный режим без внешних сервисов.** Слой метаданных переключается на
встроенный SQLite, хранилище блобов — на локальную файловую систему. По умолчанию
приложение работает полностью на одной машине: без Postgres и без S3.
2. **Редактирование фотографий в браузере.** Вкладка «Редактирование» с инструментом
«Свет»: экспозиция, контраст, света, тени, белые и чёрные, тональная кривая по
каналам с живой гистограммой. Правки видны сразу, без ожидания сервера.
3. **Новый интерфейс рабочего пространства.** Альбомы с обложками, тёмная, светлая и
нейтральная темы, единая система векторных иконок, гало-эмблема и фавикон.
4. **Нативный Android-клиент.** Отдельное приложение: просмотр альбомов, загрузка с
устройства, двусторонняя синхронизация с сервером, безопасная разгрузка превью.
5. **Видео и офлайн.** Видео в общих альбомах с фоновой подготовкой и перемоткой;
офлайн-кеш оболочки, списков и превью.
---
## Новые возможности
### Локальный режим: SQLite и файловая система
- **Слой метаданных** выбирается конфигурацией: внешний PostgreSQL или встроенный
SQLite с тем же поведением для одного экземпляра приложения.
- **Хранилище блобов** выбирается отдельно: S3-совместимое (Garage) или локальная
файловая система. Клиент никогда не видит хранилище — блобы всегда отдаются через
бэкенд по подписанному URL.
- По умолчанию (`DB_BACKEND=sqlite`, `STORAGE_BACKEND=fs`) приложение поднимается без
Postgres и S3: данные лежат в `galo.db` и каталоге `data/`.
- **Секрет подписи JWT и URL объектов** генерируются при первом старте и хранятся в
слое метаданных — переживают перезапуск и рестарт SQLite.
- Реконсиляция хранилища с журналом объектов: `make reconcile` (отчёт),
`make reconcile-delete` (удаление осиротевших блобов). Сборку мусора ведёт журнал,
хранилище не сканируется.
### Слой объектов
- Физическое объектное хранилище с **непрозрачными ключами**: идентификаторы альбомов и
фото в ключах не участвуют, сопоставление живёт только в метаданных.
- **Журнал объектов**: у каждого объекта ровно одна запись с ключом, MIME-типом,
размером, габаритами и состоянием; фото ссылается на объекты по ролям
(`original`, `thumb`, `medium`, `preview`, `playback`, `edit`, `edit_thumb`,
`edit_medium`, `edit_preview`).
- Один физический объект может разделяться несколькими фото: перенос и копирование
меняют только ссылки, хранилище не трогается.
- Сборка мусора по ссылкам: объект удаляется, когда на него не ссылается ни одно фото
и ни один аватар.
### Редактирование фотографий
- Вкладка «Редактирование» в правой панели фото, инструмент «Свет»: экспозиция,
контраст, света, тени, белые, чёрные.
- **Тональная кривая**: выбор канала (R/G/B/мастер), точки добавляются кликом по
полю графика, перетаскиваются и двигаются стрелками с клавиатуры.
- **Гистограмма каналов** пересчитывается на лету с учётом текущих правок.
- Блок «Кривая» сворачивается (по умолчанию свёрнут), кнопки выбора канала остаются
доступными.
- Предпросмотр правок рисуется **в браузере** — WASM-модулем на Rust, с резервной
JS-реализацией, если модуль недоступен или считает иначе.
- Правки применяются сразу; на сервер уходят через 10 секунд без изменений или при
переключении фото. Сервер рендерит отредактированную версию в фоне отдельной
очередью и сообщает о готовности.
- Переключатель «Сброс» удаляет и параметры правок, и отредактированную версию.
- Редактирование неразрушающее: оригинал остаётся неизменным, скачивание отдаёт
исходник. Рендер идёт по исходному объекту, а не по уменьшенной копии.
### Интерфейс рабочего пространства
- Альбомы с обложками, «лайтрумовская» компоновка: рельс альбомов, сетка, единая
панель фотографии.
- Три темы — тёмная, светлая, нейтральная — с семантическими токенами; переключатель
тем переехал в шапку, нейтральная тема затемнена.
- **Единая система иконок**: встроенная векторная графика (Material Symbols) вместо
эмодзи и символов системного шрифта — иконки наследуют цвет темы и не тянут
внешние ресурсы.
- **Гало-эмблема** (диск солнца в отделённом кольце) и фирменный фавикон: отдаются
самим приложением, наследуют тему, читаются в размере 16–24 px.
- Панель сведений о фото со **сворачиваемыми секциями** и настраиваемым набором
атрибутов: состав EXIF хранится в профиле пользователя.
- Полноэкранный просмотр: своя панель, кнопка «на весь экран», возврат по `Esc`.
### Просмотр и навигация
- **Зум** колесом мыши (с привязкой к курсору, до 200%), клавишами `+` и `-`;
`Space` возвращает во вписанный вид, а из вписанного — в 100% от оригинала по
центру кадра.
- В зуме стрелки клавиатуры **панорамируют** фото, переключение соседних снимков
остаётся за мышью и кнопками у краёв.
- **Скрытие боковых панелей** альбома: кнопка в панели сетки и хоткей `Tab`. Панели
уезжают за края, содержимое занимает всю ширину, панели возвращаются при наведении
на кромку окна. Размер миниатюр при этом сохраняется — в строке помещается больше
фото.
- Лента миниатюр в просмотре следует за текущим фото; на превью работает наведение-
зум.
- Полноэкранный режим приложения по `Ctrl+F`, навигация с клавиатуры в сетке с
возвратом фокуса, открытие фото тапом на тач-экранах.
- Просмотр 360-панорам по тегу `pano360`, корректное направление перетаскивания.
- Пока грузится оригинал для зума — спиннер; на Android — щипок-зум.
### Работа с фотографиями
- Мультивыбор в сетке, контекстные меню, **перенос и копирование** снимков, корзина
на уровне альбома и общие счётчики.
- **Теги** фото: правки расходятся по сетке и просмотру.
- Переименование фото и удаление из просмотра с подтверждением.
- EXIF извлекается полностью при загрузке; для старых снимков — дозаполнение
(`cmd/backfill-exif`).
### Офлайн-режим
- Service worker кеширует оболочку приложения, списки и эскизы/превью фотографий:
открытые альбомы и ранее загруженные снимки доступны без сети.
- Модуль фильтров в офлайне не кешируется — он всегда подтягивается свежим.
### Видео
- Загрузка видео в те же альбомы, что и фото (`kind=video`).
- Фоновая подготовка: постер (кадр кадра → обычные варианты сетки) и web-совместимый
вариант для воспроизведения (ремукс без перекодирования либо транскод в
h264/yuv420p/aac с `+faststart`), с поддержкой перемотки (`Range`).
- Транскод идёт в отдельном малом пуле и не вытесняет генерацию вариантов
изображений; готовность транслируется подписчикам альбома.
- Без `ffmpeg`/`ffprobe` приём видео отклоняется, `/api/health` сообщает
`"video": "unavailable"`, изображения работают как прежде.
### Android-клиент
- Отдельный Gradle-модуль, работает с существующим HTTP API, сервер и веб не
трогает. Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`.
- **Двусторонняя синхронизация** альбома устройства с серверным альбомом: загрузки,
изменения состава, удаления.
- **Безопасная разгрузка**: с устройства уходит превью, оригинал остаётся на сервере;
потеря серверной копии распознаётся и показывается в интерфейсе.
- Статус синхронизации на карточке альбома, ход восстановления оригиналов,
сортировка списка, обновление списка альбомов в реальном времени.
- Установка на устройство одной командой: `make install-android-app` (с выбором
серийного номера при нескольких подключённых устройствах).
### Профиль и учётная запись
- Профиль пользователя: круглый аватар с загрузкой и кадрированием, публичная отдача
аватара по версионированной ссылке.
- Вход и регистрация по телефону (E.164) в дополнение к email, управление
идентификаторами, удаление аккаунта.
- Предпочтения пользователя (в том числе состав панели сведений) хранятся на сервере.
### Альбомы и живое обновление
- Обложки альбомов: отдельный жизненный цикл, обложка доводится до готовности и
доставляется подписчикам.
- Живое обновление: события жизненного цикла альбома и изменения состава фото через
SSE, подписчики видят изменения без перезагрузки.
- Квадратный вариант `medium` — резкие миниатюры в сетке; шаг переключения размера
миниатюр и направление ползунка приведены в порядок.
- Быстрая загрузка больших альбомов.
### Наблюдаемость и эксплуатация
- Настраиваемое логирование: `LOG_LEVEL` (`debug` включает access-логи и ожидаемые
4xx), `LOG_FORMAT` (`json`/`text`), request id в ответах и логах.
- `scripts/seed.py` и `make testdata` — наполнение запущенного инстанса тестовыми
данными через публичный API.
- Release-сборка: `make build-release` — статичный бинарник с вшитой версией и
UPX-сжатием, если он установлен.
---
## Исправления
- **Копия фото больше не наследует отредактированную версию.** Раньше копирование
переносило все привязки объектов, включая `edit*`: копия показывала
отредактированную картинку, но не наследовала параметры правок, и первая же новая
правка перезаписывала обработку с нуля. Теперь правка принадлежит своему фото, а
копия показывает исходник. Разовая миграция убирает привязки у копий, созданных до
фикса.
- Предпросмотр правок работает и без зума, а не только в увеличенном виде.
- Устранено искажение цветов в предпросмотре (WASM-фильтры считались по
преобразованным каналам).
- Гистограмма пересчитывается при правке, а не остаётся от исходного снимка.
- Линия тональной кривой видима в тёмной теме.
- Починен зум в просмотре: контейнер прокрутки и центрирование.
- Android: разгруженное превью больше не считается новой версией элемента.
- Сервер отдаёт контрольную сумму оригинала в ответе на загрузку — клиент может
сверить файл.
- Счётчик корзины и правки тегов синхронизируются с сеткой и просмотром.
- Ошибка загрузки крупного файла показывается явно, лимит поднят.
- `make run` пересобирает фронтенд, если он изменился.
---
## Технические изменения
### API
Новые эндпоинты:
- `PATCH /api/photos/:id/edit` — сохранить параметры правок (тело: `{"light": {...}}`).
- `DELETE /api/photos/:id/edit` — сбросить правки вместе с отредактированной
версией.
- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`,
`POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences`,
`POST|DELETE /api/auth/me/avatar`, `GET /api/avatars/:userID/:version?size=large`.
- `POST /api/photo-batch/copy`, `POST /api/photo-batch/move` — пакетные операции над
снимками.
Изменившиеся ответы: у фото появились поля `edit_params`, `edit_url`,
`edit_thumb_url`, `edit_medium_url`, `edit_preview_url`; у видео — `playback_url`.
### Схема данных и миграции
- Postgres: миграции до `000016`; SQLite: до `000008` (нумерация диалектов
независимая, миграции вшиты в бинарник и применяются автоматически при старте;
`make migrate` применяет только Postgres).
- Новые роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` — рядом с
обычными `original`, `thumb`, `medium`, `preview`, `playback`.
- Новая колонка `photos.edit_params` — параметры неразрушающих правок.
### Сборка
- Фильтры правок считает WASM-модуль из `frontend/wasm/light` (Rust, `no_std`).
Пересборка нужна только при правке самого модуля; в репозитории лежит готовый
`light.wasm`, поэтому фронтенд собирается без Rust-инструментов.
- `npm run wasm` — отдельная пересборка модуля; обычная сборка фронтенда обновляет
его сама.
---
## Как обновиться
1. Собрать бинарник: `make build` (фронтенд → `backend/static` → `go:embed` → `bin/galo`)
или `make build-release` — с вшитой версией.
2. Остановить сервис, заменить бинарник. Фронтенк вшит в бинарник, отдельно
выкладывать нечего.
3. Запустить: миграции применятся автоматически при старте для выбранного бэкенда.
Для Postgres можно отдельно прогнать `make migrate`.
4. Для видео нужен `ffmpeg`/`ffprobe` на хосте; без них приём видео отключается.
5. Android-клиент ставится отдельно: `make install-android-app`.
Откат: бинарник возвращается предыдущей версии; миграции `down` не восстанавливают
удалённые привязки копий — это безопасно, отредактированные версии исходных фото
остаются на месте.
---
## Ограничения
- Редактирование — только для изображений; видео не редактируется.
- Правки не порождают отдельной записи в истории: параметры хранятся в метаданных
фото, отредактированная версия — как отдельный объект.
- Офлайн-кеш ограничен оболочкой, списками и эскизами/превью: оригиналы для зума и
скачивания требуют сети.
- Android-клиент требует JDK 17 и Android SDK (`platforms;android-35`,
`build-tools;35.0.0`).
- Номер версии берётся из `git describe --tags`; в репозитории пока нет тегов, поэтому
версия в сборке — короткий SHA или `dev`. Для осмысленной нумерации нужен тег.
---
## Для разработчиков
- Спецификации OpenSpec синхронизированы с поведением: обновлены `ui-workspace`
(редактирование, кривая, зум и навигация), `media-objects` (роли `edit*`, раздельное
владение правками, отдельная очередь рендера), `photos` (рендер по оригиналу),
`dev-make-workflow` (сборка WASM-модуля).
- Изменение `fix-copy-edit-variant-sharing` реализовано (9/9 задач) и ждёт ревью и
архивации: `openspec list` показывает его как `complete`.
- Контрактные тесты репозиториев (`internal/repo/contract`) гоняют один набор
проверок на SQLite и Postgres — новый кейс `Clone` проверяет, что копия не
наследует отредактированную версию, в обоих хранилищах.
- Интеграционные тесты Postgres требуют `TEST_DATABASE_URL`; без них они
пропускаются. Тесты e2e поднимают SQLite в `t.TempDir()` и внешних сервисов не
требуют.
- Сервис переименован в `galo`, бренд — «гало»; сборка `make build-release` пишет
`bin/galo-$(VERSION)`.
@@ -0,0 +1,145 @@
# Описание релиза Galo — 23 октября 2026
**Версия:** `0.2.0`
**База релиза:** `0.1-alpha` (`723b19b`, 21 октября 2026) → `develop` (`9e39e3d`, 23 октября 2026)
**Объём:** 6 коммитов, 3 спецификации обновлены, 2 изменения реализованы
---
## Коротко о главном
1. **Инструмент «Цвет».** Баланс белого, вибрация и насыщенность, цветовой
микшер по восьми диапазонам оттенков, цветокоррекция по зонам яркости и
общая поканальная коррекция. Предпросмотр считает WebGL2, есть WASM/JS-фолбэк.
2. **Пресеты тоновой кривой.** Плавная монотонная кривая (PCHIP), подвижные
крайние точки и плоские полки. Встроенные и пользовательские пресеты всех
четырёх каналов хранятся в профиле, импортируются и экспортируются JSON-файлом.
3. **Настройки профиля больше не затирают друг друга.** Сохранение сведений о фото
и пресетов кривой мержится на сервере по разделам.
4. **Исправлено переключение фото в редакторе.** Вкладка правой панели сохраняется,
а несохранённые правки уходят тому фото, для которого сделаны.
---
## Новые возможности
### Инструмент «Цвет»
- **Баланс белого:** температура и оттенок, пипетка по нейтральной точке на фото.
- **Вибрация и насыщенность** с раздельными ползунками.
- **Цветовой микшер** по восьми диапазонам (красный, оранжевый, жёлтый, зелёный,
бирюзовый, синий, фиолетовый, пурпурный): тон, насыщенность и яркость каждого
диапазона.
- **Цветокоррекция по зонам:** колёса теней, средних тонов и светов, наложение и
баланс зон.
- **Поканальная коррекция** красного, зелёного и синего каналов.
- **Живой предпросмотр на WebGL2:** кадр считает GPU, поэтому изменения видны сразу.
Если WebGL2 недоступен, используется WASM/JS-фолбэк с тем же результатом.
- Ползунки с градиентами, цветовые колёса и сброс значения двойным кликом.
- Порядок операций совпадает в Go-рендере, WASM и JS-предпросмотре; паритет
закреплён тестами.
### Тоновая кривая и пресеты
- **Плавная кривая:** монотонная кубическая интерполяция (PCHIP) вместо прямых
отрезков — и в SVG-графике, и в LUT для предпросмотра и серверного рендера.
- **Крайние точки** `(0,0)` и `(1,1)` существуют сразу, двигаются по обеим осям;
вне крайних точек кривая остаётся плоской. Первый клик по полю вставляет точку
между крайними.
- **Встроенные пресеты:** «Линейный», «Средний контраст», «Сильный контраст».
Контраст задаётся в RGB, каналы R/G/B остаются линейными.
- **Пользовательские пресеты** описывают кривые всех четырёх каналов:
- сохранение, применение, переименование и удаление;
- хранение в `users.preferences.curve_presets` в профиле пользователя;
- приватность: чужие пресеты не видны;
- импорт и экспорт JSON `{ "version": 1, "presets": [...] }`, upsert по имени;
- старый формат одного канала конвертируется при чтении.
- Подпись показывает активный пресет; при ручной правке любого канала —
«Пользовательский». Сброс возвращает линейную кривую.
### Настройки профиля
- `PATCH /api/auth/me/preferences` теперь **мержит** разделы, а не заменяет весь
JSON-объект.
- Сохранение `photo_info` не стирает `curve_presets`, и наоборот.
- Валидация: не больше 100 пресетов, имя 1..64 символа, 2..16 точек на каждый из
четырёх каналов, `x/y` в диапазоне 0..1.
- Неизвестные разделы настроек сохраняются без изменений.
---
## Исправления
- **Переключение фото в редакторе.** Вкладка правой панели больше не сбрасывается
на «Сведения». Накопленные правки отправляются фото, для которого сделаны, даже
если пользователь уже перешёл к следующему.
- **Фоновые сохранения не подменяют текущее фото.** Устаревшие ответы сохранения и
загрузки игнорируются; панель показывает только фото текущего маршрута.
- **Копия фото не наследует отредактированную версию.** Копия показывает исходник,
правка принадлежит своему фото (архивная синхронизация спецификации
`media-objects`).
- Устранены ошибки доступа к `photo = null` при кратковременной смене фото.
---
## Технические изменения
### API
- `PATCH /api/auth/me/preferences` — мерж известных разделов; в ответе возвращается
полный объединённый объект.
- `PATCH /api/photos/:id/edit` — параметры правок теперь включают блок `color` и
тоновую кривую; серверный рендер использует те же LUT, что и предпросмотр.
- `DELETE /api/photos/:id/edit` — сброс правок без изменений.
### Данные и миграции
- Схема БД не менялась: новые настройки лежат в JSON-блобе
`users.preferences`; параметры правок — в `photos.edit_params`.
- Новые роли объектов и миграции не добавлялись.
- Старые сохранённые кривые начнут интерполироваться плавно — визуальный результат
может немного измениться без миграции данных.
### Сборка
- Обновлён WASM-модуль фильтров (`frontend/static/light.wasm`).
- `make build-release` собирает статический бинарник с вшитой версией и сжимает
его UPX, если UPX доступен.
- Версия берётся из `git describe --tags`; релизный тег — `0.2.0`.
### Проверка
- `npm run check` — 0 ошибок и предупреждений.
- `npm run test` — 54 теста.
- `go test ./...` — все пакеты зелёные.
- E2E: сохранение/сброс цветовых правок, приватность и хранение пресетов кривой.
- Проверено в браузере: WebGL2-предпросмотр, пипетка, меню пресетов, сохранение
всех каналов, сохранение вкладки и правок при переключении фото.
---
## Как обновиться
1. Собрать релизный бинарник: `make build-release` (или взять готовый
`bin/galo-0.2.0`).
2. Остановить сервис и заменить бинарник. Фронтенд вшит в бинарник.
3. Запустить сервис: миграции применяются автоматически при старте для выбранного
бэкенда. Для этого релиза отдельных миграций нет.
4. Для видео по-прежнему нужны `ffmpeg`/`ffprobe` на хосте.
5. Android-клиент обновляется отдельно через `make install-android-app`.
Откат: вернуть предыдущий бинарник. Схема не менялась, данные правок и профиля
остаются совместимыми; новые ключи настроек старый бинарник просто игнорирует.
---
## Ограничения
- Редактирование цвета и кривой доступно только для изображений; видео не
редактируется.
- WebGL2-предпросмотр ускоряет отрисовку, но при его отсутствии используется
WASM/JS-фолбэк.
- Пресеты не синхронизируются между пользователями: перенос возможен только через
экспорт/импорт JSON.
- Правки не создают отдельной записи в истории: параметры хранятся в метаданных
фото, отредактированная версия — отдельный объект.