feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера - пресеты всех каналов в профиле, импорт и экспорт - мерж настроек auth без потери разделов - сохранение вкладки и правок текущего фото при переключении
This commit is contained in:
@@ -4,7 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -181,6 +184,9 @@ const (
|
||||
maxLabelEntries = 500
|
||||
maxPreferenceKeyLen = 128
|
||||
maxPreferenceValueLen = 128
|
||||
maxCurvePresets = 100
|
||||
maxCurvePresetNameLen = 64
|
||||
maxCurvePresetPoints = 16
|
||||
)
|
||||
|
||||
type photoInfoPreferences struct {
|
||||
@@ -188,33 +194,102 @@ type photoInfoPreferences struct {
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type curvePresetPoint struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type curvePresetChannels struct {
|
||||
RGB []curvePresetPoint `json:"rgb"`
|
||||
Red []curvePresetPoint `json:"red"`
|
||||
Green []curvePresetPoint `json:"green"`
|
||||
Blue []curvePresetPoint `json:"blue"`
|
||||
}
|
||||
|
||||
type curvePreset struct {
|
||||
Name string `json:"name"`
|
||||
Channels curvePresetChannels `json:"channels"`
|
||||
}
|
||||
|
||||
type preferencesPayload struct {
|
||||
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
|
||||
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
|
||||
CurvePresets *[]curvePreset `json:"curve_presets,omitempty"`
|
||||
}
|
||||
|
||||
// normalize trims preset names before they are validated and stored.
|
||||
func (p *preferencesPayload) normalize() {
|
||||
if p.CurvePresets == nil {
|
||||
return
|
||||
}
|
||||
for i := range *p.CurvePresets {
|
||||
(*p.CurvePresets)[i].Name = strings.TrimSpace((*p.CurvePresets)[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (p preferencesPayload) validate() error {
|
||||
pi := p.PhotoInfo
|
||||
if pi == nil {
|
||||
return nil
|
||||
}
|
||||
if len(pi.Visible) > maxVisibleAttrs {
|
||||
return fmt.Errorf("too many visible attributes")
|
||||
}
|
||||
for _, k := range pi.Visible {
|
||||
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||
return fmt.Errorf("invalid visible attribute key")
|
||||
if pi != nil {
|
||||
if len(pi.Visible) > maxVisibleAttrs {
|
||||
return fmt.Errorf("too many visible attributes")
|
||||
}
|
||||
for _, k := range pi.Visible {
|
||||
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||
return fmt.Errorf("invalid visible attribute key")
|
||||
}
|
||||
}
|
||||
if len(pi.Labels) > maxLabelEntries {
|
||||
return fmt.Errorf("too many label overrides")
|
||||
}
|
||||
for k, v := range pi.Labels {
|
||||
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||
return fmt.Errorf("invalid label key")
|
||||
}
|
||||
if len(v) > maxPreferenceValueLen {
|
||||
return fmt.Errorf("label value too long")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pi.Labels) > maxLabelEntries {
|
||||
return fmt.Errorf("too many label overrides")
|
||||
if p.CurvePresets != nil {
|
||||
return validateCurvePresets(*p.CurvePresets)
|
||||
}
|
||||
for k, v := range pi.Labels {
|
||||
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||
return fmt.Errorf("invalid label key")
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCurvePresets(presets []curvePreset) error {
|
||||
if len(presets) > maxCurvePresets {
|
||||
return fmt.Errorf("too many curve presets")
|
||||
}
|
||||
names := make(map[string]struct{}, len(presets))
|
||||
for _, preset := range presets {
|
||||
if preset.Name == "" || utf8.RuneCountInString(preset.Name) > maxCurvePresetNameLen {
|
||||
return fmt.Errorf("invalid curve preset name")
|
||||
}
|
||||
if len(v) > maxPreferenceValueLen {
|
||||
return fmt.Errorf("label value too long")
|
||||
channels := []struct {
|
||||
name string
|
||||
points []curvePresetPoint
|
||||
}{
|
||||
{"rgb", preset.Channels.RGB},
|
||||
{"red", preset.Channels.Red},
|
||||
{"green", preset.Channels.Green},
|
||||
{"blue", preset.Channels.Blue},
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if len(channel.points) < 2 || len(channel.points) > maxCurvePresetPoints {
|
||||
return fmt.Errorf("invalid curve preset points for %s", channel.name)
|
||||
}
|
||||
for _, pt := range channel.points {
|
||||
if math.IsNaN(pt.X) || math.IsNaN(pt.Y) || math.IsInf(pt.X, 0) || math.IsInf(pt.Y, 0) {
|
||||
return fmt.Errorf("invalid curve preset point")
|
||||
}
|
||||
if pt.X < 0 || pt.X > 1 || pt.Y < 0 || pt.Y > 1 {
|
||||
return fmt.Errorf("invalid curve preset point")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, dup := names[preset.Name]; dup {
|
||||
return fmt.Errorf("duplicate curve preset name")
|
||||
}
|
||||
names[preset.Name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -230,18 +305,40 @@ func (h *Handler) UpdatePreferences(c *gin.Context) {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
|
||||
return
|
||||
}
|
||||
payload.normalize()
|
||||
if err := payload.validate(); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
u, err := h.svc.store.GetUserByID(ctx, UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
|
||||
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
// Merge the submitted sections into the stored blob so saving one section
|
||||
// never clears the others. Unknown keys are preserved.
|
||||
merged := make(map[string]any, len(u.Preferences)+2)
|
||||
for k, v := range u.Preferences {
|
||||
merged[k] = v
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": payload})
|
||||
if payload.PhotoInfo != nil {
|
||||
merged["photo_info"] = payload.PhotoInfo
|
||||
}
|
||||
if payload.CurvePresets != nil {
|
||||
merged["curve_presets"] = *payload.CurvePresets
|
||||
}
|
||||
if payload.PhotoInfo != nil || payload.CurvePresets != nil {
|
||||
raw, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.store.UpdatePreferences(ctx, UserID(c), raw); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": merged})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,7 @@ func newTestRouter(t *testing.T) *gin.Engine {
|
||||
authed.PATCH("/auth/me", h.UpdateMe)
|
||||
authed.POST("/auth/me/password", h.ChangePassword)
|
||||
authed.POST("/auth/me/identifiers", h.AddIdentifier)
|
||||
authed.PATCH("/auth/me/preferences", h.UpdatePreferences)
|
||||
authed.DELETE("/auth/me", h.DeleteMe)
|
||||
return r
|
||||
}
|
||||
@@ -213,3 +215,215 @@ func TestDeleteMeHandler(t *testing.T) {
|
||||
t.Fatalf("me after delete status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func curvePresetPayloadWith(name, channel string, points []map[string]float64) map[string]any {
|
||||
linear := func() []map[string]float64 {
|
||||
return []map[string]float64{
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 1, "y": 1},
|
||||
}
|
||||
}
|
||||
channels := map[string]any{
|
||||
"rgb": linear(),
|
||||
"red": linear(),
|
||||
"green": linear(),
|
||||
"blue": linear(),
|
||||
}
|
||||
channels[channel] = points
|
||||
return map[string]any{"name": name, "channels": channels}
|
||||
}
|
||||
|
||||
func curvePresetPayload(name string) map[string]any {
|
||||
return curvePresetPayloadWith(name, "rgb", []map[string]float64{
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 1, "y": 0.8},
|
||||
})
|
||||
}
|
||||
|
||||
func decodePreferences(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode preferences %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Preferences
|
||||
}
|
||||
|
||||
func TestUpdatePreferencesMergesSections(t *testing.T) {
|
||||
r := newTestRouter(t)
|
||||
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
|
||||
"email": "prefs@test.local", "password": "password123", "name": "Prefs",
|
||||
})
|
||||
token := decodeAuth(t, reg).Tokens.AccessToken
|
||||
|
||||
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"photo_info": map[string]any{
|
||||
"visible": []string{"camera"},
|
||||
"labels": map[string]string{"camera": "Камера"},
|
||||
},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save photo_info status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"curve_presets": []any{curvePresetPayload("Мой")},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save presets status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
prefs := decodePreferences(t, rec)
|
||||
if _, ok := prefs["photo_info"]; !ok {
|
||||
t.Fatalf("photo_info lost after saving presets: %+v", prefs)
|
||||
}
|
||||
presets, ok := prefs["curve_presets"].([]any)
|
||||
if !ok || len(presets) != 1 {
|
||||
t.Fatalf("presets = %#v, want one", prefs["curve_presets"])
|
||||
}
|
||||
|
||||
// Saving photo_info again must keep the presets.
|
||||
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"photo_info": map[string]any{"visible": []string{"iso"}},
|
||||
})
|
||||
prefs = decodePreferences(t, rec)
|
||||
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 1 {
|
||||
t.Fatalf("presets lost after saving photo_info: %+v", prefs)
|
||||
}
|
||||
info, _ := prefs["photo_info"].(map[string]any)
|
||||
if visible, _ := info["visible"].([]any); len(visible) != 1 || visible[0] != "iso" {
|
||||
t.Fatalf("photo_info not updated: %+v", prefs["photo_info"])
|
||||
}
|
||||
|
||||
// The merged state is what GET /auth/me returns.
|
||||
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
|
||||
var me struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
|
||||
t.Fatalf("decode me: %v", err)
|
||||
}
|
||||
if _, ok := me.Preferences["photo_info"]; !ok {
|
||||
t.Fatalf("me lost photo_info: %+v", me.Preferences)
|
||||
}
|
||||
if _, ok := me.Preferences["curve_presets"]; !ok {
|
||||
t.Fatalf("me lost curve_presets: %+v", me.Preferences)
|
||||
}
|
||||
|
||||
// An empty preset list clears only presets.
|
||||
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"curve_presets": []any{},
|
||||
})
|
||||
prefs = decodePreferences(t, rec)
|
||||
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 0 {
|
||||
t.Fatalf("presets not cleared: %+v", prefs["curve_presets"])
|
||||
}
|
||||
if _, ok := prefs["photo_info"]; !ok {
|
||||
t.Fatalf("photo_info lost after clearing presets: %+v", prefs)
|
||||
}
|
||||
|
||||
// Names are stored without surrounding whitespace.
|
||||
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"curve_presets": []any{curvePresetPayload(" Мой ")},
|
||||
})
|
||||
prefs = decodePreferences(t, rec)
|
||||
presets = prefs["curve_presets"].([]any)
|
||||
first, _ := presets[0].(map[string]any)
|
||||
if first["name"] != "Мой" {
|
||||
t.Fatalf("preset name = %#v, want trimmed", first["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesArePrivate(t *testing.T) {
|
||||
r := newTestRouter(t)
|
||||
first := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
|
||||
"email": "private1@test.local", "password": "password123", "name": "One",
|
||||
}))
|
||||
second := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
|
||||
"email": "private2@test.local", "password": "password123", "name": "Two",
|
||||
}))
|
||||
|
||||
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", first.Tokens.AccessToken, map[string]any{
|
||||
"curve_presets": []any{curvePresetPayload("Секрет")},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(r, http.MethodGet, "/auth/me", second.Tokens.AccessToken, nil)
|
||||
var me struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
|
||||
t.Fatalf("decode me: %v", err)
|
||||
}
|
||||
if _, ok := me.Preferences["curve_presets"]; ok {
|
||||
t.Fatalf("other user sees presets: %+v", me.Preferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePreferencesRejectsInvalidCurvePresets(t *testing.T) {
|
||||
r := newTestRouter(t)
|
||||
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
|
||||
"email": "invalidprefs@test.local", "password": "password123", "name": "Invalid",
|
||||
})
|
||||
token := decodeAuth(t, reg).Tokens.AccessToken
|
||||
|
||||
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"curve_presets": []any{curvePresetPayload("Хороший")},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
manyPoints := make([]map[string]float64, 17)
|
||||
for i := range manyPoints {
|
||||
manyPoints[i] = map[string]float64{"x": float64(i) / 16, "y": float64(i) / 16}
|
||||
}
|
||||
tooMany := make([]any, 101)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = curvePresetPayload("preset-" + string(rune('a'+i%26)) + strings.Repeat("x", i/26))
|
||||
}
|
||||
|
||||
missingChannel := curvePresetPayload("bad")
|
||||
delete(missingChannel["channels"].(map[string]any), "blue")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
presets []any
|
||||
}{
|
||||
{"too long name", []any{curvePresetPayload(strings.Repeat("a", 65))}},
|
||||
{"missing channel", []any{missingChannel}},
|
||||
{"one point", []any{curvePresetPayloadWith("one", "red", []map[string]float64{
|
||||
{"x": 0, "y": 0},
|
||||
})}},
|
||||
{"too many points", []any{curvePresetPayloadWith("many", "rgb", manyPoints)}},
|
||||
{"x out of range", []any{curvePresetPayloadWith("range", "blue", []map[string]float64{
|
||||
{"x": 1.1, "y": 0},
|
||||
{"x": 1, "y": 1},
|
||||
})}},
|
||||
{"duplicate name", []any{curvePresetPayload("dup"), curvePresetPayload("dup")}},
|
||||
{"too many presets", tooMany},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
|
||||
"curve_presets": tc.presets,
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d body = %s", tc.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
|
||||
var me struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
|
||||
t.Fatalf("decode me: %v", err)
|
||||
}
|
||||
presets, _ := me.Preferences["curve_presets"].([]any)
|
||||
if len(presets) != 1 {
|
||||
t.Fatalf("invalid requests changed presets: %+v", me.Preferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// curve. The first and last points extend to the edges of the range, and the
|
||||
// values in between are interpolated linearly.
|
||||
// curve. The first and last points extend to the edges of the range as flat
|
||||
// shelves, and the values in between are interpolated with a monotone cubic
|
||||
// Hermite spline (PCHIP, Fritsch–Carlson).
|
||||
func curveLUT(points []CurvePoint) [256]uint8 {
|
||||
var lut [256]uint8
|
||||
pts := normalizeCurve(points)
|
||||
@@ -328,16 +329,71 @@ func curveLUT(points []CurvePoint) [256]uint8 {
|
||||
}
|
||||
return lut
|
||||
}
|
||||
tangents := curveTangents(pts)
|
||||
for i := 0; i < 256; i++ {
|
||||
x := float64(i) / 255
|
||||
lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255))
|
||||
lut[i] = uint8(math.Round(clamp01(curveEvalWithTangents(pts, tangents, x)) * 255))
|
||||
}
|
||||
return lut
|
||||
}
|
||||
|
||||
// curveEval evaluates the curve at x using linear interpolation between the
|
||||
// surrounding control points.
|
||||
// curveTangents returns the PCHIP slopes at the control points. Interior
|
||||
// tangents are weighted harmonic means of the neighbouring slopes; endpoint
|
||||
// tangents use the one-sided three-point formula with the Fritsch–Carlson
|
||||
// shape-preserving clamp.
|
||||
func curveTangents(points []CurvePoint) []float64 {
|
||||
n := len(points)
|
||||
if n < 2 {
|
||||
return nil
|
||||
}
|
||||
h := make([]float64, n-1)
|
||||
delta := make([]float64, n-1)
|
||||
for i := 0; i < n-1; i++ {
|
||||
h[i] = points[i+1].X - points[i].X
|
||||
delta[i] = (points[i+1].Y - points[i].Y) / h[i]
|
||||
}
|
||||
m := make([]float64, n)
|
||||
if n == 2 {
|
||||
m[0] = delta[0]
|
||||
m[1] = delta[0]
|
||||
return m
|
||||
}
|
||||
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1])
|
||||
m[n-1] = endpointTangent(h[n-2], h[n-3], delta[n-2], delta[n-3])
|
||||
for i := 1; i < n-1; i++ {
|
||||
if delta[i-1]*delta[i] <= 0 {
|
||||
m[i] = 0
|
||||
continue
|
||||
}
|
||||
w1 := 2*h[i] + h[i-1]
|
||||
w2 := h[i] + 2*h[i-1]
|
||||
m[i] = (w1 + w2) / (w1/delta[i-1] + w2/delta[i])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// endpointTangent is the one-sided endpoint slope of the PCHIP spline.
|
||||
func endpointTangent(h0, h1, d0, d1 float64) float64 {
|
||||
m := ((2*h0+h1)*d0 - h0*d1) / (h0 + h1)
|
||||
if m*d0 <= 0 {
|
||||
return 0
|
||||
}
|
||||
if d0*d1 < 0 && math.Abs(m) > math.Abs(3*d0) {
|
||||
return 3 * d0
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// curveEval evaluates the curve at x with a monotone cubic Hermite spline.
|
||||
// Outside the first and last points the value is constant.
|
||||
func curveEval(points []CurvePoint, x float64) float64 {
|
||||
if len(points) < 2 {
|
||||
return x
|
||||
}
|
||||
return curveEvalWithTangents(points, curveTangents(points), x)
|
||||
}
|
||||
|
||||
func curveEvalWithTangents(points []CurvePoint, tangents []float64, x float64) float64 {
|
||||
if x <= points[0].X {
|
||||
return points[0].Y
|
||||
}
|
||||
@@ -345,19 +401,23 @@ func curveEval(points []CurvePoint, x float64) float64 {
|
||||
if x >= last.X {
|
||||
return last.Y
|
||||
}
|
||||
for i := 1; i < len(points); i++ {
|
||||
if x > points[i].X {
|
||||
continue
|
||||
}
|
||||
x0, y0 := points[i-1].X, points[i-1].Y
|
||||
x1, y1 := points[i].X, points[i].Y
|
||||
if x1-x0 < 1e-9 {
|
||||
return y1
|
||||
}
|
||||
t := (x - x0) / (x1 - x0)
|
||||
return y0 + t*(y1-y0)
|
||||
i := 1
|
||||
for i < len(points)-1 && x > points[i].X {
|
||||
i++
|
||||
}
|
||||
return last.Y
|
||||
p0, p1 := points[i-1], points[i]
|
||||
h := p1.X - p0.X
|
||||
if h < 1e-9 {
|
||||
return p1.Y
|
||||
}
|
||||
t := (x - p0.X) / h
|
||||
t2 := t * t
|
||||
t3 := t2 * t
|
||||
h00 := 2*t3 - 3*t2 + 1
|
||||
h10 := t3 - 2*t2 + t
|
||||
h01 := -2*t3 + 3*t2
|
||||
h11 := t3 - t2
|
||||
return h00*p0.Y + h10*h*tangents[i-1] + h01*p1.Y + h11*h*tangents[i]
|
||||
}
|
||||
|
||||
// lightAdjuster holds the precomputed factors of the light adjustments. The
|
||||
|
||||
@@ -91,6 +91,70 @@ func TestCurveLUTSteepens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The client builds the same 256-entry LUT (frontend/src/lib/edit.ts). The
|
||||
// hard-coded samples keep the Go and TypeScript implementations in sync.
|
||||
func TestCurveLUTMatchesClient(t *testing.T) {
|
||||
samples := []int{0, 32, 64, 128, 192, 224, 255}
|
||||
cases := []struct {
|
||||
name string
|
||||
points []CurvePoint
|
||||
want []int
|
||||
}{
|
||||
{"linear", []CurvePoint{{0, 0}, {1, 1}}, []int{0, 32, 64, 128, 192, 224, 255}},
|
||||
{
|
||||
"medium",
|
||||
[]CurvePoint{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
|
||||
[]int{0, 23, 51, 128, 205, 233, 255},
|
||||
},
|
||||
{
|
||||
"strong",
|
||||
[]CurvePoint{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
|
||||
[]int{0, 17, 43, 128, 213, 239, 255},
|
||||
},
|
||||
{
|
||||
"inner",
|
||||
[]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}},
|
||||
[]int{13, 17, 42, 104, 186, 234, 242},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
lut := curveLUT(tc.points)
|
||||
for i, index := range samples {
|
||||
if int(lut[index]) != tc.want[i] {
|
||||
t.Fatalf("%s lut[%d] = %d, want %d", tc.name, index, lut[index], tc.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurveLUTIsMonotone(t *testing.T) {
|
||||
for _, points := range [][]CurvePoint{
|
||||
{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
|
||||
{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
|
||||
} {
|
||||
lut := curveLUT(points)
|
||||
for i := 1; i < len(lut); i++ {
|
||||
if lut[i] < lut[i-1] {
|
||||
t.Fatalf("lut[%d] = %d < lut[%d] = %d", i, lut[i], i-1, lut[i-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurveLUTFlatOutsideEndpoints(t *testing.T) {
|
||||
lut := curveLUT([]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}})
|
||||
for i := 0; i <= int(math.Floor(0.1*255)); i++ {
|
||||
if lut[i] != 13 {
|
||||
t.Fatalf("lut[%d] = %d, want flat 13", i, lut[i])
|
||||
}
|
||||
}
|
||||
for i := 230; i < len(lut); i++ {
|
||||
if lut[i] != 242 {
|
||||
t.Fatalf("lut[%d] = %d, want flat 242", i, lut[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLightBrightensAndKeepsSource(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
for i := range src.Pix {
|
||||
|
||||
Reference in New Issue
Block a user