feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера - пресеты всех каналов в профиле, импорт и экспорт - мерж настроек auth без потери разделов - сохранение вкладки и правок текущего фото при переключении
This commit is contained in:
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,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)
|
||||||
@@ -328,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
|
||||||
}
|
}
|
||||||
@@ -345,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
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
||||||
|
|||||||
@@ -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,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}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { photosApi } from '$lib/api';
|
import { authApi, photosApi } from '$lib/api';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import EditSlider from './EditSlider.svelte';
|
import EditSlider from './EditSlider.svelte';
|
||||||
import ColorWheel from './ColorWheel.svelte';
|
import ColorWheel from './ColorWheel.svelte';
|
||||||
|
import CurvePresetMenu from './CurvePresetMenu.svelte';
|
||||||
import { edit } from '$lib/stores/edit.svelte';
|
import { edit } from '$lib/stores/edit.svelte';
|
||||||
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { applyColor, applyLight } from '$lib/light-wasm';
|
import { applyColor, applyLight } from '$lib/light-wasm';
|
||||||
import {
|
import {
|
||||||
CHANNEL_COLORS,
|
CHANNEL_COLORS,
|
||||||
@@ -14,7 +16,10 @@
|
|||||||
CURVE_CHANNELS,
|
CURVE_CHANNELS,
|
||||||
GRADING_WHEELS,
|
GRADING_WHEELS,
|
||||||
LIGHT_SLIDERS,
|
LIGHT_SLIDERS,
|
||||||
|
MAX_CURVE_POINTS,
|
||||||
MIXER_BANDS,
|
MIXER_BANDS,
|
||||||
|
curveSvgPath,
|
||||||
|
effectiveCurvePoints,
|
||||||
emptyEditParams,
|
emptyEditParams,
|
||||||
isEmptyEditParams,
|
isEmptyEditParams,
|
||||||
normalizeEditParams,
|
normalizeEditParams,
|
||||||
@@ -28,7 +33,12 @@
|
|||||||
type MixerBand,
|
type MixerBand,
|
||||||
type MixerBandKey
|
type MixerBandKey
|
||||||
} from '$lib/edit';
|
} from '$lib/edit';
|
||||||
import type { Photo } from '$lib/types';
|
import {
|
||||||
|
findActiveCurvePreset,
|
||||||
|
normalizeCurvePresets,
|
||||||
|
upsertCurvePresets
|
||||||
|
} from '$lib/curve-presets';
|
||||||
|
import type { CurvePreset, Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: Photo;
|
photo: Photo;
|
||||||
@@ -40,8 +50,13 @@
|
|||||||
let { photo, canEdit, viewer = false, onUpdated }: Props = $props();
|
let { photo, canEdit, viewer = false, onUpdated }: Props = $props();
|
||||||
|
|
||||||
let params = $state<EditParams>(emptyEditParams());
|
let params = $state<EditParams>(emptyEditParams());
|
||||||
let busy = $state(false);
|
let savingCount = $state(0);
|
||||||
|
const busy = $derived(savingCount > 0);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
// Номер последней операции на фото: ответы старых запросов не должны
|
||||||
|
// перетирать более свежие правки (например, после переключения фото).
|
||||||
|
let saveSequence = 0;
|
||||||
|
const latestSaveByPhoto = new Map<string, number>();
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
let curveCollapsed = $state(false);
|
let curveCollapsed = $state(false);
|
||||||
let channel = $state<CurveChannel>('rgb');
|
let channel = $state<CurveChannel>('rgb');
|
||||||
@@ -61,20 +76,34 @@
|
|||||||
// Правки сохранены, но отредактированная версия ещё рендерится.
|
// Правки сохранены, но отредактированная версия ещё рендерится.
|
||||||
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
||||||
|
|
||||||
|
const customPresets = $derived(normalizeCurvePresets(auth.preferences.curve_presets));
|
||||||
|
const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({
|
||||||
|
rgb: channelPoints('rgb'),
|
||||||
|
red: channelPoints('red'),
|
||||||
|
green: channelPoints('green'),
|
||||||
|
blue: channelPoints('blue')
|
||||||
|
});
|
||||||
|
const activePreset = $derived(findActiveCurvePreset(effectiveCurves, customPresets));
|
||||||
|
const activePresetName = $derived(activePreset?.name ?? 'Пользовательский');
|
||||||
|
|
||||||
// При смене фото параметры берутся из метаданных.
|
// При смене фото параметры берутся из метаданных.
|
||||||
let lastPhotoId = '';
|
let lastPhotoId = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = photo.id;
|
const id = photo?.id;
|
||||||
if (id === lastPhotoId) return;
|
if (!id) return;
|
||||||
|
if (id !== lastPhotoId) {
|
||||||
lastPhotoId = id;
|
lastPhotoId = id;
|
||||||
|
untrack(() => {
|
||||||
eyedropper.stop();
|
eyedropper.stop();
|
||||||
params = readEditParams(photo);
|
params = readEditParams(photo);
|
||||||
edit.set(id, params);
|
edit.set(id, params);
|
||||||
error = '';
|
error = '';
|
||||||
return () => {
|
});
|
||||||
// Фото переключилось: отправляем накопленные правки.
|
}
|
||||||
flushSave();
|
// Cleanup регистрируется на каждом запуске эффекта: иначе повторный
|
||||||
};
|
// запуск из-за обновления объекта photo (тот же id) потерял бы
|
||||||
|
// сохранение накопленных правок при переключении фото.
|
||||||
|
return () => flushSave();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Пипетка баланса белого: просмотрщик присылает цвет нейтральной точки.
|
// Пипетка баланса белого: просмотрщик присылает цвет нейтральной точки.
|
||||||
@@ -217,16 +246,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTo(id: string, next: EditParams) {
|
async function saveTo(id: string, next: EditParams) {
|
||||||
if (busy) return;
|
const seq = ++saveSequence;
|
||||||
busy = true;
|
latestSaveByPhoto.set(id, seq);
|
||||||
|
savingCount++;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await photosApi.edit(id, next);
|
const res = await photosApi.edit(id, next);
|
||||||
onUpdated(res.photo);
|
if (latestSaveByPhoto.get(id) === seq) onUpdated(res.photo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
if (latestSaveByPhoto.get(id) === seq) error = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
savingCount--;
|
||||||
|
if (latestSaveByPhoto.get(id) === seq) latestSaveByPhoto.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,14 +448,60 @@
|
|||||||
channel = next;
|
channel = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Точки канала: у пустого канала — линейная кривая с крайними точками. */
|
||||||
function channelPoints(ch: CurveChannel): CurvePoint[] {
|
function channelPoints(ch: CurveChannel): CurvePoint[] {
|
||||||
return params.light.curve?.[ch] ?? [];
|
return effectiveCurvePoints(params.light.curve?.[ch]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть ли у канала сохранённая (не пустая) кривая. */
|
||||||
|
function hasChannelCurve(ch: CurveChannel): boolean {
|
||||||
|
return (params.light.curve?.[ch]?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function curvePath(ch: CurveChannel): string {
|
function curvePath(ch: CurveChannel): string {
|
||||||
const pts = channelPoints(ch);
|
return curveSvgPath(channelPoints(ch));
|
||||||
if (pts.length < 2) return 'M0,100 L100,0';
|
}
|
||||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x * 100},${(1 - p.y) * 100}`).join(' ');
|
|
||||||
|
function applyPreset(preset: CurvePreset) {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const curve = { ...params.light.curve };
|
||||||
|
for (const ch of CURVE_CHANNELS) {
|
||||||
|
curve[ch] = preset.channels[ch].map((point) => ({ ...point }));
|
||||||
|
}
|
||||||
|
const next = normalizeEditParams({
|
||||||
|
...params,
|
||||||
|
light: { ...params.light, curve }
|
||||||
|
});
|
||||||
|
params = next;
|
||||||
|
edit.set(photo.id, next);
|
||||||
|
onSliderCommit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurvePresets(next: CurvePreset[]) {
|
||||||
|
const res = await authApi.savePreferences({ curve_presets: next });
|
||||||
|
auth.setPreferences(res.preferences ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSavePreset(preset: CurvePreset) {
|
||||||
|
await saveCurvePresets(upsertCurvePresets(customPresets, [preset]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRenamePreset(oldName: string, newName: string) {
|
||||||
|
if (newName === oldName) return;
|
||||||
|
if (customPresets.some((item) => item.name === newName)) {
|
||||||
|
throw new Error('Пресет с таким названием уже есть');
|
||||||
|
}
|
||||||
|
await saveCurvePresets(
|
||||||
|
customPresets.map((item) => (item.name === oldName ? { ...item, name: newName } : item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeletePreset(name: string) {
|
||||||
|
await saveCurvePresets(customPresets.filter((item) => item.name !== name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImportPresets(imported: CurvePreset[]) {
|
||||||
|
await saveCurvePresets(upsertCurvePresets(customPresets, imported));
|
||||||
}
|
}
|
||||||
|
|
||||||
let dragging = -1;
|
let dragging = -1;
|
||||||
@@ -452,7 +529,7 @@
|
|||||||
if (target && target.tagName.toLowerCase() === 'circle') return;
|
if (target && target.tagName.toLowerCase() === 'circle') return;
|
||||||
const ch = channel;
|
const ch = channel;
|
||||||
const pts = [...channelPoints(ch)];
|
const pts = [...channelPoints(ch)];
|
||||||
if (pts.length >= 16) return;
|
if (pts.length >= MAX_CURVE_POINTS) return;
|
||||||
const point = pointFromEvent(ev);
|
const point = pointFromEvent(ev);
|
||||||
let index = pts.findIndex((p) => p.x > point.x);
|
let index = pts.findIndex((p) => p.x > point.x);
|
||||||
if (index < 0) index = pts.length;
|
if (index < 0) index = pts.length;
|
||||||
@@ -470,7 +547,11 @@
|
|||||||
const ch = channel;
|
const ch = channel;
|
||||||
const pts = [...channelPoints(ch)];
|
const pts = [...channelPoints(ch)];
|
||||||
if (dragging >= pts.length) return;
|
if (dragging >= pts.length) return;
|
||||||
pts[dragging] = pointFromEvent(ev);
|
const point = pointFromEvent(ev);
|
||||||
|
const minX = dragging > 0 ? pts[dragging - 1].x + 1e-4 : 0;
|
||||||
|
const maxX = dragging < pts.length - 1 ? pts[dragging + 1].x - 1e-4 : 1;
|
||||||
|
const x = maxX >= minX ? Math.min(maxX, Math.max(minX, point.x)) : minX;
|
||||||
|
pts[dragging] = { x, y: point.y };
|
||||||
params = {
|
params = {
|
||||||
...params,
|
...params,
|
||||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
||||||
@@ -499,8 +580,11 @@
|
|||||||
const ch = channel;
|
const ch = channel;
|
||||||
const pts = [...channelPoints(ch)];
|
const pts = [...channelPoints(ch)];
|
||||||
if (index >= pts.length) return;
|
if (index >= pts.length) return;
|
||||||
|
const minX = index > 0 ? pts[index - 1].x + 1e-4 : 0;
|
||||||
|
const maxX = index < pts.length - 1 ? pts[index + 1].x - 1e-4 : 1;
|
||||||
|
const nextX = pts[index].x + dx;
|
||||||
pts[index] = {
|
pts[index] = {
|
||||||
x: Math.min(1, Math.max(0, pts[index].x + dx)),
|
x: maxX >= minX ? Math.min(maxX, Math.max(minX, nextX)) : minX,
|
||||||
y: Math.min(1, Math.max(0, pts[index].y + dy))
|
y: Math.min(1, Math.max(0, pts[index].y + dy))
|
||||||
};
|
};
|
||||||
params = {
|
params = {
|
||||||
@@ -523,18 +607,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetEdit(id: string) {
|
async function resetEdit(id: string) {
|
||||||
if (busy) return;
|
const seq = ++saveSequence;
|
||||||
busy = true;
|
latestSaveByPhoto.set(id, seq);
|
||||||
|
savingCount++;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await photosApi.resetEdit(id);
|
const res = await photosApi.resetEdit(id);
|
||||||
|
if (latestSaveByPhoto.get(id) === seq) {
|
||||||
|
// Сброс фонового (уже покинутого) фото не должен менять
|
||||||
|
// параметры открытого сейчас фото.
|
||||||
|
if (photo?.id === id) {
|
||||||
params = readEditParams(res.photo);
|
params = readEditParams(res.photo);
|
||||||
edit.set(id, params);
|
edit.set(id, params);
|
||||||
|
}
|
||||||
onUpdated(res.photo);
|
onUpdated(res.photo);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
if (latestSaveByPhoto.get(id) === seq) error = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
savingCount--;
|
||||||
|
if (latestSaveByPhoto.get(id) === seq) latestSaveByPhoto.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,8 +785,6 @@
|
|||||||
stroke-width="1.2"
|
stroke-width="1.2"
|
||||||
style="stroke: {channel === 'rgb' ? '#e5e5e5' : CHANNEL_COLORS[channel]}"
|
style="stroke: {channel === 'rgb' ? '#e5e5e5' : CHANNEL_COLORS[channel]}"
|
||||||
/>
|
/>
|
||||||
<circle cx="0" cy="100" r="1.8" fill="#e5e5e5" />
|
|
||||||
<circle cx="100" cy="0" r="1.8" fill="#e5e5e5" />
|
|
||||||
{#each channelPoints(channel) as point, index (index)}
|
{#each channelPoints(channel) as point, index (index)}
|
||||||
<circle
|
<circle
|
||||||
cx={point.x * 100}
|
cx={point.x * 100}
|
||||||
@@ -718,16 +808,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div class="mt-2 flex items-center justify-between gap-2">
|
<div class="mt-2 flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<span class="text-text-muted text-xs">Точечная кривая</span>
|
<span class="text-text-muted shrink-0 text-xs">Точечная кривая</span>
|
||||||
<span class="text-text-muted flex items-center gap-0.5 text-xs"
|
<CurvePresetMenu
|
||||||
>Линейный<Icon name="expand_more" class="size-3" /></span
|
curves={effectiveCurves}
|
||||||
>
|
presets={customPresets}
|
||||||
|
activeName={activePresetName}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onApply={applyPreset}
|
||||||
|
onSave={onSavePreset}
|
||||||
|
onRename={onRenamePreset}
|
||||||
|
onDelete={onDeletePreset}
|
||||||
|
onImport={onImportPresets}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
|
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
|
||||||
disabled={!canEdit || !channelPoints(channel).length}
|
disabled={!canEdit || !hasChannelCurve(channel)}
|
||||||
onclick={resetCurve}
|
onclick={resetCurve}
|
||||||
>
|
>
|
||||||
Сбросить
|
Сбросить
|
||||||
|
|||||||
@@ -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('');
|
||||||
@@ -509,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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
applyColorToPixels,
|
applyColorToPixels,
|
||||||
|
buildCurveLUT,
|
||||||
|
curveSvgPath,
|
||||||
|
effectiveCurvePoints,
|
||||||
emptyColor,
|
emptyColor,
|
||||||
emptyEditParams,
|
emptyEditParams,
|
||||||
isEmptyColor,
|
isEmptyColor,
|
||||||
@@ -182,3 +185,73 @@ describe('цветовые параметры', () => {
|
|||||||
expect(b).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+106
-10
@@ -154,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,
|
||||||
@@ -411,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), поэтому предпросмотр
|
||||||
@@ -423,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];
|
||||||
}
|
}
|
||||||
return last.y;
|
|
||||||
|
/**
|
||||||
|
* 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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
- Архивировать изменение после удовлетворения требований ревью проекта.
|
||||||
|
- Проверить архивированный результат.
|
||||||
Reference in New Issue
Block a user