diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go index 19bac96..8d015fe 100644 --- a/backend/internal/auth/handlers.go +++ b/backend/internal/auth/handlers.go @@ -4,7 +4,10 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" + "strings" + "unicode/utf8" "github.com/gin-gonic/gin" @@ -181,6 +184,9 @@ const ( maxLabelEntries = 500 maxPreferenceKeyLen = 128 maxPreferenceValueLen = 128 + maxCurvePresets = 100 + maxCurvePresetNameLen = 64 + maxCurvePresetPoints = 16 ) type photoInfoPreferences struct { @@ -188,33 +194,102 @@ type photoInfoPreferences struct { 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 { - 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 { pi := p.PhotoInfo - if pi == nil { - return nil - } - if len(pi.Visible) > maxVisibleAttrs { - return fmt.Errorf("too many visible attributes") - } - for _, k := range pi.Visible { - if k == "" || len(k) > maxPreferenceKeyLen { - return fmt.Errorf("invalid visible attribute key") + if pi != nil { + if len(pi.Visible) > maxVisibleAttrs { + return fmt.Errorf("too many visible attributes") + } + for _, k := range pi.Visible { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid visible attribute key") + } + } + if len(pi.Labels) > maxLabelEntries { + return fmt.Errorf("too many label overrides") + } + for k, v := range pi.Labels { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid label key") + } + if len(v) > maxPreferenceValueLen { + return fmt.Errorf("label value too long") + } } } - if len(pi.Labels) > maxLabelEntries { - return fmt.Errorf("too many label overrides") + if p.CurvePresets != nil { + return validateCurvePresets(*p.CurvePresets) } - for k, v := range pi.Labels { - if k == "" || len(k) > maxPreferenceKeyLen { - return fmt.Errorf("invalid label key") + 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") } - if len(v) > maxPreferenceValueLen { - return fmt.Errorf("label value too long") + 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 } @@ -230,18 +305,40 @@ func (h *Handler) UpdatePreferences(c *gin.Context) { httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object")) return } + payload.normalize() if err := payload.validate(); err != nil { httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error())) 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.BadRequest("invalid_preferences", "could not encode preferences")) + httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found")) return } - if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil { - httpx.RespondError(c, err) - 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 } - c.JSON(http.StatusOK, gin.H{"preferences": payload}) + 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 { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences")) + return + } + if err := h.svc.store.UpdatePreferences(ctx, UserID(c), raw); err != nil { + httpx.RespondError(c, err) + return + } + } + c.JSON(http.StatusOK, gin.H{"preferences": merged}) } diff --git a/backend/internal/auth/handlers_test.go b/backend/internal/auth/handlers_test.go index 33cc075..07c3cfe 100644 --- a/backend/internal/auth/handlers_test.go +++ b/backend/internal/auth/handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -30,6 +31,7 @@ func newTestRouter(t *testing.T) *gin.Engine { authed.PATCH("/auth/me", h.UpdateMe) authed.POST("/auth/me/password", h.ChangePassword) authed.POST("/auth/me/identifiers", h.AddIdentifier) + authed.PATCH("/auth/me/preferences", h.UpdatePreferences) authed.DELETE("/auth/me", h.DeleteMe) return r } @@ -213,3 +215,215 @@ func TestDeleteMeHandler(t *testing.T) { 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) + } +} diff --git a/backend/internal/e2e/curve_presets_test.go b/backend/internal/e2e/curve_presets_test.go new file mode 100644 index 0000000..58c6ec0 --- /dev/null +++ b/backend/internal/e2e/curve_presets_test.go @@ -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) + } +} diff --git a/backend/internal/photos/edit.go b/backend/internal/photos/edit.go index 35971d1..b90ce83 100644 --- a/backend/internal/photos/edit.go +++ b/backend/internal/photos/edit.go @@ -317,8 +317,9 @@ func clampInt(v, min, max int) int { } // 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 -// values in between are interpolated linearly. +// curve. The first and last points extend to the edges of the range as flat +// shelves, and the values in between are interpolated with a monotone cubic +// Hermite spline (PCHIP, Fritsch–Carlson). func curveLUT(points []CurvePoint) [256]uint8 { var lut [256]uint8 pts := normalizeCurve(points) @@ -328,16 +329,71 @@ func curveLUT(points []CurvePoint) [256]uint8 { } return lut } + tangents := curveTangents(pts) for i := 0; i < 256; i++ { 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 } -// curveEval evaluates the curve at x using linear interpolation between the -// surrounding control points. +// curveTangents returns the PCHIP slopes at the control points. Interior +// 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 { + 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 { return points[0].Y } @@ -345,19 +401,23 @@ func curveEval(points []CurvePoint, x float64) float64 { if x >= last.X { return last.Y } - for i := 1; i < len(points); i++ { - if x > points[i].X { - continue - } - x0, y0 := points[i-1].X, points[i-1].Y - x1, y1 := points[i].X, points[i].Y - if x1-x0 < 1e-9 { - return y1 - } - t := (x - x0) / (x1 - x0) - return y0 + t*(y1-y0) + i := 1 + for i < len(points)-1 && x > points[i].X { + i++ } - return last.Y + p0, p1 := points[i-1], points[i] + h := p1.X - p0.X + if h < 1e-9 { + return p1.Y + } + t := (x - p0.X) / h + t2 := t * t + t3 := t2 * t + 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 diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go index 3a9c5d7..0c7e66e 100644 --- a/backend/internal/photos/edit_test.go +++ b/backend/internal/photos/edit_test.go @@ -91,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) { src := image.NewNRGBA(image.Rect(0, 0, 2, 2)) for i := range src.Pix { diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index cd1c5b4..64598fc 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -22,10 +22,16 @@ let picking = $state(false); let photo = $state(null); + // Вкладка правой панели живёт здесь и переживает смену фото. + let photoTab = $state<'info' | 'edit'>('info'); const selectedItems = $derived(selection.albumId === albumId ? selection.items : []); 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'); @@ -51,7 +57,9 @@ } function onRenamed(updated: Photo) { - photo = updated; + // Фоновое сохранение предыдущего фото может завершиться уже после + // переключения: его ответ не должен подменять текущее фото в панели. + if (updated.id === photoId) photo = updated; if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated); photoUpdates.publish(updated); } @@ -90,9 +98,17 @@ photo = null; return; } + let cancelled = false; api<{ photo: Photo }>(`/photos/${id}`) - .then((data) => (photo = data.photo)) - .catch((e) => (error = (e as Error).message)); + .then((data) => { + if (!cancelled && data.photo.id === id) photo = data.photo; + }) + .catch((e) => { + if (!cancelled) error = (e as Error).message; + }); + return () => { + cancelled = true; + }; }); async function resetCover() { @@ -146,6 +162,7 @@ {#if panelPhoto} + 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; + presets: CurvePreset[]; + activeName: string; + disabled?: boolean; + onApply: (preset: CurvePreset) => void; + onSave: (preset: CurvePreset) => Promise; + onRename: (oldName: string, newName: string) => Promise; + onDelete: (name: string) => Promise; + onImport: (presets: CurvePreset[]) => Promise; + } + + 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(); + let menuElement = $state(); + let menuStyle = $state(''); + let fileInput = $state(); + let nameInput = $state(); + + 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 || 'Не удалось экспортировать пресеты'; + } + } + + + { + if (dialog && ev.key === 'Escape' && !busy) { + ev.preventDefault(); + closeDialog(); + } + }} +/> + +
+ + + {#if open} + + + {/if} + + +
+ +{#if dialog} + +{/if} diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index 4c86ca6..bc7035f 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -1,11 +1,13 @@