Compare commits
21
Commits
f8ee93777d
...
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc5c0f340 | ||
|
|
a481375783 | ||
|
|
9e39e3d84c | ||
|
|
0bfee5f2f8 | ||
|
|
3b8406fb4c | ||
|
|
502f2766d7 | ||
|
|
d791b9a572 | ||
|
|
c886c6f22c | ||
|
|
723b19bbe5 | ||
|
|
afe85f7be0 | ||
|
|
70ac12db10 | ||
|
|
8dd8cb6f0c | ||
|
|
759e78ff79 | ||
|
|
b075f4c3a4 | ||
|
|
748143c9d7 | ||
|
|
eb2d478275 | ||
|
|
332f35b553 | ||
|
|
48ed0bb775 | ||
|
|
b64574b921 | ||
|
|
d904b2fe31 | ||
|
|
2668d87347 |
@@ -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,33 +194,102 @@ 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 {
|
||||||
}
|
return fmt.Errorf("too many visible attributes")
|
||||||
if len(pi.Visible) > maxVisibleAttrs {
|
}
|
||||||
return fmt.Errorf("too many visible attributes")
|
for _, k := range pi.Visible {
|
||||||
}
|
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||||
for _, k := range pi.Visible {
|
return fmt.Errorf("invalid visible attribute key")
|
||||||
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 {
|
if p.CurvePresets != nil {
|
||||||
return fmt.Errorf("too many label overrides")
|
return validateCurvePresets(*p.CurvePresets)
|
||||||
}
|
}
|
||||||
for k, v := range pi.Labels {
|
return nil
|
||||||
if k == "" || len(k) > maxPreferenceKeyLen {
|
}
|
||||||
return fmt.Errorf("invalid label key")
|
|
||||||
|
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 {
|
channels := []struct {
|
||||||
return fmt.Errorf("label value too long")
|
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 {
|
if err != nil {
|
||||||
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
|
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
|
// Merge the submitted sections into the stored blob so saving one section
|
||||||
httpx.RespondError(c, err)
|
// never clears the others. Unknown keys are preserved.
|
||||||
return
|
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"
|
"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,75 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galo/internal/albums"
|
||||||
|
"galo/internal/photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A color edit must survive the round trip: the parameters are stored in the
|
||||||
|
// photo metadata, the edited version is rendered, and a reset returns the
|
||||||
|
// photo to its original look.
|
||||||
|
func TestColorEditRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "color@test.local", "", "password123", "C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Color"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||||
|
AlbumID: album.ID, UserID: user.ID, Filename: "c.png", Data: makePNG(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
color := photos.ColorParams{Temperature: 25, Vibrance: 20}
|
||||||
|
color.Mixer.Red.Saturation = 30
|
||||||
|
color.Grading.Shadows = photos.ColorWheel{Hue: 220, Saturation: 40}
|
||||||
|
edited, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||||
|
Light: photos.LightParams{Exposure: 0.3},
|
||||||
|
Color: color,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("edit: %v", err)
|
||||||
|
}
|
||||||
|
if edited.EditParams == nil {
|
||||||
|
t.Fatal("edit returned no parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.EditPreviewURL == "" {
|
||||||
|
t.Fatalf("no edited preview url: %+v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got.EditParams["color"]; !ok {
|
||||||
|
t.Fatalf("color params not stored: %+v", got.EditParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reset: %v", err)
|
||||||
|
}
|
||||||
|
if len(reset.EditParams) != 0 {
|
||||||
|
t.Fatalf("reset kept params: %+v", reset.EditParams)
|
||||||
|
}
|
||||||
|
if reset.EditURL != "" || reset.EditPreviewURL != "" {
|
||||||
|
t.Fatalf("reset kept the edited version: %+v", reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
assets "galo"
|
||||||
|
"galo/internal/albums"
|
||||||
|
"galo/internal/photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A copy of an edited photo must not inherit the edited version: the edit
|
||||||
|
// belongs to the photo whose edits produced it.
|
||||||
|
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "copy@test.local", "", "password123", "C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
source, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get source: %v", err)
|
||||||
|
}
|
||||||
|
if source.EditThumbURL == "" || source.EditPreviewURL == "" {
|
||||||
|
t.Fatalf("source has no edited version: %+v", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
|
||||||
|
// The copy shares the base variants with the source.
|
||||||
|
if clone.ThumbURL != source.ThumbURL || clone.PreviewURL != source.PreviewURL {
|
||||||
|
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", source, clone)
|
||||||
|
}
|
||||||
|
// But it shows the original picture and carries no edit parameters.
|
||||||
|
if clone.EditURL != "" || clone.EditThumbURL != "" || clone.EditMediumURL != "" || clone.EditPreviewURL != "" {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", clone)
|
||||||
|
}
|
||||||
|
if len(clone.EditParams) != 0 {
|
||||||
|
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migration drops the edit bindings that copies carried before the fix,
|
||||||
|
// while the edited version of the photo that owns them stays untouched.
|
||||||
|
func TestMigrationDropsEditBindingsOfCopies(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "mig@test.local", "", "password123", "M")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
var editObjectID string
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&editObjectID); err != nil {
|
||||||
|
t.Fatalf("edited thumb object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
|
||||||
|
// Reproduce the state left by the old code: the copy carries the edited
|
||||||
|
// version of the source photo.
|
||||||
|
if _, err := st.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'edit_thumb')`,
|
||||||
|
clone.ID, editObjectID); err != nil {
|
||||||
|
t.Fatalf("bind edited version to the copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrations fs: %v", err)
|
||||||
|
}
|
||||||
|
up, err := fs.ReadFile(migFS, "000008_photo_edit_copy_bindings.up.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.ExecContext(ctx, string(up)); err != nil {
|
||||||
|
t.Fatalf("apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role LIKE 'edit%'`, clone.ID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count copy edit bindings: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("copy still carries %d edit bindings", n)
|
||||||
|
}
|
||||||
|
// The photo that owns the edited version keeps it.
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count source edit bindings: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("source edit bindings = %d, want 1", n)
|
||||||
|
}
|
||||||
|
// A bound object is never garbage, so the edited version stays alive.
|
||||||
|
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim garbage: %v", err)
|
||||||
|
}
|
||||||
|
for _, o := range claimed {
|
||||||
|
if o.ID == editObjectID {
|
||||||
|
t.Fatalf("edited version of the owner was claimed as garbage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Curve presets live in the user profile: they survive a round trip through
|
||||||
|
// the metadata layer and are never visible to another user.
|
||||||
|
func TestCurvePresetsPersistAndArePrivate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
owner, _, err := st.auth.Register(ctx, "presets@test.local", "", "password123", "Owner")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register owner: %v", err)
|
||||||
|
}
|
||||||
|
other, _, err := st.auth.Register(ctx, "otherpresets@test.local", "", "password123", "Other")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []byte(`{"curve_presets":[{"name":"Мой контраст","channels":{"rgb":[{"x":0,"y":0},{"x":1,"y":0.9}],"red":[{"x":0,"y":0},{"x":1,"y":1}],"green":[{"x":0,"y":0},{"x":1,"y":1}],"blue":[{"x":0,"y":0},{"x":1,"y":1}]}}]}`)
|
||||||
|
if err := st.repos.Auth.UpdatePreferences(ctx, owner.ID, raw); err != nil {
|
||||||
|
t.Fatalf("update preferences: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := st.repos.Auth.GetUserByID(ctx, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get owner: %v", err)
|
||||||
|
}
|
||||||
|
presets, ok := got.Preferences["curve_presets"].([]any)
|
||||||
|
if !ok || len(presets) != 1 {
|
||||||
|
t.Fatalf("presets not stored: %+v", got.Preferences)
|
||||||
|
}
|
||||||
|
preset, _ := presets[0].(map[string]any)
|
||||||
|
if preset["name"] != "Мой контраст" {
|
||||||
|
t.Fatalf("preset = %+v", preset)
|
||||||
|
}
|
||||||
|
channels, _ := preset["channels"].(map[string]any)
|
||||||
|
for _, channel := range []string{"rgb", "red", "green", "blue"} {
|
||||||
|
if points, ok := channels[channel].([]any); !ok || len(points) != 2 {
|
||||||
|
t.Fatalf("channel %s = %#v", channel, channels[channel])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otherUser, err := st.repos.Auth.GetUserByID(ctx, other.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get other: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := otherUser.Preferences["curve_presets"]; ok {
|
||||||
|
t.Fatalf("other user sees presets: %+v", otherUser.Preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,60 @@ func TestCopySharesStorageObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, cl, _ := newRecordingServiceFS(t, pool)
|
||||||
|
user := createUser(t, ctx, pool)
|
||||||
|
albumA := createAlbum(t, ctx, pool, user)
|
||||||
|
albumB := createAlbum(t, ctx, pool, user)
|
||||||
|
|
||||||
|
p, err := svc.Upload(ctx, UploadInput{
|
||||||
|
AlbumID: albumA, UserID: user, Filename: "p.png", MimeType: "image/png", Data: makePNG(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
|
||||||
|
|
||||||
|
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
|
||||||
|
w.processVariantJobs(ctx)
|
||||||
|
|
||||||
|
// The edit belongs to the photo: the parameters live in its metadata and
|
||||||
|
// the background render binds the edited version to it.
|
||||||
|
if _, err := svc.EditPhoto(ctx, p.ID, user, EditParams{Light: LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.processEditJobs(ctx)
|
||||||
|
|
||||||
|
src, err := svc.store.GetByID(ctx, p.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get source: %v", err)
|
||||||
|
}
|
||||||
|
if src.EditThumbKey == "" || src.EditPreviewKey == "" {
|
||||||
|
t.Fatalf("source has no edited version: %+v", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
|
||||||
|
|
||||||
|
// The copy shares the base variants with the source.
|
||||||
|
if clone.ThumbKey != src.ThumbKey || clone.MediumKey != src.MediumKey || clone.PreviewKey != src.PreviewKey {
|
||||||
|
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", src, clone)
|
||||||
|
}
|
||||||
|
// But it never inherits the edited version or its parameters.
|
||||||
|
if clone.EditKey != "" || clone.EditThumbKey != "" || clone.EditMediumKey != "" || clone.EditPreviewKey != "" {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", clone)
|
||||||
|
}
|
||||||
|
if len(clone.EditParams) != 0 {
|
||||||
|
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sameSet(a, b []string) bool {
|
func sameSet(a, b []string) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+452
-18
@@ -39,9 +39,93 @@ type CurvePoint struct {
|
|||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ColorParams holds the parameters of the "Цвет" (color) tool: white balance,
|
||||||
|
// vibrance/saturation, the per-hue color mixer, color grading and a simple
|
||||||
|
// per-channel color correction. Percent values use -100..100, wheel hues use
|
||||||
|
// 0..360.
|
||||||
|
type ColorParams struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Tint float64 `json:"tint"`
|
||||||
|
Vibrance float64 `json:"vibrance"`
|
||||||
|
Saturation float64 `json:"saturation"`
|
||||||
|
Mixer ColorMixer `json:"mixer"`
|
||||||
|
Grading ColorGrading `json:"grading"`
|
||||||
|
Correction ColorCorrection `json:"correction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorMixer holds one HSL band per hue, mirroring the Lightroom color mixer.
|
||||||
|
type ColorMixer struct {
|
||||||
|
Red MixerBand `json:"red"`
|
||||||
|
Orange MixerBand `json:"orange"`
|
||||||
|
Yellow MixerBand `json:"yellow"`
|
||||||
|
Green MixerBand `json:"green"`
|
||||||
|
Aqua MixerBand `json:"aqua"`
|
||||||
|
Blue MixerBand `json:"blue"`
|
||||||
|
Purple MixerBand `json:"purple"`
|
||||||
|
Magenta MixerBand `json:"magenta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MixerBand is a hue/saturation/luminance adjustment of one color band.
|
||||||
|
type MixerBand struct {
|
||||||
|
Hue float64 `json:"hue"`
|
||||||
|
Saturation float64 `json:"saturation"`
|
||||||
|
Luminance float64 `json:"luminance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorGrading holds the three color-grading wheels and their blending.
|
||||||
|
type ColorGrading struct {
|
||||||
|
Shadows ColorWheel `json:"shadows"`
|
||||||
|
Midtones ColorWheel `json:"midtones"`
|
||||||
|
Highlights ColorWheel `json:"highlights"`
|
||||||
|
Blending float64 `json:"blending"`
|
||||||
|
Balance float64 `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorWheel is a hue, strength and luminance used by color grading.
|
||||||
|
type ColorWheel struct {
|
||||||
|
Hue float64 `json:"hue"`
|
||||||
|
Saturation float64 `json:"saturation"`
|
||||||
|
Luminance float64 `json:"luminance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorCorrection is a simple per-channel gain, in percent.
|
||||||
|
type ColorCorrection struct {
|
||||||
|
Red float64 `json:"red"`
|
||||||
|
Green float64 `json:"green"`
|
||||||
|
Blue float64 `json:"blue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mixerBand is a color band with its hue center in degrees.
|
||||||
|
type mixerBand struct {
|
||||||
|
center float64
|
||||||
|
hue float64
|
||||||
|
saturation float64
|
||||||
|
luminance float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// bands returns the mixer bands in a stable order.
|
||||||
|
func (m ColorMixer) bands() [8]mixerBand {
|
||||||
|
return [8]mixerBand{
|
||||||
|
{0, m.Red.Hue, m.Red.Saturation, m.Red.Luminance},
|
||||||
|
{30, m.Orange.Hue, m.Orange.Saturation, m.Orange.Luminance},
|
||||||
|
{60, m.Yellow.Hue, m.Yellow.Saturation, m.Yellow.Luminance},
|
||||||
|
{120, m.Green.Hue, m.Green.Saturation, m.Green.Luminance},
|
||||||
|
{180, m.Aqua.Hue, m.Aqua.Saturation, m.Aqua.Luminance},
|
||||||
|
{240, m.Blue.Hue, m.Blue.Saturation, m.Blue.Luminance},
|
||||||
|
{280, m.Purple.Hue, m.Purple.Saturation, m.Purple.Luminance},
|
||||||
|
{320, m.Magenta.Hue, m.Magenta.Saturation, m.Magenta.Luminance},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wheels returns the grading wheels in a stable order.
|
||||||
|
func (g ColorGrading) wheels() [3]ColorWheel {
|
||||||
|
return [3]ColorWheel{g.Shadows, g.Midtones, g.Highlights}
|
||||||
|
}
|
||||||
|
|
||||||
// EditParams is the full set of non-destructive editing parameters of a photo.
|
// EditParams is the full set of non-destructive editing parameters of a photo.
|
||||||
type EditParams struct {
|
type EditParams struct {
|
||||||
Light LightParams `json:"light"`
|
Light LightParams `json:"light"`
|
||||||
|
Color ColorParams `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limits of the editing parameters. They are enforced on write so a hand-made
|
// Limits of the editing parameters. They are enforced on write so a hand-made
|
||||||
@@ -82,9 +166,54 @@ func (p EditParams) Normalized() EditParams {
|
|||||||
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
|
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
|
||||||
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
|
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
|
||||||
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
|
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
|
||||||
|
p.Color = p.Color.Normalized()
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalized returns a copy of the color parameters clamped to their ranges.
|
||||||
|
func (c ColorParams) Normalized() ColorParams {
|
||||||
|
c.Temperature = clampFloat(c.Temperature, -MaxPercent, MaxPercent)
|
||||||
|
c.Tint = clampFloat(c.Tint, -MaxPercent, MaxPercent)
|
||||||
|
c.Vibrance = clampFloat(c.Vibrance, -MaxPercent, MaxPercent)
|
||||||
|
c.Saturation = clampFloat(c.Saturation, -MaxPercent, MaxPercent)
|
||||||
|
|
||||||
|
bands := c.Mixer.bands()
|
||||||
|
for i := range bands {
|
||||||
|
bands[i].hue = clampFloat(bands[i].hue, -MaxPercent, MaxPercent)
|
||||||
|
bands[i].saturation = clampFloat(bands[i].saturation, -MaxPercent, MaxPercent)
|
||||||
|
bands[i].luminance = clampFloat(bands[i].luminance, -MaxPercent, MaxPercent)
|
||||||
|
}
|
||||||
|
c.Mixer = ColorMixer{
|
||||||
|
Red: MixerBand{bands[0].hue, bands[0].saturation, bands[0].luminance},
|
||||||
|
Orange: MixerBand{bands[1].hue, bands[1].saturation, bands[1].luminance},
|
||||||
|
Yellow: MixerBand{bands[2].hue, bands[2].saturation, bands[2].luminance},
|
||||||
|
Green: MixerBand{bands[3].hue, bands[3].saturation, bands[3].luminance},
|
||||||
|
Aqua: MixerBand{bands[4].hue, bands[4].saturation, bands[4].luminance},
|
||||||
|
Blue: MixerBand{bands[5].hue, bands[5].saturation, bands[5].luminance},
|
||||||
|
Purple: MixerBand{bands[6].hue, bands[6].saturation, bands[6].luminance},
|
||||||
|
Magenta: MixerBand{bands[7].hue, bands[7].saturation, bands[7].luminance},
|
||||||
|
}
|
||||||
|
|
||||||
|
wheels := c.Grading.wheels()
|
||||||
|
for i := range wheels {
|
||||||
|
wheels[i].Hue = clampFloat(wheels[i].Hue, 0, 360)
|
||||||
|
wheels[i].Saturation = clampFloat(wheels[i].Saturation, -MaxPercent, MaxPercent)
|
||||||
|
wheels[i].Luminance = clampFloat(wheels[i].Luminance, -MaxPercent, MaxPercent)
|
||||||
|
}
|
||||||
|
c.Grading = ColorGrading{
|
||||||
|
Shadows: wheels[0],
|
||||||
|
Midtones: wheels[1],
|
||||||
|
Highlights: wheels[2],
|
||||||
|
Blending: clampFloat(c.Grading.Blending, 0, MaxPercent),
|
||||||
|
Balance: clampFloat(c.Grading.Balance, -MaxPercent, MaxPercent),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Correction.Red = clampFloat(c.Correction.Red, -MaxPercent, MaxPercent)
|
||||||
|
c.Correction.Green = clampFloat(c.Correction.Green, -MaxPercent, MaxPercent)
|
||||||
|
c.Correction.Blue = clampFloat(c.Correction.Blue, -MaxPercent, MaxPercent)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// IsEmpty reports whether the parameters describe an untouched photo.
|
// IsEmpty reports whether the parameters describe an untouched photo.
|
||||||
func (p EditParams) IsEmpty() bool {
|
func (p EditParams) IsEmpty() bool {
|
||||||
l := p.Light
|
l := p.Light
|
||||||
@@ -99,7 +228,26 @@ func (p EditParams) IsEmpty() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return p.Color.IsEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty reports whether the color parameters leave the image untouched.
|
||||||
|
func (c ColorParams) IsEmpty() bool {
|
||||||
|
if c.Temperature != 0 || c.Tint != 0 || c.Vibrance != 0 || c.Saturation != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range c.Mixer.bands() {
|
||||||
|
if b.hue != 0 || b.saturation != 0 || b.luminance != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range c.Grading.wheels() {
|
||||||
|
// A hue without strength or luminance has no effect.
|
||||||
|
if w.Saturation != 0 || w.Luminance != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Correction.Red == 0 && c.Correction.Green == 0 && c.Correction.Blue == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON encodes the parameters for storage in the photo metadata.
|
// JSON encodes the parameters for storage in the photo metadata.
|
||||||
@@ -169,8 +317,9 @@ func clampInt(v, min, max int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// curveLUT builds a 256-entry lookup table from the control points of a
|
// curveLUT builds a 256-entry lookup table from the control points of a
|
||||||
// curve. The first and last points extend to the edges of the range, and the
|
// curve. The first and last points extend to the edges of the range as flat
|
||||||
// values in between are interpolated linearly.
|
// shelves, and the values in between are interpolated with a monotone cubic
|
||||||
|
// Hermite spline (PCHIP, Fritsch–Carlson).
|
||||||
func curveLUT(points []CurvePoint) [256]uint8 {
|
func curveLUT(points []CurvePoint) [256]uint8 {
|
||||||
var lut [256]uint8
|
var lut [256]uint8
|
||||||
pts := normalizeCurve(points)
|
pts := normalizeCurve(points)
|
||||||
@@ -180,16 +329,71 @@ func curveLUT(points []CurvePoint) [256]uint8 {
|
|||||||
}
|
}
|
||||||
return lut
|
return lut
|
||||||
}
|
}
|
||||||
|
tangents := curveTangents(pts)
|
||||||
for i := 0; i < 256; i++ {
|
for i := 0; i < 256; i++ {
|
||||||
x := float64(i) / 255
|
x := float64(i) / 255
|
||||||
lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255))
|
lut[i] = uint8(math.Round(clamp01(curveEvalWithTangents(pts, tangents, x)) * 255))
|
||||||
}
|
}
|
||||||
return lut
|
return lut
|
||||||
}
|
}
|
||||||
|
|
||||||
// curveEval evaluates the curve at x using linear interpolation between the
|
// curveTangents returns the PCHIP slopes at the control points. Interior
|
||||||
// surrounding control points.
|
// tangents are weighted harmonic means of the neighbouring slopes; endpoint
|
||||||
|
// tangents use the one-sided three-point formula with the Fritsch–Carlson
|
||||||
|
// shape-preserving clamp.
|
||||||
|
func curveTangents(points []CurvePoint) []float64 {
|
||||||
|
n := len(points)
|
||||||
|
if n < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h := make([]float64, n-1)
|
||||||
|
delta := make([]float64, n-1)
|
||||||
|
for i := 0; i < n-1; i++ {
|
||||||
|
h[i] = points[i+1].X - points[i].X
|
||||||
|
delta[i] = (points[i+1].Y - points[i].Y) / h[i]
|
||||||
|
}
|
||||||
|
m := make([]float64, n)
|
||||||
|
if n == 2 {
|
||||||
|
m[0] = delta[0]
|
||||||
|
m[1] = delta[0]
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1])
|
||||||
|
m[n-1] = endpointTangent(h[n-2], h[n-3], delta[n-2], delta[n-3])
|
||||||
|
for i := 1; i < n-1; i++ {
|
||||||
|
if delta[i-1]*delta[i] <= 0 {
|
||||||
|
m[i] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w1 := 2*h[i] + h[i-1]
|
||||||
|
w2 := h[i] + 2*h[i-1]
|
||||||
|
m[i] = (w1 + w2) / (w1/delta[i-1] + w2/delta[i])
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointTangent is the one-sided endpoint slope of the PCHIP spline.
|
||||||
|
func endpointTangent(h0, h1, d0, d1 float64) float64 {
|
||||||
|
m := ((2*h0+h1)*d0 - h0*d1) / (h0 + h1)
|
||||||
|
if m*d0 <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if d0*d1 < 0 && math.Abs(m) > math.Abs(3*d0) {
|
||||||
|
return 3 * d0
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// curveEval evaluates the curve at x with a monotone cubic Hermite spline.
|
||||||
|
// Outside the first and last points the value is constant.
|
||||||
func curveEval(points []CurvePoint, x float64) float64 {
|
func curveEval(points []CurvePoint, x float64) float64 {
|
||||||
|
if len(points) < 2 {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return curveEvalWithTangents(points, curveTangents(points), x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func curveEvalWithTangents(points []CurvePoint, tangents []float64, x float64) float64 {
|
||||||
if x <= points[0].X {
|
if x <= points[0].X {
|
||||||
return points[0].Y
|
return points[0].Y
|
||||||
}
|
}
|
||||||
@@ -197,19 +401,23 @@ func curveEval(points []CurvePoint, x float64) float64 {
|
|||||||
if x >= last.X {
|
if x >= last.X {
|
||||||
return last.Y
|
return last.Y
|
||||||
}
|
}
|
||||||
for i := 1; i < len(points); i++ {
|
i := 1
|
||||||
if x > points[i].X {
|
for i < len(points)-1 && x > points[i].X {
|
||||||
continue
|
i++
|
||||||
}
|
|
||||||
x0, y0 := points[i-1].X, points[i-1].Y
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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
|
// lightAdjuster holds the precomputed factors of the light adjustments. The
|
||||||
@@ -267,3 +475,229 @@ func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
|
|||||||
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
|
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
|
||||||
return newLightAdjuster(p).apply(v, master, channel)
|
return newLightAdjuster(p).apply(v, master, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rgbToHSL converts RGB (each 0..255) to HSL: hue in degrees, saturation and
|
||||||
|
// lightness in 0..1. It uses only arithmetic, so it is easy to mirror in the
|
||||||
|
// client and in the WASM module.
|
||||||
|
func rgbToHSL(r, g, b float64) (h, s, l float64) {
|
||||||
|
r /= 255
|
||||||
|
g /= 255
|
||||||
|
b /= 255
|
||||||
|
max := math.Max(r, math.Max(g, b))
|
||||||
|
min := math.Min(r, math.Min(g, b))
|
||||||
|
l = (max + min) / 2
|
||||||
|
if max == min {
|
||||||
|
return 0, 0, l
|
||||||
|
}
|
||||||
|
d := max - min
|
||||||
|
if l > 0.5 {
|
||||||
|
s = d / (2 - max - min)
|
||||||
|
} else {
|
||||||
|
s = d / (max + min)
|
||||||
|
}
|
||||||
|
switch max {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d
|
||||||
|
if g < b {
|
||||||
|
h += 6
|
||||||
|
}
|
||||||
|
case g:
|
||||||
|
h = (b-r)/d + 2
|
||||||
|
case b:
|
||||||
|
h = (r-g)/d + 4
|
||||||
|
}
|
||||||
|
h *= 60
|
||||||
|
return h, s, l
|
||||||
|
}
|
||||||
|
|
||||||
|
// hslToRGB converts HSL (hue in degrees, saturation and lightness in 0..1)
|
||||||
|
// back to RGB in 0..255.
|
||||||
|
func hslToRGB(h, s, l float64) (r, g, b float64) {
|
||||||
|
if s == 0 {
|
||||||
|
v := l * 255
|
||||||
|
return v, v, v
|
||||||
|
}
|
||||||
|
var q float64
|
||||||
|
if l < 0.5 {
|
||||||
|
q = l * (1 + s)
|
||||||
|
} else {
|
||||||
|
q = l + s - l*s
|
||||||
|
}
|
||||||
|
p := 2*l - q
|
||||||
|
hk := h / 360
|
||||||
|
r = hueToRGB(p, q, hk+1.0/3)
|
||||||
|
g = hueToRGB(p, q, hk)
|
||||||
|
b = hueToRGB(p, q, hk-1.0/3)
|
||||||
|
return r * 255, g * 255, b * 255
|
||||||
|
}
|
||||||
|
|
||||||
|
// hueToRGB is the per-channel helper of hslToRGB.
|
||||||
|
func hueToRGB(p, q, t float64) float64 {
|
||||||
|
if t < 0 {
|
||||||
|
t++
|
||||||
|
}
|
||||||
|
if t > 1 {
|
||||||
|
t--
|
||||||
|
}
|
||||||
|
if t < 1.0/6 {
|
||||||
|
return p + (q-p)*6*t
|
||||||
|
}
|
||||||
|
if t < 1.0/2 {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
if t < 2.0/3 {
|
||||||
|
return p + (q-p)*(2.0/3-t)*6
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandWeight returns a triangular weight of a hue around a band center.
|
||||||
|
func bandWeight(h, center float64) float64 {
|
||||||
|
d := math.Abs(h - center)
|
||||||
|
if d > 180 {
|
||||||
|
d = 360 - d
|
||||||
|
}
|
||||||
|
return clamp01(1 - d/60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorAdjuster holds the precomputed factors of the color adjustments. The
|
||||||
|
// order of the operations is fixed and mirrored by the client-side preview and
|
||||||
|
// the WASM module, so the live preview and the rendered result match.
|
||||||
|
type colorAdjuster struct {
|
||||||
|
temperature float64
|
||||||
|
tint float64
|
||||||
|
vibrance float64
|
||||||
|
saturation float64
|
||||||
|
mixer [8]mixerBand
|
||||||
|
hasMixer bool
|
||||||
|
wheels [3]ColorWheel
|
||||||
|
luminances [3]float64
|
||||||
|
blending float64
|
||||||
|
balance float64
|
||||||
|
correction [3]float64
|
||||||
|
tints [3][3]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newColorAdjuster(p ColorParams) colorAdjuster {
|
||||||
|
a := colorAdjuster{
|
||||||
|
temperature: p.Temperature / 100,
|
||||||
|
tint: p.Tint / 100,
|
||||||
|
vibrance: p.Vibrance / 100,
|
||||||
|
saturation: p.Saturation / 100,
|
||||||
|
mixer: p.Mixer.bands(),
|
||||||
|
wheels: p.Grading.wheels(),
|
||||||
|
blending: p.Grading.Blending / 100,
|
||||||
|
balance: p.Grading.Balance / 100,
|
||||||
|
correction: [3]float64{p.Correction.Red / 100, p.Correction.Green / 100, p.Correction.Blue / 100},
|
||||||
|
}
|
||||||
|
for _, band := range a.mixer {
|
||||||
|
if band.hue != 0 || band.saturation != 0 || band.luminance != 0 {
|
||||||
|
a.hasMixer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, wheel := range a.wheels {
|
||||||
|
a.luminances[i] = wheel.Luminance / 100
|
||||||
|
r, g, b := hslToRGB(wheel.Hue, 1, 0.5)
|
||||||
|
a.tints[i] = [3]float64{r, g, b}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply color-corrects one RGB pixel (each 0..255) and returns the result.
|
||||||
|
func (a colorAdjuster) apply(r, g, b float64) (float64, float64, float64) {
|
||||||
|
// Баланс белого: тёплый/холодный и зелёный/пурпурный сдвиг.
|
||||||
|
r *= 1 + 0.20*a.temperature + 0.10*a.tint
|
||||||
|
g *= 1 - 0.20*a.tint
|
||||||
|
b *= 1 - 0.20*a.temperature + 0.10*a.tint
|
||||||
|
r = clampFloat(r, 0, 255)
|
||||||
|
g = clampFloat(g, 0, 255)
|
||||||
|
b = clampFloat(b, 0, 255)
|
||||||
|
|
||||||
|
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
|
||||||
|
// HSL-переход не нужен, lightness считается напрямую.
|
||||||
|
fast := !a.hasMixer && a.saturation == 0 && a.vibrance == 0
|
||||||
|
var weights [3]float64
|
||||||
|
var rr, gg, bb float64
|
||||||
|
|
||||||
|
if fast {
|
||||||
|
max := math.Max(r, math.Max(g, b))
|
||||||
|
min := math.Min(r, math.Min(g, b))
|
||||||
|
l := (max + min) / 510
|
||||||
|
lb := clamp01(l + a.balance*0.5)
|
||||||
|
shadowW := (1 - lb) * (1 - lb)
|
||||||
|
shadowW *= shadowW
|
||||||
|
highlightW := lb * lb
|
||||||
|
highlightW *= highlightW
|
||||||
|
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
|
||||||
|
targetL := l
|
||||||
|
for i := range a.wheels {
|
||||||
|
targetL += a.luminances[i] * 0.2 * weights[i]
|
||||||
|
}
|
||||||
|
targetL = clamp01(targetL)
|
||||||
|
delta := (targetL - l) * 255
|
||||||
|
rr = r + delta
|
||||||
|
gg = g + delta
|
||||||
|
bb = b + delta
|
||||||
|
} else {
|
||||||
|
// RGB -> HSL.
|
||||||
|
h, s, l := rgbToHSL(r, g, b)
|
||||||
|
|
||||||
|
// Насыщенность и вибрация: вибрация сильнее влияет на приглушённые цвета.
|
||||||
|
if a.saturation != 0 || a.vibrance != 0 {
|
||||||
|
s = clamp01(s * (1 + a.saturation + a.vibrance*(1-s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветовой микшер: сдвиг оттенка, насыщенности и яркости по зонам.
|
||||||
|
if a.hasMixer {
|
||||||
|
for _, band := range a.mixer {
|
||||||
|
w := bandWeight(h, band.center)
|
||||||
|
if w == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h += (band.hue / 100) * 30 * w
|
||||||
|
s = clamp01(s * (1 + (band.saturation/100)*w))
|
||||||
|
l = clamp01(l + (band.luminance/100)*0.2*w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h = math.Mod(h, 360)
|
||||||
|
if h < 0 {
|
||||||
|
h += 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зоны яркости: веса в сумме дают 1, баланс сдвигает границы.
|
||||||
|
lb := clamp01(l + a.balance*0.5)
|
||||||
|
// Более узкие зоны: колесо теней почти не задевает средние тона.
|
||||||
|
shadowW := (1 - lb) * (1 - lb)
|
||||||
|
shadowW *= shadowW
|
||||||
|
highlightW := lb * lb
|
||||||
|
highlightW *= highlightW
|
||||||
|
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
|
||||||
|
for i := range a.wheels {
|
||||||
|
l = clamp01(l + a.luminances[i]*0.2*weights[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSL -> RGB.
|
||||||
|
rr, gg, bb = hslToRGB(h, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точечный цвет: три колеса по зонам яркости.
|
||||||
|
blend := a.blending
|
||||||
|
for i, wheel := range a.wheels {
|
||||||
|
strength := (wheel.Saturation / 100) * 0.5 * blend * weights[i]
|
||||||
|
if strength == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tint := a.tints[i]
|
||||||
|
rr = rr*(1-strength) + tint[0]*strength
|
||||||
|
gg = gg*(1-strength) + tint[1]*strength
|
||||||
|
bb = bb*(1-strength) + tint[2]*strength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветокоррекция: поканальный сдвиг.
|
||||||
|
rr *= 1 + a.correction[0]*0.5
|
||||||
|
gg *= 1 + a.correction[1]*0.5
|
||||||
|
bb *= 1 + a.correction[2]*0.5
|
||||||
|
|
||||||
|
return clampFloat(rr, 0, 255), clampFloat(gg, 0, 255), clampFloat(bb, 0, 255)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ const (
|
|||||||
RoleEditPreview = "edit_preview"
|
RoleEditPreview = "edit_preview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EditRoles lists the roles of the edited version. The edited version belongs
|
||||||
|
// to the photo whose edits produced it, so copies of that photo never share
|
||||||
|
// these roles.
|
||||||
|
func EditRoles() []string {
|
||||||
|
return []string{RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEditRole reports whether the role belongs to the edited version.
|
||||||
|
func IsEditRole(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// applyLight renders the light adjustments into a copy of the image. The
|
// applyLight renders the light adjustments into a copy of the image. The
|
||||||
// original pixels are never modified in place.
|
// original pixels are never modified in place.
|
||||||
func applyLight(src image.Image, p LightParams) *image.NRGBA {
|
func applyLight(src image.Image, p LightParams) *image.NRGBA {
|
||||||
@@ -74,14 +90,68 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeAndAdjust decodes the original and applies the light adjustments to
|
// decodeAndAdjust decodes the original and applies the light and color
|
||||||
// it, so the variants and the full-size version share one decode.
|
// adjustments to it, so the variants and the full-size version share one
|
||||||
|
// decode. Color is applied after light.
|
||||||
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
|
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
|
||||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return applyLight(img, p.Light), nil
|
edited := applyLight(img, p.Light)
|
||||||
|
if p.Color.IsEmpty() {
|
||||||
|
return edited, nil
|
||||||
|
}
|
||||||
|
return applyColor(edited, p.Color), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyColor applies the color adjustments in place to an image that already
|
||||||
|
// has the light adjustments applied. The image is owned by the caller.
|
||||||
|
func applyColor(dst *image.NRGBA, p ColorParams) *image.NRGBA {
|
||||||
|
adj := newColorAdjuster(p)
|
||||||
|
|
||||||
|
rows := dst.Rect.Dy()
|
||||||
|
workers := runtime.GOMAXPROCS(0)
|
||||||
|
if workers > rows {
|
||||||
|
workers = rows
|
||||||
|
}
|
||||||
|
if workers <= 1 {
|
||||||
|
applyColorRows(dst, 0, rows, adj)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
// Рендер больших фото идёт по строкам параллельно.
|
||||||
|
chunk := (rows + workers - 1) / workers
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for from := 0; from < rows; from += chunk {
|
||||||
|
to := from + chunk
|
||||||
|
if to > rows {
|
||||||
|
to = rows
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(from, to int) {
|
||||||
|
defer wg.Done()
|
||||||
|
applyColorRows(dst, from, to, adj)
|
||||||
|
}(from, to)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyColorRows applies the color adjustments to the given range of rows.
|
||||||
|
func applyColorRows(dst *image.NRGBA, from, to int, adj colorAdjuster) {
|
||||||
|
width := dst.Rect.Dx()
|
||||||
|
for y := from; y < to; y++ {
|
||||||
|
row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4]
|
||||||
|
for i := 0; i+3 < len(row); i += 4 {
|
||||||
|
if row[i+3] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, g, b := adj.apply(float64(row[i]), float64(row[i+1]), float64(row[i+2]))
|
||||||
|
row[i] = uint8(clampFloat(r+0.5, 0, 255))
|
||||||
|
row[i+1] = uint8(clampFloat(g+0.5, 0, 255))
|
||||||
|
row[i+2] = uint8(clampFloat(b+0.5, 0, 255))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeEditedVariants renders the grid variants of an edited photo. They
|
// encodeEditedVariants renders the grid variants of an edited photo. They
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ func (s *Service) ResetEdit(ctx context.Context, photoID, userID string) (*model
|
|||||||
if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
|
if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteObjectBindings(ctx, photoID,
|
if err := s.store.DeleteObjectBindings(ctx, photoID, EditRoles()...); err != nil {
|
||||||
RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.publishEdit(ctx, photoID, userID)
|
return s.publishEdit(ctx, photoID, userID)
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ func TestParseEditParamsClampsValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditRolesCoverTheEditedVersion(t *testing.T) {
|
||||||
|
roles := EditRoles()
|
||||||
|
if len(roles) != 4 {
|
||||||
|
t.Fatalf("edit roles = %v, want four", roles)
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
if !IsEditRole(role) {
|
||||||
|
t.Fatalf("role %q must be an edit role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} {
|
||||||
|
if IsEditRole(role) {
|
||||||
|
t.Fatalf("role %q must not be an edit role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEditParamsIsEmpty(t *testing.T) {
|
func TestEditParamsIsEmpty(t *testing.T) {
|
||||||
var zero EditParams
|
var zero EditParams
|
||||||
if !zero.IsEmpty() {
|
if !zero.IsEmpty() {
|
||||||
@@ -74,6 +91,70 @@ func TestCurveLUTSteepens(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The client builds the same 256-entry LUT (frontend/src/lib/edit.ts). The
|
||||||
|
// hard-coded samples keep the Go and TypeScript implementations in sync.
|
||||||
|
func TestCurveLUTMatchesClient(t *testing.T) {
|
||||||
|
samples := []int{0, 32, 64, 128, 192, 224, 255}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
points []CurvePoint
|
||||||
|
want []int
|
||||||
|
}{
|
||||||
|
{"linear", []CurvePoint{{0, 0}, {1, 1}}, []int{0, 32, 64, 128, 192, 224, 255}},
|
||||||
|
{
|
||||||
|
"medium",
|
||||||
|
[]CurvePoint{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
|
||||||
|
[]int{0, 23, 51, 128, 205, 233, 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"strong",
|
||||||
|
[]CurvePoint{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
|
||||||
|
[]int{0, 17, 43, 128, 213, 239, 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inner",
|
||||||
|
[]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}},
|
||||||
|
[]int{13, 17, 42, 104, 186, 234, 242},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
lut := curveLUT(tc.points)
|
||||||
|
for i, index := range samples {
|
||||||
|
if int(lut[index]) != tc.want[i] {
|
||||||
|
t.Fatalf("%s lut[%d] = %d, want %d", tc.name, index, lut[index], tc.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurveLUTIsMonotone(t *testing.T) {
|
||||||
|
for _, points := range [][]CurvePoint{
|
||||||
|
{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
|
||||||
|
{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
|
||||||
|
} {
|
||||||
|
lut := curveLUT(points)
|
||||||
|
for i := 1; i < len(lut); i++ {
|
||||||
|
if lut[i] < lut[i-1] {
|
||||||
|
t.Fatalf("lut[%d] = %d < lut[%d] = %d", i, lut[i], i-1, lut[i-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurveLUTFlatOutsideEndpoints(t *testing.T) {
|
||||||
|
lut := curveLUT([]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}})
|
||||||
|
for i := 0; i <= int(math.Floor(0.1*255)); i++ {
|
||||||
|
if lut[i] != 13 {
|
||||||
|
t.Fatalf("lut[%d] = %d, want flat 13", i, lut[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 230; i < len(lut); i++ {
|
||||||
|
if lut[i] != 242 {
|
||||||
|
t.Fatalf("lut[%d] = %d, want flat 242", i, lut[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyLightBrightensAndKeepsSource(t *testing.T) {
|
func TestApplyLightBrightensAndKeepsSource(t *testing.T) {
|
||||||
src := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
src := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||||
for i := range src.Pix {
|
for i := range src.Pix {
|
||||||
@@ -165,3 +246,195 @@ func mustEncodePNG(t *testing.T, img image.Image) []byte {
|
|||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseEditParamsClampsColor(t *testing.T) {
|
||||||
|
raw := []byte(`{"color":{"temperature":200,"tint":-300,"vibrance":50,"saturation":-500,"mixer":{"red":{"hue":150,"saturation":-200,"luminance":10}},"grading":{"shadows":{"hue":400,"saturation":150},"balance":-200},"correction":{"red":120}}}`)
|
||||||
|
params, err := ParseEditParams(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
c := params.Color
|
||||||
|
if c.Temperature != MaxPercent || c.Tint != -MaxPercent {
|
||||||
|
t.Fatalf("wb = %v/%v, want clamped", c.Temperature, c.Tint)
|
||||||
|
}
|
||||||
|
if c.Saturation != -MaxPercent {
|
||||||
|
t.Fatalf("saturation = %v, want %v", c.Saturation, -MaxPercent)
|
||||||
|
}
|
||||||
|
if c.Mixer.Red.Hue != MaxPercent || c.Mixer.Red.Saturation != -MaxPercent {
|
||||||
|
t.Fatalf("mixer red = %+v, want clamped", c.Mixer.Red)
|
||||||
|
}
|
||||||
|
if c.Grading.Shadows.Hue != 360 || c.Grading.Shadows.Saturation != MaxPercent {
|
||||||
|
t.Fatalf("grading shadows = %+v, want clamped", c.Grading.Shadows)
|
||||||
|
}
|
||||||
|
if c.Grading.Balance != -MaxPercent || c.Correction.Red != MaxPercent {
|
||||||
|
t.Fatalf("grading/correction not clamped: %+v %+v", c.Grading, c.Correction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColorParamsIsEmpty(t *testing.T) {
|
||||||
|
var zero ColorParams
|
||||||
|
if !zero.IsEmpty() {
|
||||||
|
t.Fatal("zero color params must be empty")
|
||||||
|
}
|
||||||
|
vibrance := ColorParams{Vibrance: 1}
|
||||||
|
if vibrance.IsEmpty() {
|
||||||
|
t.Fatal("vibrance must not be empty")
|
||||||
|
}
|
||||||
|
mixer := ColorParams{}
|
||||||
|
mixer.Mixer.Blue.Luminance = -10
|
||||||
|
if mixer.IsEmpty() {
|
||||||
|
t.Fatal("mixer band must not be empty")
|
||||||
|
}
|
||||||
|
// Оттенок колеса без силы не влияет на картинку.
|
||||||
|
hueOnly := ColorParams{}
|
||||||
|
hueOnly.Grading.Shadows.Hue = 120
|
||||||
|
if !hueOnly.IsEmpty() {
|
||||||
|
t.Fatal("grading hue without strength must count as empty")
|
||||||
|
}
|
||||||
|
grade := ColorParams{}
|
||||||
|
grade.Grading.Highlights.Saturation = 10
|
||||||
|
if grade.IsEmpty() {
|
||||||
|
t.Fatal("grading strength must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorWhiteBalance(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 100, 100, 100, 255
|
||||||
|
out := applyColor(src, ColorParams{Temperature: 100})
|
||||||
|
if !(out.Pix[0] > out.Pix[1] && out.Pix[1] > out.Pix[2]) {
|
||||||
|
t.Fatalf("warm white balance = %d/%d/%d, want r > g > b", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorSaturationMinus100IsGray(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 200, 50, 50, 255
|
||||||
|
out := applyColor(src, ColorParams{Saturation: -100})
|
||||||
|
if out.Pix[0] != out.Pix[1] || out.Pix[1] != out.Pix[2] {
|
||||||
|
t.Fatalf("desaturated pixel = %d/%d/%d, want gray", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorSaturationPlus100(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 150, 120, 120, 255
|
||||||
|
before := src.Pix[0] - src.Pix[1]
|
||||||
|
out := applyColor(src, ColorParams{Saturation: 100})
|
||||||
|
if out.Pix[0]-out.Pix[1] <= before {
|
||||||
|
t.Fatalf("saturation did not spread channels: %d/%d/%d", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorGradingTintsShadows(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||||
|
p := ColorParams{}
|
||||||
|
p.Grading.Blending = 50
|
||||||
|
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||||
|
out := applyColor(src, p)
|
||||||
|
if out.Pix[2] <= out.Pix[0] {
|
||||||
|
t.Fatalf("blue shadow grading = %d/%d/%d, want b > r", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorGradingLuminance(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||||
|
p := ColorParams{}
|
||||||
|
p.Grading.Shadows = ColorWheel{Luminance: 100}
|
||||||
|
out := applyColor(src, p)
|
||||||
|
if out.Pix[0] <= 40 {
|
||||||
|
t.Fatalf("shadow luminance did not brighten: %d", out.Pix[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorUniformRows(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 64, 64))
|
||||||
|
for i := 0; i < len(src.Pix); i += 4 {
|
||||||
|
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 40, 40, 40, 255
|
||||||
|
}
|
||||||
|
p := ColorParams{}
|
||||||
|
p.Grading.Blending = 50
|
||||||
|
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||||
|
out := applyColor(src, p)
|
||||||
|
wantR, wantG, wantB := out.Pix[0], out.Pix[1], out.Pix[2]
|
||||||
|
for y := 0; y < 64; y++ {
|
||||||
|
for x := 0; x < 64; x++ {
|
||||||
|
i := y*out.Stride + x*4
|
||||||
|
if out.Pix[i] != wantR || out.Pix[i+1] != wantG || out.Pix[i+2] != wantB {
|
||||||
|
t.Fatalf("pixel (%d,%d) = %d/%d/%d, want %d/%d/%d", x, y, out.Pix[i], out.Pix[i+1], out.Pix[i+2], wantR, wantG, wantB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorParallelMatchesSingle(t *testing.T) {
|
||||||
|
makeImage := func() *image.NRGBA {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 32, 64))
|
||||||
|
for y := 0; y < 64; y++ {
|
||||||
|
for x := 0; x < 32; x++ {
|
||||||
|
i := y*img.Stride + x*4
|
||||||
|
img.Pix[i] = uint8(20 + y*3)
|
||||||
|
img.Pix[i+1] = uint8(20 + y*2)
|
||||||
|
img.Pix[i+2] = uint8(30 + y)
|
||||||
|
img.Pix[i+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
p := ColorParams{}
|
||||||
|
p.Grading.Blending = 50
|
||||||
|
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||||
|
|
||||||
|
parallel := applyColor(makeImage(), p)
|
||||||
|
single := makeImage()
|
||||||
|
applyColorRows(single, 0, single.Rect.Dy(), newColorAdjuster(p))
|
||||||
|
|
||||||
|
for i := range parallel.Pix {
|
||||||
|
if parallel.Pix[i] != single.Pix[i] {
|
||||||
|
t.Fatalf("parallel differs at byte %d: %d != %d", i, parallel.Pix[i], single.Pix[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorKeepsTransparentPixels(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 10, 20, 30, 0
|
||||||
|
out := applyColor(src, ColorParams{Saturation: 100, Temperature: 100})
|
||||||
|
if out.Pix[0] != 10 || out.Pix[1] != 20 || out.Pix[2] != 30 || out.Pix[3] != 0 {
|
||||||
|
t.Fatalf("transparent pixel changed: %v", out.Pix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEditParamsWithoutColorIsBackwardCompatible(t *testing.T) {
|
||||||
|
params, err := ParseEditParams([]byte(`{"light":{"exposure":0.5}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !params.Color.IsEmpty() {
|
||||||
|
t.Fatalf("missing color must be empty: %+v", params.Color)
|
||||||
|
}
|
||||||
|
if params.IsEmpty() {
|
||||||
|
t.Fatal("light edits must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyColorEmptyIsIdentity(t *testing.T) {
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 3, 1))
|
||||||
|
colors := [][3]uint8{{0, 0, 0}, {200, 50, 25}, {33, 210, 128}}
|
||||||
|
for i, c := range colors {
|
||||||
|
src.Pix[i*4] = c[0]
|
||||||
|
src.Pix[i*4+1] = c[1]
|
||||||
|
src.Pix[i*4+2] = c[2]
|
||||||
|
src.Pix[i*4+3] = 255
|
||||||
|
}
|
||||||
|
before := make([]byte, len(src.Pix))
|
||||||
|
copy(before, src.Pix)
|
||||||
|
applyColor(src, ColorParams{})
|
||||||
|
for i := range before {
|
||||||
|
if before[i] != src.Pix[i] {
|
||||||
|
t.Fatalf("empty color changed pixel %d: %d != %d", i, src.Pix[i], before[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,12 +124,13 @@ func (h *Handler) Download(c *gin.Context) {
|
|||||||
func (h *Handler) Edit(c *gin.Context) {
|
func (h *Handler) Edit(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Light LightParams `json:"light"`
|
Light LightParams `json:"light"`
|
||||||
|
Color ColorParams `json:"color"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "light params are required"))
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light})
|
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.RespondError(c, err)
|
httpx.RespondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -250,10 +250,18 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
|
|||||||
mediaKind(src.Kind), src.DurationMS); err != nil {
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it, so the
|
||||||
|
// copy shares the base variants only.
|
||||||
|
roles := EditRoles()
|
||||||
|
args := []any{id, src.ID}
|
||||||
|
for _, role := range roles {
|
||||||
|
args = append(args, role)
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO photo_objects (photo_id, object_id, role)
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
id, src.ID); err != nil {
|
WHERE photo_id = $2 AND role NOT IN (`+sqliteutil.Placeholders(3, len(roles))+`)`,
|
||||||
|
args...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|||||||
@@ -290,10 +290,13 @@ func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderI
|
|||||||
mediaKind(src.Kind), src.DurationMS); err != nil {
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it, so the
|
||||||
|
// copy shares the base variants only.
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`INSERT INTO photo_objects (photo_id, object_id, role)
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
id, src.ID); err != nil {
|
WHERE photo_id = $2 AND role <> ALL($3::text[])`,
|
||||||
|
id, src.ID, EditRoles()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
|||||||
{"Social", testSocial},
|
{"Social", testSocial},
|
||||||
{"Photos", testPhotos},
|
{"Photos", testPhotos},
|
||||||
{"Objects", testObjects},
|
{"Objects", testObjects},
|
||||||
|
{"Clone", testClone},
|
||||||
{"UserAvatar", testUserAvatar},
|
{"UserAvatar", testUserAvatar},
|
||||||
{"VariantJobKinds", testVariantJobKinds},
|
{"VariantJobKinds", testVariantJobKinds},
|
||||||
{"Settings", testSettings},
|
{"Settings", testSettings},
|
||||||
@@ -709,6 +710,56 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testClone(t *testing.T, ctx context.Context, r Repos) {
|
||||||
|
owner := seedUser(t, ctx, r, "Owner")
|
||||||
|
a := seedAlbum(t, ctx, r, owner.ID)
|
||||||
|
target := seedAlbum(t, ctx, r, owner.ID)
|
||||||
|
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
|
|
||||||
|
// The photo carries the base variants and an edited version.
|
||||||
|
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||||
|
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
|
||||||
|
t.Fatalf("create thumb: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
|
||||||
|
t.Fatalf("activate thumb: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
|
||||||
|
t.Fatalf("bind thumb: %v", err)
|
||||||
|
}
|
||||||
|
edited := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||||
|
if err := r.Photos.CreateObject(ctx, edited); err != nil {
|
||||||
|
t.Fatalf("create edited object: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, edited.ID); err != nil {
|
||||||
|
t.Fatalf("activate edited object: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.BindObject(ctx, p.ID, edited.ID, photos.RoleEdit); err != nil {
|
||||||
|
t.Fatalf("bind edited version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneID := newID()
|
||||||
|
if err := r.Photos.Clone(ctx, p, target.ID, owner.ID, cloneID); err != nil {
|
||||||
|
t.Fatalf("clone: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneObjects, err := r.Photos.PhotoObjectIDs(ctx, cloneID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone objects: %v", err)
|
||||||
|
}
|
||||||
|
// The copy shares the base variants.
|
||||||
|
if !contains(cloneObjects, thumb.ID) {
|
||||||
|
t.Fatalf("clone objects = %+v, want thumb %s", cloneObjects, thumb.ID)
|
||||||
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it.
|
||||||
|
if contains(cloneObjects, edited.ID) {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", cloneObjects)
|
||||||
|
}
|
||||||
|
if refs, _ := r.Photos.ObjectRefCount(ctx, edited.ID); refs != 1 {
|
||||||
|
t.Fatalf("edited object refs = %d, want 1", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
||||||
owner := seedUser(t, ctx, r, "Owner")
|
owner := seedUser(t, ctx, r, "Owner")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The removed bindings cannot be restored: which object belonged to which copy
|
||||||
|
-- is not recorded anywhere. Rollback of the code is enough - the edited
|
||||||
|
-- versions of the photos that own them stay untouched.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The edited version belongs to the photo whose edits produced it. Copies made
|
||||||
|
-- before the fix still carried the edit bindings, so a photo without edit
|
||||||
|
-- parameters showed the edited picture. Drop those bindings: the objects
|
||||||
|
-- become collectable garbage and the copies show the original picture again.
|
||||||
|
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (
|
||||||
|
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'::jsonb
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The removed bindings cannot be restored: which object belonged to which copy
|
||||||
|
-- is not recorded anywhere. Rollback of the code is enough - the edited
|
||||||
|
-- versions of the photos that own them stay untouched.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The edited version belongs to the photo whose edits produced it. Copies made
|
||||||
|
-- before the fix still carried the edit bindings, so a photo without edit
|
||||||
|
-- parameters showed the edited picture. Drop those bindings: the objects
|
||||||
|
-- become collectable garbage and the copies show the original picture again.
|
||||||
|
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (
|
||||||
|
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'
|
||||||
|
);
|
||||||
@@ -22,10 +22,16 @@
|
|||||||
let picking = $state(false);
|
let picking = $state(false);
|
||||||
|
|
||||||
let photo = $state<Photo | null>(null);
|
let photo = $state<Photo | null>(null);
|
||||||
|
// Вкладка правой панели живёт здесь и переживает смену фото.
|
||||||
|
let photoTab = $state<'info' | 'edit'>('info');
|
||||||
|
|
||||||
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
|
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
|
||||||
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
||||||
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
// Не показываем в панели фото от предыдущего маршрута, пока не загрузилось
|
||||||
|
// текущее: иначе правки могут уйти не тому фото.
|
||||||
|
const panelPhoto = $derived(
|
||||||
|
photoId ? (photo?.id === photoId ? photo : null) : selectedPhoto
|
||||||
|
);
|
||||||
|
|
||||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
|
||||||
@@ -51,7 +57,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRenamed(updated: Photo) {
|
function onRenamed(updated: Photo) {
|
||||||
photo = updated;
|
// Фоновое сохранение предыдущего фото может завершиться уже после
|
||||||
|
// переключения: его ответ не должен подменять текущее фото в панели.
|
||||||
|
if (updated.id === photoId) photo = updated;
|
||||||
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
||||||
photoUpdates.publish(updated);
|
photoUpdates.publish(updated);
|
||||||
}
|
}
|
||||||
@@ -90,9 +98,17 @@
|
|||||||
photo = null;
|
photo = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
api<{ photo: Photo }>(`/photos/${id}`)
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
.then((data) => (photo = data.photo))
|
.then((data) => {
|
||||||
.catch((e) => (error = (e as Error).message));
|
if (!cancelled && data.photo.id === id) photo = data.photo;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) error = (e as Error).message;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function resetCover() {
|
async function resetCover() {
|
||||||
@@ -146,6 +162,7 @@
|
|||||||
|
|
||||||
{#if panelPhoto}
|
{#if panelPhoto}
|
||||||
<PhotoPanel
|
<PhotoPanel
|
||||||
|
bind:tab={photoTab}
|
||||||
photo={panelPhoto}
|
photo={panelPhoto}
|
||||||
{role}
|
{role}
|
||||||
viewer={!!photoId}
|
viewer={!!photoId}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange?: (hue: number, saturation: number) => void;
|
||||||
|
oncommit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hue, saturation, label, disabled = false, onchange, oncommit }: Props = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let el: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
function pick(ev: PointerEvent) {
|
||||||
|
if (!el || disabled) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = (ev.clientX - rect.left) / rect.width - 0.5;
|
||||||
|
const y = (ev.clientY - rect.top) / rect.height - 0.5;
|
||||||
|
const radius = Math.sqrt(x * x + y * y);
|
||||||
|
const angle = Math.atan2(x, -y);
|
||||||
|
const nextHue = (((angle * 180) / Math.PI) + 360) % 360;
|
||||||
|
const nextSaturation = Math.min(1, radius * 2) * 100;
|
||||||
|
onchange?.(nextHue, nextSaturation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(ev: PointerEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
pick(ev);
|
||||||
|
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(ev: PointerEvent) {
|
||||||
|
if (dragging) pick(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
oncommit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Двойной клик сбрасывает колесо в нейтраль. */
|
||||||
|
function onDoubleClick(ev: MouseEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (disabled) return;
|
||||||
|
onchange?.(0, 0);
|
||||||
|
oncommit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const knob = $derived({
|
||||||
|
left:
|
||||||
|
50 +
|
||||||
|
Math.sin((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50,
|
||||||
|
top:
|
||||||
|
50 -
|
||||||
|
Math.cos((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
bind:this={el}
|
||||||
|
class="relative aspect-square w-full max-w-[88px] touch-none rounded-full"
|
||||||
|
style="background: radial-gradient(circle, #d9d9d9 0%, rgba(217,217,217,0) 55%), conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red);"
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={label ?? 'Цвет'}
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="360"
|
||||||
|
aria-valuenow={Math.round(hue)}
|
||||||
|
onpointerdown={onDown}
|
||||||
|
onpointermove={onMove}
|
||||||
|
onpointerup={onUp}
|
||||||
|
onpointerleave={onUp}
|
||||||
|
ondblclick={onDoubleClick}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow"
|
||||||
|
style="left: {knob.left}%; top: {knob.top}%;"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
{#if label}<span class="text-text-muted text-[10px]">{label}</span>{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { CURVE_CHANNELS, type CurveChannel, type CurvePoint } from '$lib/edit';
|
||||||
|
import {
|
||||||
|
BUILTIN_CURVE_PRESETS,
|
||||||
|
MAX_CURVE_PRESET_NAME,
|
||||||
|
exportCurvePresetFile,
|
||||||
|
normalizeCurvePresetName,
|
||||||
|
parseCurvePresetFile
|
||||||
|
} from '$lib/curve-presets';
|
||||||
|
import type { CurvePreset } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
curves: Record<CurveChannel, CurvePoint[]>;
|
||||||
|
presets: CurvePreset[];
|
||||||
|
activeName: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onApply: (preset: CurvePreset) => void;
|
||||||
|
onSave: (preset: CurvePreset) => Promise<void>;
|
||||||
|
onRename: (oldName: string, newName: string) => Promise<void>;
|
||||||
|
onDelete: (name: string) => Promise<void>;
|
||||||
|
onImport: (presets: CurvePreset[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
curves,
|
||||||
|
presets,
|
||||||
|
activeName,
|
||||||
|
disabled = false,
|
||||||
|
onApply,
|
||||||
|
onSave,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onImport
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let dialog = $state<'save' | 'rename' | null>(null);
|
||||||
|
let renameFrom = $state('');
|
||||||
|
let nameDraft = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let trigger = $state<HTMLButtonElement | undefined>();
|
||||||
|
let menuElement = $state<HTMLDivElement | undefined>();
|
||||||
|
let menuStyle = $state('');
|
||||||
|
let fileInput = $state<HTMLInputElement | undefined>();
|
||||||
|
let nameInput = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
const MENU_WIDTH = 288;
|
||||||
|
const MENU_HEIGHT = 320;
|
||||||
|
const MENU_GAP = 6;
|
||||||
|
const VIEWPORT_PAD = 8;
|
||||||
|
|
||||||
|
const isCustomActive = $derived(presets.some((preset) => preset.name === activeName));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (dialog && nameInput) {
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Панель редактирования — скроллящийся контейнер с transform, поэтому меню
|
||||||
|
// рендерится в portal и позиционируется от кнопки в координатах viewport.
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onScroll = (ev: Event) => {
|
||||||
|
if (menuElement && ev.target instanceof Node && menuElement.contains(ev.target)) return;
|
||||||
|
open = false;
|
||||||
|
};
|
||||||
|
const onResize = () => (open = false);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function positionMenu() {
|
||||||
|
if (!trigger || typeof window === 'undefined') return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const width = Math.min(MENU_WIDTH, window.innerWidth - VIEWPORT_PAD * 2);
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(VIEWPORT_PAD, rect.right - width),
|
||||||
|
window.innerWidth - width - VIEWPORT_PAD
|
||||||
|
);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP - VIEWPORT_PAD;
|
||||||
|
const spaceAbove = rect.top - MENU_GAP - VIEWPORT_PAD;
|
||||||
|
const openBelow = spaceBelow >= Math.min(MENU_HEIGHT, 220) || spaceBelow >= spaceAbove;
|
||||||
|
if (openBelow) {
|
||||||
|
menuStyle = `left:${left}px; top:${rect.bottom + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceBelow)}px;`;
|
||||||
|
} else {
|
||||||
|
menuStyle = `left:${left}px; bottom:${window.innerHeight - rect.top + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceAbove)}px;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (!open) positionMenu();
|
||||||
|
open = !open;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(preset: CurvePreset) {
|
||||||
|
onApply(preset);
|
||||||
|
open = false;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSave() {
|
||||||
|
nameDraft = isCustomActive ? activeName : '';
|
||||||
|
open = false;
|
||||||
|
error = '';
|
||||||
|
dialog = 'save';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(preset: CurvePreset) {
|
||||||
|
renameFrom = preset.name;
|
||||||
|
nameDraft = preset.name;
|
||||||
|
open = false;
|
||||||
|
error = '';
|
||||||
|
dialog = 'rename';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
if (busy) return;
|
||||||
|
dialog = null;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
const name = normalizeCurvePresetName(nameDraft);
|
||||||
|
if (!name) {
|
||||||
|
error = `Введите название до ${MAX_CURVE_PRESET_NAME} символов`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialog === 'save') {
|
||||||
|
if (
|
||||||
|
presets.some((preset) => preset.name === name) &&
|
||||||
|
!window.confirm(`Перезаписать пресет «${name}»?`)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const channels = {} as CurvePreset['channels'];
|
||||||
|
for (const ch of CURVE_CHANNELS) {
|
||||||
|
channels[ch] = curves[ch].map((point) => ({ ...point }));
|
||||||
|
}
|
||||||
|
await onSave({ name, channels });
|
||||||
|
dialog = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message || 'Не удалось сохранить пресет';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialog === 'rename') {
|
||||||
|
if (name === renameFrom) {
|
||||||
|
dialog = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (presets.some((preset) => preset.name === name)) {
|
||||||
|
error = 'Пресет с таким названием уже есть';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await onRename(renameFrom, name);
|
||||||
|
dialog = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message || 'Не удалось переименовать пресет';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePreset(preset: CurvePreset) {
|
||||||
|
if (busy) return;
|
||||||
|
if (!window.confirm(`Удалить пресет «${preset.name}»?`)) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await onDelete(preset.name);
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message || 'Не удалось удалить пресет';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFile() {
|
||||||
|
fileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFile(ev: Event) {
|
||||||
|
const input = ev.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file || busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const presetsToImport = parseCurvePresetFile(await file.text());
|
||||||
|
await onImport(presetsToImport);
|
||||||
|
open = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message || 'Не удалось импортировать пресеты';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPresets() {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([exportCurvePresetFile(presets)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'galo-curve-presets.json';
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
open = false;
|
||||||
|
error = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message || 'Не удалось экспортировать пресеты';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(ev) => {
|
||||||
|
if (dialog && ev.key === 'Escape' && !busy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
bind:this={trigger}
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text flex min-w-0 items-center gap-1 text-xs disabled:opacity-40"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggle}
|
||||||
|
>
|
||||||
|
<span class="max-w-[10rem] truncate">{activeName}</span>
|
||||||
|
<Icon name="expand_more" class="size-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<button
|
||||||
|
use:portal
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-[75] cursor-default"
|
||||||
|
aria-label="Закрыть меню пресетов"
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
bind:this={menuElement}
|
||||||
|
class="border-border bg-bg-elevated fixed z-[80] overflow-y-auto rounded-md border p-1 shadow-lg"
|
||||||
|
style={menuStyle}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">Встроенные</p>
|
||||||
|
{#each BUILTIN_CURVE_PRESETS as preset (preset.name)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-xs"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => apply(preset)}
|
||||||
|
>
|
||||||
|
<span class="flex-1 truncate">{preset.name}</span>
|
||||||
|
{#if preset.name === activeName}
|
||||||
|
<Icon name="check" class="text-primary size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if presets.length}
|
||||||
|
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">
|
||||||
|
Пользовательские
|
||||||
|
</p>
|
||||||
|
{#each presets as preset (preset.name)}
|
||||||
|
<div class="hover:bg-bg-muted flex items-center rounded">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-xs"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => apply(preset)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{preset.name}</span>
|
||||||
|
{#if preset.name === activeName}
|
||||||
|
<Icon name="check" class="text-primary ml-auto size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text px-1.5 py-1.5"
|
||||||
|
title="Переименовать пресет"
|
||||||
|
aria-label="Переименовать пресет {preset.name}"
|
||||||
|
onclick={() => startRename(preset)}
|
||||||
|
>
|
||||||
|
<Icon name="edit" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-danger px-1.5 py-1.5"
|
||||||
|
title="Удалить пресет"
|
||||||
|
aria-label="Удалить пресет {preset.name}"
|
||||||
|
onclick={() => void removePreset(preset)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-border mt-1 space-y-0.5 border-t pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||||
|
onclick={startSave}
|
||||||
|
>
|
||||||
|
<Icon name="save" class="size-3.5" />
|
||||||
|
Сохранить…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||||
|
onclick={pickFile}
|
||||||
|
>
|
||||||
|
<Icon name="cloud_upload" class="size-3.5" />
|
||||||
|
Импортировать…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||||
|
onclick={exportPresets}
|
||||||
|
>
|
||||||
|
<Icon name="download" class="size-3.5" />
|
||||||
|
Экспортировать…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-danger px-2 py-1 text-[11px]">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
onchange={onFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if dialog}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(ev) => {
|
||||||
|
if (ev.target === ev.currentTarget) closeDialog();
|
||||||
|
}}
|
||||||
|
onkeydown={(ev) => {
|
||||||
|
if (ev.key === 'Escape') closeDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="border-border bg-bg-elevated w-full max-w-sm rounded-lg border p-4 shadow-xl"
|
||||||
|
onsubmit={submit}
|
||||||
|
>
|
||||||
|
<h3 class="text-text text-sm font-semibold">
|
||||||
|
{dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
|
||||||
|
</h3>
|
||||||
|
<p class="text-text-muted mt-1 text-xs">
|
||||||
|
{dialog === 'save' ? 'Кривые всех каналов: RGB, R, G, B' : `Было: ${renameFrom}`}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={nameDraft}
|
||||||
|
maxlength={MAX_CURVE_PRESET_NAME}
|
||||||
|
class="border-border bg-bg text-text focus:border-primary mt-3 w-full rounded border px-2 py-1.5 text-sm outline-none"
|
||||||
|
placeholder="Название пресета"
|
||||||
|
aria-label="Название пресета"
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-danger mt-1 text-xs">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text-muted hover:text-text rounded-md border px-3 py-1.5 text-xs disabled:opacity-40"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={closeDialog}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-primary text-bg rounded-md px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? 'Сохраняем…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Полный градиент дорожки (например, для цветовых ползунков). */
|
||||||
|
gradient?: string;
|
||||||
|
/** Значение при двойном клике. */
|
||||||
|
resetValue?: number;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
oninput?: (value: number) => void;
|
||||||
|
onchange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
disabled = false,
|
||||||
|
gradient,
|
||||||
|
resetValue = 0,
|
||||||
|
format,
|
||||||
|
oninput,
|
||||||
|
onchange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const percent = $derived(((value - min) / (max - min)) * 100);
|
||||||
|
const trackStyle = $derived(
|
||||||
|
gradient
|
||||||
|
? `background: ${gradient};`
|
||||||
|
: `background: linear-gradient(to right, #cfcfcf 0%, #cfcfcf ${percent}%, #4a4a4a ${percent}%, #4a4a4a 100%);`
|
||||||
|
);
|
||||||
|
const display = $derived(format ? format(value) : String(value));
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
oninput?.(Number((event.currentTarget as HTMLInputElement).value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (disabled) return;
|
||||||
|
oninput?.(resetValue);
|
||||||
|
onchange?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<label class="text-text text-xs" for={id}>{label}</label>
|
||||||
|
<span class="text-text-muted text-xs tabular-nums">{display}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
type="range"
|
||||||
|
class="edit-slider"
|
||||||
|
style={trackStyle}
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
{disabled}
|
||||||
|
{value}
|
||||||
|
oninput={handleInput}
|
||||||
|
onchange={onchange}
|
||||||
|
ondblclick={handleDoubleClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edit-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.edit-slider:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.edit-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f3f3;
|
||||||
|
border: 1px solid #9a9a9a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.edit-slider::-moz-range-thumb {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f3f3;
|
||||||
|
border: 1px solid #9a9a9a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -19,19 +19,28 @@
|
|||||||
photo: Photo;
|
photo: Photo;
|
||||||
role: string;
|
role: string;
|
||||||
viewer?: boolean;
|
viewer?: boolean;
|
||||||
|
tab?: 'info' | 'edit';
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onRenamed?: (photo: Photo) => void;
|
onRenamed?: (photo: Photo) => void;
|
||||||
onDeleted?: () => void;
|
onDeleted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
|
let {
|
||||||
|
photo,
|
||||||
|
role,
|
||||||
|
viewer = false,
|
||||||
|
tab = $bindable('info'),
|
||||||
|
onOpen,
|
||||||
|
onRenamed,
|
||||||
|
onDeleted
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
const canRename = $derived(canEdit || photo?.uploader_id === auth.user?.id);
|
||||||
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
const canDelete = $derived(canEdit || photo?.uploader_id === auth.user?.id);
|
||||||
// Правки может вносить и загрузивший фото, если у него есть право загрузки.
|
// Правки может вносить и загрузивший фото, если у него есть право загрузки.
|
||||||
const canEditPhoto = $derived(
|
const canEditPhoto = $derived(
|
||||||
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer')
|
canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer')
|
||||||
);
|
);
|
||||||
|
|
||||||
let editingName = $state(false);
|
let editingName = $state(false);
|
||||||
@@ -39,8 +48,6 @@
|
|||||||
let savingName = $state(false);
|
let savingName = $state(false);
|
||||||
let nameError = $state('');
|
let nameError = $state('');
|
||||||
|
|
||||||
let tab = $state<'info' | 'edit'>('info');
|
|
||||||
|
|
||||||
let pendingDelete = $state(false);
|
let pendingDelete = $state(false);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
|
|
||||||
@@ -68,15 +75,16 @@
|
|||||||
// фото (например, отрендеренные правки) не должны сбрасывать вкладку.
|
// фото (например, отрендеренные правки) не должны сбрасывать вкладку.
|
||||||
let lastPhotoId = '';
|
let lastPhotoId = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = photo.id;
|
const id = photo?.id;
|
||||||
if (id === lastPhotoId) return;
|
if (!id || id === lastPhotoId) return;
|
||||||
lastPhotoId = id;
|
lastPhotoId = id;
|
||||||
editingName = false;
|
editingName = false;
|
||||||
nameError = '';
|
nameError = '';
|
||||||
showAll = false;
|
showAll = false;
|
||||||
tagQuery = '';
|
tagQuery = '';
|
||||||
tagOpen = false;
|
tagOpen = false;
|
||||||
tab = 'info';
|
// Вкладку не сбрасываем: при переходе между фото пользователь остаётся
|
||||||
|
// на том же разделе правой панели.
|
||||||
});
|
});
|
||||||
|
|
||||||
let tagQuery = $state('');
|
let tagQuery = $state('');
|
||||||
@@ -255,7 +263,12 @@ const tabClass =
|
|||||||
|
|
||||||
{#if tab === 'edit'}
|
{#if tab === 'edit'}
|
||||||
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
|
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
|
||||||
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
|
<PhotoEditPanel
|
||||||
|
{photo}
|
||||||
|
canEdit={canEditPhoto}
|
||||||
|
viewer={viewer}
|
||||||
|
onUpdated={(updated) => onRenamed?.(updated)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
|
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
|
||||||
@@ -504,7 +517,8 @@ const tabClass =
|
|||||||
labels={prefs.photo_info?.labels ?? {}}
|
labels={prefs.photo_info?.labels ?? {}}
|
||||||
busy={savingPrefs}
|
busy={savingPrefs}
|
||||||
onSave={savePreferences}
|
onSave={savePreferences}
|
||||||
onReset={() => savePreferences({})}
|
onReset={() =>
|
||||||
|
savePreferences({ photo_info: { visible: [...EXIF_BASE], labels: {} } })}
|
||||||
onClose={() => (settingsOpen = false)}
|
onClose={() => (settingsOpen = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
import { viewer } from '$lib/stores/viewer.svelte';
|
import { viewer } from '$lib/stores/viewer.svelte';
|
||||||
import { edit } from '$lib/stores/edit.svelte';
|
import { edit } from '$lib/stores/edit.svelte';
|
||||||
import { applyLight, initLightWasm } from '$lib/light-wasm';
|
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||||
|
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
|
||||||
|
import { WebGLPreview } from '$lib/webgl-preview';
|
||||||
import {
|
import {
|
||||||
|
isEmptyColor,
|
||||||
isEmptyEditParams,
|
isEmptyEditParams,
|
||||||
|
isEmptyLight,
|
||||||
photoFullUrl,
|
photoFullUrl,
|
||||||
photoPreviewUrl,
|
photoPreviewUrl,
|
||||||
readEditParams
|
readEditParams
|
||||||
@@ -30,10 +34,15 @@
|
|||||||
|
|
||||||
let fresh = $state<Photo | null>(null);
|
let fresh = $state<Photo | null>(null);
|
||||||
let touchX = 0;
|
let touchX = 0;
|
||||||
|
let previewLow = $state(false);
|
||||||
|
let useGL = $state(false);
|
||||||
|
|
||||||
const current = $derived(photos[index]);
|
const current = $derived(photos[index]);
|
||||||
|
|
||||||
const MAX_ZOOM = 2;
|
const MAX_ZOOM = 2;
|
||||||
|
// В зуме фильтруем уменьшенную копию вьюпорта: предпросмотр остаётся
|
||||||
|
// отзывчивым, а финальное качество даёт серверный рендер.
|
||||||
|
const PREVIEW_MAX = 560;
|
||||||
// Шаг зума клавишами `+`/`-`.
|
// Шаг зума клавишами `+`/`-`.
|
||||||
const ZOOM_STEP = 1.25;
|
const ZOOM_STEP = 1.25;
|
||||||
|
|
||||||
@@ -97,7 +106,15 @@
|
|||||||
|
|
||||||
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
||||||
const clientSource = $derived(
|
const clientSource = $derived(
|
||||||
zoomed ? originalSrc || basePreviewSrc : basePreviewSrc
|
useGL
|
||||||
|
? zoomed
|
||||||
|
? originalSrc || basePreviewSrc
|
||||||
|
: basePreviewSrc
|
||||||
|
: previewLow
|
||||||
|
? basePreviewSrc
|
||||||
|
: zoomed
|
||||||
|
? originalSrc || basePreviewSrc
|
||||||
|
: basePreviewSrc
|
||||||
);
|
);
|
||||||
const displaySrc = $derived(
|
const displaySrc = $derived(
|
||||||
clientPreview
|
clientPreview
|
||||||
@@ -152,8 +169,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// In zoom mode, preload the full-resolution original and swap it in once loaded.
|
// In zoom mode, preload the full-resolution original and swap it in once loaded.
|
||||||
|
// Во время правок full-res не грузим: превью рисуется из base preview.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!zoomed || hiResLoaded) return;
|
if (!zoomed || hiResLoaded || clientPreview) return;
|
||||||
const url = fullSrc;
|
const url = fullSrc;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const im = new Image();
|
const im = new Image();
|
||||||
@@ -179,61 +197,135 @@
|
|||||||
// Пока сервер не отрендерил отредактированную версию, видимую область
|
// Пока сервер не отрендерил отредактированную версию, видимую область
|
||||||
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
|
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
|
||||||
let canvas: HTMLCanvasElement | undefined = $state();
|
let canvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
let glCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
|
let glPreview: WebGLPreview | null = null;
|
||||||
let viewportFrame: number | null = null;
|
let viewportFrame: number | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const el = glCanvas;
|
||||||
|
if (!el) return;
|
||||||
|
const preview = WebGLPreview.create(el);
|
||||||
|
glPreview = preview;
|
||||||
|
useGL = !!preview;
|
||||||
|
return () => {
|
||||||
|
preview?.dispose();
|
||||||
|
glPreview = null;
|
||||||
|
useGL = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пока пользователь активно крутит контролы, фильтруем уменьшенный буфер,
|
||||||
|
// а после короткой паузы — полный кадр. Иначе тяжёлый фильтр блокирует UI.
|
||||||
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function markInteracting() {
|
||||||
|
previewLow = true;
|
||||||
|
if (settleTimer) clearTimeout(settleTimer);
|
||||||
|
settleTimer = setTimeout(() => {
|
||||||
|
settleTimer = null;
|
||||||
|
previewLow = false;
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
settleTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Рисует видимую область фото с применёнными правками. Работает и в зуме,
|
* Рисует видимую область фото с применёнными правками. Сначала пробует
|
||||||
* и в обычном виде: берём ту часть картинки, что попала во вьюпорт.
|
* WebGL (кадр считает GPU), затем CPU-фолбэк.
|
||||||
*/
|
*/
|
||||||
function renderViewport() {
|
function renderViewport() {
|
||||||
const c = canvas;
|
|
||||||
const img = imgEl;
|
const img = imgEl;
|
||||||
const el = scroller;
|
const el = scroller;
|
||||||
if (!c || !img || !el) return;
|
if (!img || !el) return;
|
||||||
const natural = img.naturalWidth;
|
const natural = img.naturalWidth;
|
||||||
if (!natural) return;
|
if (!natural) return;
|
||||||
const imgRect = img.getBoundingClientRect();
|
const imgRect = img.getBoundingClientRect();
|
||||||
const viewRect = el.getBoundingClientRect();
|
const viewRect = el.getBoundingClientRect();
|
||||||
if (!imgRect.width || !viewRect.width) return;
|
if (!imgRect.width || !viewRect.width) return;
|
||||||
const ratio = imgRect.width / natural;
|
|
||||||
|
|
||||||
// Видимая часть картинки.
|
|
||||||
const left = Math.max(imgRect.left, viewRect.left);
|
|
||||||
const top = Math.max(imgRect.top, viewRect.top);
|
|
||||||
const right = Math.min(imgRect.right, viewRect.right);
|
|
||||||
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
|
|
||||||
const visibleW = right - left;
|
|
||||||
const visibleH = bottom - top;
|
|
||||||
if (visibleW <= 0 || visibleH <= 0) return;
|
|
||||||
|
|
||||||
const width = el.clientWidth;
|
const width = el.clientWidth;
|
||||||
const height = el.clientHeight;
|
const height = el.clientHeight;
|
||||||
if (!width || !height) return;
|
if (!width || !height) return;
|
||||||
if (c.width !== width || c.height !== height) {
|
|
||||||
c.width = width;
|
|
||||||
c.height = height;
|
|
||||||
}
|
|
||||||
c.style.width = `${width}px`;
|
|
||||||
c.style.height = `${height}px`;
|
|
||||||
|
|
||||||
|
// Кусок изображения и его место на холсте.
|
||||||
|
let crop: { x: number; y: number; width: number; height: number };
|
||||||
|
let dest: { x: number; y: number; width: number; height: number };
|
||||||
|
if (!zoomed) {
|
||||||
|
crop = { x: 0, y: 0, width: natural, height: img.naturalHeight };
|
||||||
|
dest = {
|
||||||
|
x: imgRect.left - viewRect.left,
|
||||||
|
y: imgRect.top - viewRect.top,
|
||||||
|
width: imgRect.width,
|
||||||
|
height: imgRect.height
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const ratio = imgRect.width / natural;
|
||||||
|
const left = Math.max(imgRect.left, viewRect.left);
|
||||||
|
const top = Math.max(imgRect.top, viewRect.top);
|
||||||
|
const right = Math.min(imgRect.right, viewRect.right);
|
||||||
|
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
|
||||||
|
const visibleW = right - left;
|
||||||
|
const visibleH = bottom - top;
|
||||||
|
if (visibleW <= 0 || visibleH <= 0) return;
|
||||||
|
crop = {
|
||||||
|
x: (left - imgRect.left) / ratio,
|
||||||
|
y: (top - imgRect.top) / ratio,
|
||||||
|
width: visibleW / ratio,
|
||||||
|
height: visibleH / ratio
|
||||||
|
};
|
||||||
|
dest = {
|
||||||
|
x: left - viewRect.left,
|
||||||
|
y: top - viewRect.top,
|
||||||
|
width: visibleW,
|
||||||
|
height: visibleH
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU-путь: фильтр целиком на видеокарте, main thread свободен.
|
||||||
|
if (useGL && glPreview) {
|
||||||
|
const ok = glPreview.render({
|
||||||
|
image: img,
|
||||||
|
imageVersion: `${img.currentSrc || img.src}|${natural}|${img.naturalHeight}`,
|
||||||
|
imageWidth: natural,
|
||||||
|
imageHeight: img.naturalHeight,
|
||||||
|
crop,
|
||||||
|
viewport: { width, height },
|
||||||
|
dest,
|
||||||
|
light: liveParams.light,
|
||||||
|
color: liveParams.color
|
||||||
|
});
|
||||||
|
if (ok) return;
|
||||||
|
useGL = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU-фолбэк (если WebGL недоступен).
|
||||||
|
const c = canvas;
|
||||||
|
if (!c) return;
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, width, height);
|
const lowPx = isEmptyLight(liveParams.light) ? 900 : PREVIEW_MAX;
|
||||||
|
const maxPx = previewLow ? lowPx : 4096;
|
||||||
// Исходный кусок в пикселях картинки и его место на холсте.
|
const k = Math.min(1, maxPx / Math.max(dest.width, dest.height));
|
||||||
const sx = (left - imgRect.left) / ratio;
|
const bw = Math.max(1, Math.round(dest.width * k));
|
||||||
const sy = (top - imgRect.top) / ratio;
|
const bh = Math.max(1, Math.round(dest.height * k));
|
||||||
const sw = visibleW / ratio;
|
if (c.width !== bw || c.height !== bh) {
|
||||||
const sh = visibleH / ratio;
|
c.width = bw;
|
||||||
const dx = Math.round(left - viewRect.left);
|
c.height = bh;
|
||||||
const dy = Math.round(top - viewRect.top);
|
}
|
||||||
const dw = Math.round(visibleW);
|
c.style.left = `${Math.round(dest.x)}px`;
|
||||||
const dh = Math.round(visibleH);
|
c.style.top = `${Math.round(dest.y)}px`;
|
||||||
|
c.style.width = `${Math.round(dest.width)}px`;
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
c.style.height = `${Math.round(dest.height)}px`;
|
||||||
const data = ctx.getImageData(dx, dy, dw, dh);
|
ctx.clearRect(0, 0, bw, bh);
|
||||||
applyLight(data.data, liveParams.light);
|
ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, bw, bh);
|
||||||
ctx.putImageData(data, dx, dy);
|
const frame = ctx.getImageData(0, 0, bw, bh);
|
||||||
|
if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light);
|
||||||
|
if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color);
|
||||||
|
ctx.putImageData(frame, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleViewport() {
|
function scheduleViewport() {
|
||||||
@@ -250,10 +342,22 @@
|
|||||||
zoomed;
|
zoomed;
|
||||||
scale;
|
scale;
|
||||||
liveParams.light;
|
liveParams.light;
|
||||||
|
liveParams.color;
|
||||||
displaySrc;
|
displaySrc;
|
||||||
|
previewLow;
|
||||||
|
useGL;
|
||||||
scheduleViewport();
|
scheduleViewport();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Любое изменение параметров — это активное взаимодействие.
|
||||||
|
let lastLiveParams: typeof liveParams | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const p = liveParams;
|
||||||
|
if (p === lastLiveParams) return;
|
||||||
|
lastLiveParams = p;
|
||||||
|
markInteracting();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const onFs = () => {
|
const onFs = () => {
|
||||||
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||||||
@@ -439,6 +543,7 @@
|
|||||||
if (!current || !scroller) return;
|
if (!current || !scroller) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
markInteracting();
|
||||||
|
|
||||||
const natW = baseWidth();
|
const natW = baseWidth();
|
||||||
const natH = baseHeight();
|
const natH = baseHeight();
|
||||||
@@ -454,6 +559,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(ev: PointerEvent) {
|
function onPointerDown(ev: PointerEvent) {
|
||||||
|
if (eyedropper.mode !== 'none') return;
|
||||||
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
|
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
|
||||||
dragging = true;
|
dragging = true;
|
||||||
startX = ev.clientX;
|
startX = ev.clientX;
|
||||||
@@ -483,6 +589,46 @@
|
|||||||
scheduleViewport();
|
scheduleViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Пипетка: сэмплирует пиксель базового изображения под курсором. */
|
||||||
|
async function onImageClick(ev: MouseEvent) {
|
||||||
|
const photo = current;
|
||||||
|
if (!photo || !imgEl || eyedropper.mode === 'none') return;
|
||||||
|
const rect = imgEl.getBoundingClientRect();
|
||||||
|
const x = (ev.clientX - rect.left) / rect.width;
|
||||||
|
const y = (ev.clientY - rect.top) / rect.height;
|
||||||
|
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const src = (zoomed ? originalSrc : basePreviewSrc) || basePreviewSrc;
|
||||||
|
if (!src) return;
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('image load failed'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const px = Math.min(
|
||||||
|
img.naturalWidth - 1,
|
||||||
|
Math.max(0, Math.round(x * (img.naturalWidth - 1)))
|
||||||
|
);
|
||||||
|
const py = Math.min(
|
||||||
|
img.naturalHeight - 1,
|
||||||
|
Math.max(0, Math.round(y * (img.naturalHeight - 1)))
|
||||||
|
);
|
||||||
|
const data = ctx.getImageData(px, py, 1, 1).data;
|
||||||
|
eyedropper.report(photo.id, data[0], data[1], data[2]);
|
||||||
|
} catch {
|
||||||
|
/* изображение не загрузилось — тихо выходим */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleViewerFullscreen() {
|
async function toggleViewerFullscreen() {
|
||||||
if (!viewerEl) return;
|
if (!viewerEl) return;
|
||||||
try {
|
try {
|
||||||
@@ -730,7 +876,10 @@
|
|||||||
oncontextmenu={onContextMenu}
|
oncontextmenu={onContextMenu}
|
||||||
ontouchstart={onTouchStart}
|
ontouchstart={onTouchStart}
|
||||||
ontouchend={onTouchEnd}
|
ontouchend={onTouchEnd}
|
||||||
onscroll={scheduleViewport}
|
onscroll={() => {
|
||||||
|
markInteracting();
|
||||||
|
scheduleViewport();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<img
|
<img
|
||||||
@@ -746,12 +895,26 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if eyedropper.mode !== 'none' && eyedropper.photoId === current?.id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-0 z-20 cursor-crosshair"
|
||||||
|
aria-label="Выберите нейтральную точку на фото"
|
||||||
|
onclick={onImageClick}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
{#if clientPreview}
|
{#if clientPreview}
|
||||||
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
||||||
сервер готовит отредактированную версию фото. -->
|
сервер готовит отредактированную версию фото. -->
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
class="pointer-events-none absolute inset-0 z-10 select-none"
|
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||||||
|
class:hidden={useGL}
|
||||||
|
></canvas>
|
||||||
|
<canvas
|
||||||
|
bind:this={glCanvas}
|
||||||
|
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||||||
|
class:hidden={!useGL}
|
||||||
></canvas>
|
></canvas>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,9 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
applyColorToPixels,
|
||||||
|
buildCurveLUT,
|
||||||
|
curveSvgPath,
|
||||||
|
effectiveCurvePoints,
|
||||||
|
emptyColor,
|
||||||
emptyEditParams,
|
emptyEditParams,
|
||||||
|
isEmptyColor,
|
||||||
isEmptyEditParams,
|
isEmptyEditParams,
|
||||||
normalizeEditParams,
|
normalizeEditParams,
|
||||||
readEditParams
|
readEditParams,
|
||||||
|
whiteBalanceFromRGB
|
||||||
} from './edit';
|
} from './edit';
|
||||||
import type { Photo } from './types';
|
import type { Photo } from './types';
|
||||||
|
|
||||||
@@ -56,3 +63,195 @@ describe('параметры правок', () => {
|
|||||||
expect(points[1].x).toBeCloseTo(0.8);
|
expect(points[1].x).toBeCloseTo(0.8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('цветовые параметры', () => {
|
||||||
|
it('зажимает значения и оттенки колёс', () => {
|
||||||
|
const params = normalizeEditParams({
|
||||||
|
light: emptyEditParams().light,
|
||||||
|
color: {
|
||||||
|
...emptyColor(),
|
||||||
|
temperature: 200,
|
||||||
|
tint: -300,
|
||||||
|
saturation: -500,
|
||||||
|
grading: {
|
||||||
|
...emptyColor().grading,
|
||||||
|
shadows: { hue: 400, saturation: 150, luminance: 0 },
|
||||||
|
balance: -200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(params.color.temperature).toBe(100);
|
||||||
|
expect(params.color.tint).toBe(-100);
|
||||||
|
expect(params.color.saturation).toBe(-100);
|
||||||
|
expect(params.color.grading.shadows.hue).toBe(360);
|
||||||
|
expect(params.color.grading.shadows.saturation).toBe(100);
|
||||||
|
expect(params.color.grading.balance).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('считает пустыми нулевые цветовые параметры', () => {
|
||||||
|
expect(isEmptyColor(emptyColor())).toBe(true);
|
||||||
|
expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false);
|
||||||
|
const hueOnly = emptyColor();
|
||||||
|
hueOnly.grading.shadows.hue = 120;
|
||||||
|
expect(isEmptyColor(hueOnly)).toBe(true);
|
||||||
|
const grade = emptyColor();
|
||||||
|
grade.grading.highlights.saturation = 10;
|
||||||
|
expect(isEmptyColor(grade)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('читает цвет из метаданных и не теряет свет', () => {
|
||||||
|
const photo = {
|
||||||
|
edit_params: {
|
||||||
|
light: { exposure: 0.5 },
|
||||||
|
color: { temperature: 20, vibrance: 10 }
|
||||||
|
}
|
||||||
|
} as unknown as Photo;
|
||||||
|
const params = readEditParams(photo);
|
||||||
|
expect(params.light.exposure).toBe(0.5);
|
||||||
|
expect(params.color.temperature).toBe(20);
|
||||||
|
expect(params.color.vibrance).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('осветляет тёплым балансом белого', () => {
|
||||||
|
const pixels = new Uint8ClampedArray([100, 100, 100, 255]);
|
||||||
|
applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 });
|
||||||
|
expect(pixels[0]).toBeGreaterThan(pixels[1]);
|
||||||
|
expect(pixels[1]).toBeGreaterThan(pixels[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обеспечивает серый цвет при насыщенности -100', () => {
|
||||||
|
const pixels = new Uint8ClampedArray([200, 50, 50, 255]);
|
||||||
|
applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 });
|
||||||
|
expect(pixels[0]).toBe(pixels[1]);
|
||||||
|
expect(pixels[1]).toBe(pixels[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('тонирует тени синим колесом', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 };
|
||||||
|
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||||
|
applyColorToPixels(pixels, color);
|
||||||
|
expect(pixels[2]).toBeGreaterThan(pixels[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('повышает яркость теней', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 };
|
||||||
|
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||||
|
applyColorToPixels(pixels, color);
|
||||||
|
expect(pixels[0]).toBeGreaterThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одинаково обрабатывает все строки однородного изображения', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 };
|
||||||
|
const pixels = new Uint8ClampedArray(64 * 64 * 4);
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
pixels[i] = 40;
|
||||||
|
pixels[i + 1] = 40;
|
||||||
|
pixels[i + 2] = 40;
|
||||||
|
pixels[i + 3] = 255;
|
||||||
|
}
|
||||||
|
applyColorToPixels(pixels, color);
|
||||||
|
for (let i = 4; i < pixels.length; i += 4) {
|
||||||
|
expect(pixels[i]).toBe(pixels[0]);
|
||||||
|
expect(pixels[i + 1]).toBe(pixels[1]);
|
||||||
|
expect(pixels[i + 2]).toBe(pixels[2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает прозрачные пиксели', () => {
|
||||||
|
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||||
|
applyColorToPixels(pixels, {
|
||||||
|
...emptyColor(),
|
||||||
|
temperature: 100,
|
||||||
|
saturation: 100
|
||||||
|
});
|
||||||
|
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подбирает баланс белого по нейтральной точке', () => {
|
||||||
|
const neutral = whiteBalanceFromRGB(128, 128, 128);
|
||||||
|
expect(neutral.temperature).toBeCloseTo(0, 5);
|
||||||
|
expect(neutral.tint).toBeCloseTo(0, 5);
|
||||||
|
|
||||||
|
const wb = whiteBalanceFromRGB(150, 140, 120);
|
||||||
|
const temperature = wb.temperature / 100;
|
||||||
|
const tint = wb.tint / 100;
|
||||||
|
const r = 150 * (1 + 0.2 * temperature + 0.1 * tint);
|
||||||
|
const g = 140 * (1 - 0.2 * tint);
|
||||||
|
const b = 120 * (1 - 0.2 * temperature + 0.1 * tint);
|
||||||
|
expect(r).toBeCloseTo(g, 5);
|
||||||
|
expect(b).toBeCloseTo(g, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('кривая', () => {
|
||||||
|
const medium = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0.25, y: 0.2 },
|
||||||
|
{ x: 0.5, y: 0.5 },
|
||||||
|
{ x: 0.75, y: 0.8 },
|
||||||
|
{ x: 1, y: 1 }
|
||||||
|
];
|
||||||
|
const strong = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0.2, y: 0.12 },
|
||||||
|
{ x: 0.5, y: 0.5 },
|
||||||
|
{ x: 0.8, y: 0.88 },
|
||||||
|
{ x: 1, y: 1 }
|
||||||
|
];
|
||||||
|
const inner = [
|
||||||
|
{ x: 0.1, y: 0.05 },
|
||||||
|
{ x: 0.4, y: 0.3 },
|
||||||
|
{ x: 0.9, y: 0.95 }
|
||||||
|
];
|
||||||
|
const samples = [0, 32, 64, 128, 192, 224, 255];
|
||||||
|
|
||||||
|
it('показывает крайние точки у пустого канала', () => {
|
||||||
|
expect(effectiveCurvePoints(undefined)).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 }
|
||||||
|
]);
|
||||||
|
expect(effectiveCurvePoints([{ x: 0.5, y: 0.4 }])).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 }
|
||||||
|
]);
|
||||||
|
expect(effectiveCurvePoints(inner)).toEqual(inner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с серверной PCHIP-кривой', () => {
|
||||||
|
const values = (points: { x: number; y: number }[]) => {
|
||||||
|
const lut = buildCurveLUT(points);
|
||||||
|
return samples.map((i) => lut[i]);
|
||||||
|
};
|
||||||
|
expect(values([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([
|
||||||
|
0, 32, 64, 128, 192, 224, 255
|
||||||
|
]);
|
||||||
|
expect(values(medium)).toEqual([0, 23, 51, 128, 205, 233, 255]);
|
||||||
|
expect(values(strong)).toEqual([0, 17, 43, 128, 213, 239, 255]);
|
||||||
|
expect(values(inner)).toEqual([13, 17, 42, 104, 186, 234, 242]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('остаётся монотонной между точками', () => {
|
||||||
|
for (const points of [medium, strong]) {
|
||||||
|
const lut = buildCurveLUT(points);
|
||||||
|
for (let i = 1; i < lut.length; i++) {
|
||||||
|
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('делает плоские полки вне крайних точек', () => {
|
||||||
|
const lut = buildCurveLUT(inner);
|
||||||
|
for (let i = 0; i <= Math.floor(0.1 * 255); i++) expect(lut[i]).toBe(13);
|
||||||
|
for (let i = Math.ceil(0.9 * 255); i < 256; i++) expect(lut[i]).toBe(242);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('рисует плавный SVG-путь с полками', () => {
|
||||||
|
const path = curveSvgPath(inner);
|
||||||
|
expect(path.startsWith('M0.0000,95.0000 L10.0000,95.0000')).toBe(true);
|
||||||
|
expect(path).toContain('C');
|
||||||
|
expect(path.endsWith('L100.0000,5.0000')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+521
-23
@@ -30,10 +30,91 @@ export interface LightParams {
|
|||||||
curve: CurveParams;
|
curve: CurveParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MixerBand {
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
luminance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorMixer {
|
||||||
|
red: MixerBand;
|
||||||
|
orange: MixerBand;
|
||||||
|
yellow: MixerBand;
|
||||||
|
green: MixerBand;
|
||||||
|
aqua: MixerBand;
|
||||||
|
blue: MixerBand;
|
||||||
|
purple: MixerBand;
|
||||||
|
magenta: MixerBand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorWheel {
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
luminance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorGrading {
|
||||||
|
shadows: ColorWheel;
|
||||||
|
midtones: ColorWheel;
|
||||||
|
highlights: ColorWheel;
|
||||||
|
blending: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorCorrection {
|
||||||
|
red: number;
|
||||||
|
green: number;
|
||||||
|
blue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorParams {
|
||||||
|
temperature: number;
|
||||||
|
tint: number;
|
||||||
|
vibrance: number;
|
||||||
|
saturation: number;
|
||||||
|
mixer: ColorMixer;
|
||||||
|
grading: ColorGrading;
|
||||||
|
correction: ColorCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EditParams {
|
export interface EditParams {
|
||||||
light: LightParams;
|
light: LightParams;
|
||||||
|
color: ColorParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
|
||||||
|
export const MIXER_BANDS = [
|
||||||
|
{ key: 'red', label: 'Красное', center: 0 },
|
||||||
|
{ key: 'orange', label: 'Оранжевое', center: 30 },
|
||||||
|
{ key: 'yellow', label: 'Жёлтое', center: 60 },
|
||||||
|
{ key: 'green', label: 'Зелёное', center: 120 },
|
||||||
|
{ key: 'aqua', label: 'Бирюзовое', center: 180 },
|
||||||
|
{ key: 'blue', label: 'Синее', center: 240 },
|
||||||
|
{ key: 'purple', label: 'Фиолетовое', center: 280 },
|
||||||
|
{ key: 'magenta', label: 'Пурпурное', center: 320 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MixerBandKey = (typeof MIXER_BANDS)[number]['key'];
|
||||||
|
|
||||||
|
/** Ползунки базового блока «Цвет». */
|
||||||
|
export const COLOR_SLIDERS = [
|
||||||
|
{ key: 'temperature', label: 'Темп.', min: -100, max: 100, step: 1 },
|
||||||
|
{ key: 'tint', label: 'Оттенок', min: -100, max: 100, step: 1 },
|
||||||
|
{ key: 'vibrance', label: 'Сочность', min: -100, max: 100, step: 1 },
|
||||||
|
{ key: 'saturation', label: 'Насыщенность', min: -100, max: 100, step: 1 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key'];
|
||||||
|
|
||||||
|
/** Колёса точечного цвета. */
|
||||||
|
export const GRADING_WHEELS = [
|
||||||
|
{ key: 'shadows', label: 'Тени' },
|
||||||
|
{ key: 'midtones', label: 'Средние тона' },
|
||||||
|
{ key: 'highlights', label: 'Света' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GradingWheelKey = (typeof GRADING_WHEELS)[number]['key'];
|
||||||
|
|
||||||
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
|
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
|
||||||
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
|
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
|
||||||
|
|
||||||
@@ -45,7 +126,8 @@ export const CHANNEL_LABELS: Record<CurveChannel, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
|
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
|
||||||
rgb: 'var(--text)',
|
// --text в теме хранит компоненты HSL, поэтому цвет собирается через hsl().
|
||||||
|
rgb: 'hsl(var(--text))',
|
||||||
red: '#ef4444',
|
red: '#ef4444',
|
||||||
green: '#22c55e',
|
green: '#22c55e',
|
||||||
blue: '#3b82f6'
|
blue: '#3b82f6'
|
||||||
@@ -72,6 +154,12 @@ export const MAX_EXPOSURE_STOPS = 5;
|
|||||||
export const MAX_PERCENT = 100;
|
export const MAX_PERCENT = 100;
|
||||||
export const MAX_CURVE_POINTS = 16;
|
export const MAX_CURVE_POINTS = 16;
|
||||||
|
|
||||||
|
/** Крайние точки кривой по умолчанию: линейная кривая. */
|
||||||
|
export const DEFAULT_CURVE_POINTS: CurvePoint[] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
export function emptyLight(): LightParams {
|
export function emptyLight(): LightParams {
|
||||||
return {
|
return {
|
||||||
exposure: 0,
|
exposure: 0,
|
||||||
@@ -85,11 +173,42 @@ export function emptyLight(): LightParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function emptyEditParams(): EditParams {
|
export function emptyEditParams(): EditParams {
|
||||||
return { light: emptyLight() };
|
return { light: emptyLight(), color: emptyColor() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(v: number, min: number, max: number): number {
|
function emptyMixerBand(): MixerBand {
|
||||||
if (!Number.isFinite(v)) return 0;
|
return { hue: 0, saturation: 0, luminance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyColor(): ColorParams {
|
||||||
|
return {
|
||||||
|
temperature: 0,
|
||||||
|
tint: 0,
|
||||||
|
vibrance: 0,
|
||||||
|
saturation: 0,
|
||||||
|
mixer: {
|
||||||
|
red: emptyMixerBand(),
|
||||||
|
orange: emptyMixerBand(),
|
||||||
|
yellow: emptyMixerBand(),
|
||||||
|
green: emptyMixerBand(),
|
||||||
|
aqua: emptyMixerBand(),
|
||||||
|
blue: emptyMixerBand(),
|
||||||
|
purple: emptyMixerBand(),
|
||||||
|
magenta: emptyMixerBand()
|
||||||
|
},
|
||||||
|
grading: {
|
||||||
|
shadows: { hue: 0, saturation: 0, luminance: 0 },
|
||||||
|
midtones: { hue: 0, saturation: 0, luminance: 0 },
|
||||||
|
highlights: { hue: 0, saturation: 0, luminance: 0 },
|
||||||
|
blending: 50,
|
||||||
|
balance: 0
|
||||||
|
},
|
||||||
|
correction: { red: 0, green: 0, blue: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number | undefined, min: number, max: number): number {
|
||||||
|
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
|
||||||
return Math.min(max, Math.max(min, v));
|
return Math.min(max, Math.max(min, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +231,10 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Приводит параметры к допустимым диапазонам. */
|
/** Приводит параметры к допустимым диапазонам. */
|
||||||
export function normalizeEditParams(params: EditParams): EditParams {
|
export function normalizeEditParams(params: {
|
||||||
|
light?: Partial<LightParams>;
|
||||||
|
color?: Partial<ColorParams>;
|
||||||
|
}): EditParams {
|
||||||
const light = params?.light ?? emptyLight();
|
const light = params?.light ?? emptyLight();
|
||||||
return {
|
return {
|
||||||
light: {
|
light: {
|
||||||
@@ -128,13 +250,64 @@ export function normalizeEditParams(params: EditParams): EditParams {
|
|||||||
green: normalizeCurve(light.curve?.green),
|
green: normalizeCurve(light.curve?.green),
|
||||||
blue: normalizeCurve(light.curve?.blue)
|
blue: normalizeCurve(light.curve?.blue)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
color: normalizeColor(params?.color)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBand(band?: Partial<MixerBand>): MixerBand {
|
||||||
|
return {
|
||||||
|
hue: clamp(band?.hue ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
saturation: clamp(band?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
luminance: clamp(band?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWheel(wheel?: Partial<ColorWheel>): ColorWheel {
|
||||||
|
return {
|
||||||
|
hue: clamp(wheel?.hue ?? 0, 0, 360),
|
||||||
|
saturation: clamp(wheel?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
luminance: clamp(wheel?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Приводит цветовые параметры к допустимым диапазонам. */
|
||||||
|
export function normalizeColor(color?: Partial<ColorParams>): ColorParams {
|
||||||
|
const mixer: Partial<ColorMixer> = color?.mixer ?? {};
|
||||||
|
const grading: Partial<ColorGrading> = color?.grading ?? {};
|
||||||
|
const correction: Partial<ColorCorrection> = color?.correction ?? {};
|
||||||
|
return {
|
||||||
|
temperature: clamp(color?.temperature ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
tint: clamp(color?.tint ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
vibrance: clamp(color?.vibrance ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
saturation: clamp(color?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
mixer: {
|
||||||
|
red: normalizeBand(mixer.red),
|
||||||
|
orange: normalizeBand(mixer.orange),
|
||||||
|
yellow: normalizeBand(mixer.yellow),
|
||||||
|
green: normalizeBand(mixer.green),
|
||||||
|
aqua: normalizeBand(mixer.aqua),
|
||||||
|
blue: normalizeBand(mixer.blue),
|
||||||
|
purple: normalizeBand(mixer.purple),
|
||||||
|
magenta: normalizeBand(mixer.magenta)
|
||||||
|
},
|
||||||
|
grading: {
|
||||||
|
shadows: normalizeWheel(grading.shadows),
|
||||||
|
midtones: normalizeWheel(grading.midtones),
|
||||||
|
highlights: normalizeWheel(grading.highlights),
|
||||||
|
blending: clamp(grading.blending ?? 50, 0, MAX_PERCENT),
|
||||||
|
balance: clamp(grading.balance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||||
|
},
|
||||||
|
correction: {
|
||||||
|
red: clamp(correction.red ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
green: clamp(correction.green ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
blue: clamp(correction.blue ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пустые ли параметры: фотография не тронута. */
|
/** Пустой ли блок «Свет». */
|
||||||
export function isEmptyEditParams(params: EditParams): boolean {
|
export function isEmptyLight(light: LightParams): boolean {
|
||||||
const light = params.light;
|
|
||||||
if (
|
if (
|
||||||
light.exposure !== 0 ||
|
light.exposure !== 0 ||
|
||||||
light.contrast !== 0 ||
|
light.contrast !== 0 ||
|
||||||
@@ -153,16 +326,76 @@ export function isEmptyEditParams(params: EditParams): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цвет можно посчитать быстрым путём (без HSL): не задействованы микшер и
|
||||||
|
* насыщенность/вибрация — только колёса, яркость зон и температура/оттенок.
|
||||||
|
*/
|
||||||
|
export function isColorFast(color: ColorParams): boolean {
|
||||||
|
if (color.vibrance !== 0 || color.saturation !== 0) return false;
|
||||||
|
for (const band of MIXER_BANDS) {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
if (value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустые ли параметры: фотография не тронута. */
|
||||||
|
export function isEmptyEditParams(params: EditParams): boolean {
|
||||||
|
return isEmptyLight(params.light) && isEmptyColor(params.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пусты ли цветовые параметры: цвет не тронут. */
|
||||||
|
export function isEmptyColor(color?: ColorParams): boolean {
|
||||||
|
if (!color) return true;
|
||||||
|
if (color.temperature || color.tint || color.vibrance || color.saturation) return false;
|
||||||
|
for (const band of MIXER_BANDS) {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
if (value?.hue || value?.saturation || value?.luminance) return false;
|
||||||
|
}
|
||||||
|
for (const wheel of GRADING_WHEELS) {
|
||||||
|
const value = color.grading[wheel.key];
|
||||||
|
// Оттенок колеса без силы или яркости не влияет на картинку.
|
||||||
|
if (value?.saturation || value?.luminance) return false;
|
||||||
|
}
|
||||||
|
const correction = color.correction;
|
||||||
|
return !correction?.red && !correction?.green && !correction?.blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подбирает баланс белого, при котором нейтральный пиксель (r, g, b) станет
|
||||||
|
* серым. Возвращает температуру и оттенок в диапазоне -100..100.
|
||||||
|
*/
|
||||||
|
export function whiteBalanceFromRGB(
|
||||||
|
r: number,
|
||||||
|
g: number,
|
||||||
|
b: number
|
||||||
|
): { temperature: number; tint: number } {
|
||||||
|
const rr = Math.max(1, r);
|
||||||
|
const gg = Math.max(1, g);
|
||||||
|
const bb = Math.max(1, b);
|
||||||
|
const sum = gg * (1 / rr + 1 / bb);
|
||||||
|
const tint = (sum - 2) / (0.2 * (1 + sum));
|
||||||
|
const factor = 1 - 0.2 * tint;
|
||||||
|
const temperature = (gg * (1 / rr - 1 / bb) * factor) / 0.4;
|
||||||
|
return {
|
||||||
|
temperature: clamp(temperature * 100, -MAX_PERCENT, MAX_PERCENT),
|
||||||
|
tint: clamp(tint * 100, -MAX_PERCENT, MAX_PERCENT)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Читает параметры правок из метаданных фото. */
|
/** Читает параметры правок из метаданных фото. */
|
||||||
export function readEditParams(photo: Photo | null | undefined): EditParams {
|
export function readEditParams(photo: Photo | null | undefined): EditParams {
|
||||||
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined;
|
const raw = photo?.edit_params as
|
||||||
if (!raw?.light) return emptyEditParams();
|
| { light?: Partial<LightParams>; color?: Partial<ColorParams> }
|
||||||
|
| undefined;
|
||||||
|
if (!raw?.light && !raw?.color) return emptyEditParams();
|
||||||
return normalizeEditParams({
|
return normalizeEditParams({
|
||||||
light: {
|
light: {
|
||||||
...emptyLight(),
|
...emptyLight(),
|
||||||
...raw.light,
|
...raw?.light,
|
||||||
curve: raw.light.curve ?? {}
|
curve: raw?.light?.curve ?? {}
|
||||||
}
|
},
|
||||||
|
color: raw?.color ?? emptyColor()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +417,58 @@ export function photoFullUrl(photo: Photo): string | undefined {
|
|||||||
return photo.edit_url ?? photo.original_url;
|
return photo.edit_url ?? photo.original_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точки для отображения: у пустого или некорректного канала показываем
|
||||||
|
* линейную кривую с крайними точками. Сохранённые точки остаются источником
|
||||||
|
* истины, если их хотя бы две.
|
||||||
|
*/
|
||||||
|
export function effectiveCurvePoints(points?: CurvePoint[]): CurvePoint[] {
|
||||||
|
const pts = normalizeCurve(points);
|
||||||
|
if (!pts || pts.length < 2) return DEFAULT_CURVE_POINTS.map((p) => ({ ...p }));
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наклоны в контрольных точках для монотонного кубического Эрмита
|
||||||
|
* (Fritsch–Carlson). Формула совпадает с серверной реализацией.
|
||||||
|
*/
|
||||||
|
function curveTangents(points: CurvePoint[]): number[] {
|
||||||
|
const n = points.length;
|
||||||
|
if (n < 2) return [];
|
||||||
|
const h = new Array<number>(n - 1);
|
||||||
|
const delta = new Array<number>(n - 1);
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
h[i] = points[i + 1].x - points[i].x;
|
||||||
|
delta[i] = (points[i + 1].y - points[i].y) / h[i];
|
||||||
|
}
|
||||||
|
const m = new Array<number>(n).fill(0);
|
||||||
|
if (n === 2) {
|
||||||
|
m[0] = delta[0];
|
||||||
|
m[1] = delta[0];
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1]);
|
||||||
|
m[n - 1] = endpointTangent(h[n - 2], h[n - 3], delta[n - 2], delta[n - 3]);
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
if (delta[i - 1] * delta[i] <= 0) {
|
||||||
|
m[i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const w1 = 2 * h[i] + h[i - 1];
|
||||||
|
const w2 = h[i] + 2 * h[i - 1];
|
||||||
|
m[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Односторонняя касательная крайней точки с защитой от выбросов. */
|
||||||
|
function endpointTangent(h0: number, h1: number, d0: number, d1: number): number {
|
||||||
|
const m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
|
||||||
|
if (m * d0 <= 0) return 0;
|
||||||
|
if (d0 * d1 < 0 && Math.abs(m) > Math.abs(3 * d0)) return 3 * d0;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика
|
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика
|
||||||
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
|
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
|
||||||
@@ -196,25 +481,63 @@ export function buildCurveLUT(points?: CurvePoint[]): Uint8Array {
|
|||||||
for (let i = 0; i < 256; i++) lut[i] = i;
|
for (let i = 0; i < 256; i++) lut[i] = i;
|
||||||
return lut;
|
return lut;
|
||||||
}
|
}
|
||||||
|
const tangents = curveTangents(pts);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255);
|
lut[i] = Math.round(clamp01(curveEval(pts, tangents, i / 255)) * 255);
|
||||||
}
|
}
|
||||||
return lut;
|
return lut;
|
||||||
}
|
}
|
||||||
|
|
||||||
function curveEval(points: CurvePoint[], x: number): number {
|
/** Значение плавной кривой в точке x; вне крайних точек — плоская полка. */
|
||||||
|
function curveEval(points: CurvePoint[], tangents: number[], x: number): number {
|
||||||
if (x <= points[0].x) return points[0].y;
|
if (x <= points[0].x) return points[0].y;
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (x >= last.x) return last.y;
|
if (x >= last.x) return last.y;
|
||||||
for (let i = 1; i < points.length; i++) {
|
let i = 1;
|
||||||
if (x > points[i].x) continue;
|
while (i < points.length - 1 && x > points[i].x) i++;
|
||||||
const prev = points[i - 1];
|
const p0 = points[i - 1];
|
||||||
const cur = points[i];
|
const p1 = points[i];
|
||||||
if (cur.x - prev.x < 1e-9) return cur.y;
|
const h = p1.x - p0.x;
|
||||||
const t = (x - prev.x) / (cur.x - prev.x);
|
if (h < 1e-9) return p1.y;
|
||||||
return prev.y + t * (cur.y - prev.y);
|
const t = (x - p0.x) / h;
|
||||||
|
const t2 = t * t;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
const h00 = 2 * t3 - 3 * t2 + 1;
|
||||||
|
const h10 = t3 - 2 * t2 + t;
|
||||||
|
const h01 = -2 * t3 + 3 * t2;
|
||||||
|
const h11 = t3 - t2;
|
||||||
|
return h00 * p0.y + h10 * h * tangents[i - 1] + h01 * p1.y + h11 * h * tangents[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG-путь кривой в координатах 0..100 (ось Y перевёрнута). Между точками —
|
||||||
|
* кубические Безье с теми же касательными, что и у LUT; вне крайних точек —
|
||||||
|
* горизонтальные полки.
|
||||||
|
*/
|
||||||
|
export function curveSvgPath(points?: CurvePoint[]): string {
|
||||||
|
const pts = effectiveCurvePoints(points);
|
||||||
|
if (pts.length < 2) return 'M0,100 L100,0';
|
||||||
|
const tangents = curveTangents(pts);
|
||||||
|
const sx = (x: number) => (clamp01(x) * 100).toFixed(4);
|
||||||
|
const sy = (y: number) => ((1 - clamp01(y)) * 100).toFixed(4);
|
||||||
|
const parts: string[] = [`M${sx(0)},${sy(pts[0].y)}`];
|
||||||
|
if (pts[0].x > 1e-9) parts.push(`L${sx(pts[0].x)},${sy(pts[0].y)}`);
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const p0 = pts[i];
|
||||||
|
const p1 = pts[i + 1];
|
||||||
|
const h = p1.x - p0.x;
|
||||||
|
if (h < 1e-9) continue;
|
||||||
|
const c1x = p0.x + h / 3;
|
||||||
|
const c1y = p0.y + (tangents[i] * h) / 3;
|
||||||
|
const c2x = p1.x - h / 3;
|
||||||
|
const c2y = p1.y - (tangents[i + 1] * h) / 3;
|
||||||
|
parts.push(
|
||||||
|
`C${sx(c1x)},${sy(c1y)} ${sx(c2x)},${sy(c2y)} ${sx(p1.x)},${sy(p1.y)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return last.y;
|
const last = pts[pts.length - 1];
|
||||||
|
if (last.x < 1 - 1e-9) parts.push(`L${sx(1)},${sy(last.y)}`);
|
||||||
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustChannel(
|
function adjustChannel(
|
||||||
@@ -260,3 +583,178 @@ export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams):
|
|||||||
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
|
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1. */
|
||||||
|
function rgbToHSL(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h: number;
|
||||||
|
if (max === r) {
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
} else if (max === g) {
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
} else {
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
return [h * 60, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueToRGB(p: number, q: number, t: number): number {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HSL -> RGB (0..255). */
|
||||||
|
export function hslToRGB(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
if (s === 0) {
|
||||||
|
const v = l * 255;
|
||||||
|
return [v, v, v];
|
||||||
|
}
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
const hk = h / 360;
|
||||||
|
return [
|
||||||
|
hueToRGB(p, q, hk + 1 / 3) * 255,
|
||||||
|
hueToRGB(p, q, hk) * 255,
|
||||||
|
hueToRGB(p, q, hk - 1 / 3) * 255
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Треугольный вес оттенка вокруг центра полосы микшера. */
|
||||||
|
function bandWeight(h: number, center: number): number {
|
||||||
|
let d = Math.abs(h - center);
|
||||||
|
if (d > 180) d = 360 - d;
|
||||||
|
return clamp01(1 - d / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет правки «Цвет» к пикселям (в том же массиве). Порядок операций
|
||||||
|
* повторяет серверный рендер: баланс белого, HSL, насыщенность/вибрация,
|
||||||
|
* микшер, точечный цвет, цветокоррекция.
|
||||||
|
*/
|
||||||
|
export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams): void {
|
||||||
|
const temperature = color.temperature / 100;
|
||||||
|
const tint = color.tint / 100;
|
||||||
|
const vibrance = color.vibrance / 100;
|
||||||
|
const saturation = color.saturation / 100;
|
||||||
|
const blending = color.grading.blending / 100;
|
||||||
|
const balance = color.grading.balance / 100;
|
||||||
|
const correction = color.correction;
|
||||||
|
const tints = GRADING_WHEELS.map((wheel) =>
|
||||||
|
hslToRGB(color.grading[wheel.key].hue, 1, 0.5)
|
||||||
|
);
|
||||||
|
const blend = blending;
|
||||||
|
const hasMixer = MIXER_BANDS.some((band) => {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
return value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i + 3 < data.length; i += 4) {
|
||||||
|
if (data[i + 3] === 0) continue;
|
||||||
|
let r = data[i];
|
||||||
|
let g = data[i + 1];
|
||||||
|
let b = data[i + 2];
|
||||||
|
|
||||||
|
// Баланс белого.
|
||||||
|
r *= 1 + 0.2 * temperature + 0.1 * tint;
|
||||||
|
g *= 1 - 0.2 * tint;
|
||||||
|
b *= 1 - 0.2 * temperature + 0.1 * tint;
|
||||||
|
r = clamp(r, 0, 255);
|
||||||
|
g = clamp(g, 0, 255);
|
||||||
|
b = clamp(b, 0, 255);
|
||||||
|
|
||||||
|
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
|
||||||
|
// не нужен HSL-переход, lightness считается напрямую.
|
||||||
|
const fast = !hasMixer && saturation === 0 && vibrance === 0;
|
||||||
|
let weights: number[];
|
||||||
|
let rr: number;
|
||||||
|
let gg: number;
|
||||||
|
let bb: number;
|
||||||
|
|
||||||
|
if (fast) {
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 510;
|
||||||
|
const lb = clamp01(l + balance * 0.5);
|
||||||
|
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
|
||||||
|
const highlightW = lb * lb * lb * lb;
|
||||||
|
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
|
||||||
|
let targetL = l;
|
||||||
|
GRADING_WHEELS.forEach((wheel, index) => {
|
||||||
|
targetL +=
|
||||||
|
((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index];
|
||||||
|
});
|
||||||
|
targetL = clamp01(targetL);
|
||||||
|
const delta = (targetL - l) * 255;
|
||||||
|
rr = r + delta;
|
||||||
|
gg = g + delta;
|
||||||
|
bb = b + delta;
|
||||||
|
} else {
|
||||||
|
// RGB -> HSL.
|
||||||
|
let [h, s, l] = rgbToHSL(r, g, b);
|
||||||
|
|
||||||
|
// Насыщенность и вибрация.
|
||||||
|
if (saturation !== 0 || vibrance !== 0) {
|
||||||
|
s = clamp01(s * (1 + saturation + vibrance * (1 - s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветовой микшер.
|
||||||
|
if (hasMixer) {
|
||||||
|
for (const band of MIXER_BANDS) {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
const w = bandWeight(h, band.center);
|
||||||
|
if (w === 0) continue;
|
||||||
|
h += (value.hue / 100) * 30 * w;
|
||||||
|
s = clamp01(s * (1 + (value.saturation / 100) * w));
|
||||||
|
l = clamp01(l + (value.luminance / 100) * 0.2 * w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h = ((h % 360) + 360) % 360;
|
||||||
|
|
||||||
|
// Зоны яркости: веса в сумме дают 1, более узкие у теней и светов.
|
||||||
|
const lb = clamp01(l + balance * 0.5);
|
||||||
|
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
|
||||||
|
const highlightW = lb * lb * lb * lb;
|
||||||
|
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
|
||||||
|
GRADING_WHEELS.forEach((wheel, index) => {
|
||||||
|
l = clamp01(
|
||||||
|
l + ((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// HSL -> RGB.
|
||||||
|
[rr, gg, bb] = hslToRGB(h, s, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точечный цвет: три колеса по зонам яркости.
|
||||||
|
GRADING_WHEELS.forEach((wheel, index) => {
|
||||||
|
const strength =
|
||||||
|
(color.grading[wheel.key].saturation / 100) * 0.5 * blend * weights[index];
|
||||||
|
if (strength === 0) return;
|
||||||
|
const tintRGB = tints[index];
|
||||||
|
rr = rr * (1 - strength) + tintRGB[0] * strength;
|
||||||
|
gg = gg * (1 - strength) + tintRGB[1] * strength;
|
||||||
|
bb = bb * (1 - strength) + tintRGB[2] * strength;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Цветокоррекция.
|
||||||
|
rr *= 1 + (correction.red / 100) * 0.5;
|
||||||
|
gg *= 1 + (correction.green / 100) * 0.5;
|
||||||
|
bb *= 1 + (correction.blue / 100) * 0.5;
|
||||||
|
|
||||||
|
data[i] = Math.round(clamp(rr, 0, 255));
|
||||||
|
data[i + 1] = Math.round(clamp(gg, 0, 255));
|
||||||
|
data[i + 2] = Math.round(clamp(bb, 0, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export const icons = {
|
|||||||
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
|
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
|
||||||
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
|
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
|
||||||
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
|
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
|
||||||
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' }
|
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
|
||||||
|
visibility: { path: '<path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5m0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10m0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>' },
|
||||||
|
colorize: { path: '<path fill="currentColor" d="M17.66 5.41l.92.92-2.69 2.69-.92-.92zM17.67 3c-.26 0-.51.1-.71.29l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42l-2.34-2.34c-.2-.19-.45-.29-.7-.29M6.92 19L5 17.08l8.06-8.06 1.92 1.92z"/>' },
|
||||||
|
tune: { path: '<path fill="currentColor" d="M3 17v2h6v-2zm0-12v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"/>' },
|
||||||
|
blur_on: { path: '<path fill="currentColor" d="M12 3a5 5 0 0 1 0 10 5 5 0 0 1 0-10m-5 6a5 5 0 1 1 0 10 5 5 0 0 1 0-10m10 0a5 5 0 1 1 0 10 5 5 0 0 1 0-10"/>' },
|
||||||
|
circle: { path: '<path fill="currentColor" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16"/>' },
|
||||||
|
show_chart: { path: '<path fill="currentColor" d="M3 3v18h18v-2H5V3zm16.7 3.3l-1.4-1.4-6.3 6.3-3-3L5 15v-2.8l3.6-3.6 3 3z"/>' }
|
||||||
} satisfies Record<string, IconDef>;
|
} satisfies Record<string, IconDef>;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
|
import {
|
||||||
|
applyColorToPixels,
|
||||||
|
applyLightToPixels,
|
||||||
|
buildCurveLUT,
|
||||||
|
emptyColor,
|
||||||
|
emptyEditParams,
|
||||||
|
GRADING_WHEELS,
|
||||||
|
MIXER_BANDS,
|
||||||
|
type ColorParams,
|
||||||
|
type LightParams
|
||||||
|
} from './edit';
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -15,10 +25,16 @@ interface LightExports {
|
|||||||
whites: number,
|
whites: number,
|
||||||
blacks: number
|
blacks: number
|
||||||
): void;
|
): void;
|
||||||
|
apply_color(data: number, len: number, params: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let wasm: LightExports;
|
let wasm: LightExports;
|
||||||
|
|
||||||
|
const LUTS_OFFSET = 0;
|
||||||
|
const COLOR_PARAMS_OFFSET = 2048;
|
||||||
|
const COLOR_PARAM_COUNT = 42;
|
||||||
|
const PIXELS_OFFSET = 4096;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -27,24 +43,27 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
/** Применяет правки модулем WASM к копии пикселей. */
|
/** Применяет правки модулем WASM к копии пикселей. */
|
||||||
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
||||||
const points = light.curve?.rgb ?? [];
|
|
||||||
const floats = new Float32Array(points.length * 2);
|
|
||||||
points.forEach((p, i) => {
|
|
||||||
floats[i * 2] = p.x;
|
|
||||||
floats[i * 2 + 1] = p.y;
|
|
||||||
});
|
|
||||||
const mem = wasm.memory;
|
const mem = wasm.memory;
|
||||||
const need = Math.ceil((4096 + pixels.length) / 65536);
|
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
|
||||||
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536);
|
const have = mem.buffer.byteLength / 65536;
|
||||||
new Float32Array(mem.buffer, 0, floats.length).set(floats);
|
if (need > have) mem.grow(need - have);
|
||||||
wasm.build_lut(0, points.length, 0);
|
|
||||||
wasm.build_lut(0, 0, 1);
|
const curve = light.curve ?? {};
|
||||||
wasm.build_lut(0, 0, 2);
|
const tables = [
|
||||||
wasm.build_lut(0, 0, 3);
|
buildCurveLUT(curve.rgb),
|
||||||
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels);
|
buildCurveLUT(curve.red),
|
||||||
|
buildCurveLUT(curve.green),
|
||||||
|
buildCurveLUT(curve.blue)
|
||||||
|
];
|
||||||
|
const luts = new Uint8Array(256 * tables.length);
|
||||||
|
tables.forEach((table, i) => luts.set(table, i * 256));
|
||||||
|
new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts);
|
||||||
|
|
||||||
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
|
||||||
wasm.apply(
|
wasm.apply(
|
||||||
4096,
|
PIXELS_OFFSET,
|
||||||
pixels.length,
|
pixels.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -52,7 +71,45 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
|
|||||||
(light.whites / 100) * 80,
|
(light.whites / 100) * 80,
|
||||||
(light.blacks / 100) * 80
|
(light.blacks / 100) * 80
|
||||||
);
|
);
|
||||||
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice();
|
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применяет цветовые правки модулем WASM к копии пикселей. */
|
||||||
|
function applyColorWasm(pixels: Uint8ClampedArray, color: ColorParams): Uint8ClampedArray {
|
||||||
|
const mem = wasm.memory;
|
||||||
|
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
|
||||||
|
const have = mem.buffer.byteLength / 65536;
|
||||||
|
if (need > have) mem.grow(need - have);
|
||||||
|
|
||||||
|
const params = new Float64Array(COLOR_PARAM_COUNT);
|
||||||
|
params[0] = color.temperature / 100;
|
||||||
|
params[1] = color.tint / 100;
|
||||||
|
params[2] = color.vibrance / 100;
|
||||||
|
params[3] = color.saturation / 100;
|
||||||
|
MIXER_BANDS.forEach((band, index) => {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
const base = 4 + index * 3;
|
||||||
|
params[base] = value.hue / 100;
|
||||||
|
params[base + 1] = value.saturation / 100;
|
||||||
|
params[base + 2] = value.luminance / 100;
|
||||||
|
});
|
||||||
|
GRADING_WHEELS.forEach((wheel, index) => {
|
||||||
|
const value = color.grading[wheel.key];
|
||||||
|
const base = 28 + index * 2;
|
||||||
|
params[base] = value.hue;
|
||||||
|
params[base + 1] = value.saturation / 100;
|
||||||
|
params[34 + index] = (value.luminance ?? 0) / 100;
|
||||||
|
});
|
||||||
|
params[37] = color.grading.blending / 100;
|
||||||
|
params[38] = color.grading.balance / 100;
|
||||||
|
params[39] = color.correction.red / 100;
|
||||||
|
params[40] = color.correction.green / 100;
|
||||||
|
params[41] = color.correction.blue / 100;
|
||||||
|
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
|
||||||
|
|
||||||
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
|
||||||
|
wasm.apply_color(PIXELS_OFFSET, pixels.length, COLOR_PARAMS_OFFSET);
|
||||||
|
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WASM-модуль фильтров', () => {
|
describe('WASM-модуль фильтров', () => {
|
||||||
@@ -86,4 +143,170 @@ describe('WASM-модуль фильтров', () => {
|
|||||||
const out = applyWasm(pixels, light);
|
const out = applyWasm(pixels, light);
|
||||||
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('осветляет при положительной экспозиции', () => {
|
||||||
|
const light: LightParams = { ...emptyEditParams().light, exposure: 1 };
|
||||||
|
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
|
||||||
|
expect(out[0]).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с JS на большом градиенте (свет)', () => {
|
||||||
|
const light: LightParams = {
|
||||||
|
...emptyEditParams().light,
|
||||||
|
exposure: 0.4,
|
||||||
|
contrast: 20,
|
||||||
|
highlights: -20,
|
||||||
|
shadows: 30
|
||||||
|
};
|
||||||
|
const width = 256;
|
||||||
|
const height = 256;
|
||||||
|
const source = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
source[i] = 20 + (y % 200);
|
||||||
|
source[i + 1] = 30 + (x % 150);
|
||||||
|
source[i + 2] = 40 + ((x + y) % 120);
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyLightToPixels(expected, light);
|
||||||
|
const actual = applyWasm(source.slice(), light);
|
||||||
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с JS-реализацией цвета', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.temperature = 30;
|
||||||
|
color.tint = -10;
|
||||||
|
color.vibrance = 20;
|
||||||
|
color.saturation = -15;
|
||||||
|
color.mixer.red = { hue: 20, saturation: 30, luminance: -10 };
|
||||||
|
color.mixer.blue = { hue: -15, saturation: -20, luminance: 10 };
|
||||||
|
color.grading.shadows = { hue: 240, saturation: 40, luminance: 10 };
|
||||||
|
color.grading.highlights = { hue: 40, saturation: 20, luminance: -10 };
|
||||||
|
color.grading.blending = 30;
|
||||||
|
color.grading.balance = -20;
|
||||||
|
color.correction = { red: 10, green: -5, blue: 8 };
|
||||||
|
|
||||||
|
const source = new Uint8ClampedArray(4 * 64);
|
||||||
|
for (let i = 0; i < source.length; i += 4) {
|
||||||
|
source[i] = (i * 7) % 256;
|
||||||
|
source[i + 1] = (i * 13) % 256;
|
||||||
|
source[i + 2] = (i * 29) % 256;
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyColorToPixels(expected, color);
|
||||||
|
const actual = applyColorWasm(source.slice(), color);
|
||||||
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не меняет прозрачные пиксели при цветовых правках', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.saturation = 100;
|
||||||
|
color.temperature = 100;
|
||||||
|
const out = applyColorWasm(new Uint8ClampedArray([10, 20, 30, 0]), color);
|
||||||
|
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с JS на большом градиенте', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.temperature = 25;
|
||||||
|
color.vibrance = 15;
|
||||||
|
color.mixer.blue = { hue: -10, saturation: 20, luminance: 10 };
|
||||||
|
color.grading.shadows = { hue: 240, saturation: 80, luminance: 10 };
|
||||||
|
color.grading.highlights = { hue: 40, saturation: 30, luminance: -10 };
|
||||||
|
|
||||||
|
const width = 256;
|
||||||
|
const height = 256;
|
||||||
|
const source = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
source[i] = 20 + (y % 200);
|
||||||
|
source[i + 1] = 30 + (x % 150);
|
||||||
|
source[i + 2] = 40 + ((x + y) % 120);
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyColorToPixels(expected, color);
|
||||||
|
const actual = applyColorWasm(source.slice(), color);
|
||||||
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с JS на быстром пути (только колёса)', () => {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.temperature = 20;
|
||||||
|
color.tint = -10;
|
||||||
|
color.grading.blending = 50;
|
||||||
|
color.grading.balance = -20;
|
||||||
|
color.grading.shadows = { hue: 240, saturation: 70, luminance: 25 };
|
||||||
|
color.grading.midtones = { hue: 40, saturation: 20, luminance: -15 };
|
||||||
|
color.grading.highlights = { hue: 200, saturation: 30, luminance: 10 };
|
||||||
|
|
||||||
|
const width = 128;
|
||||||
|
const height = 128;
|
||||||
|
const source = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
source[i] = 10 + (y % 220);
|
||||||
|
source[i + 1] = 20 + (x % 180);
|
||||||
|
source[i + 2] = 30 + ((x + y) % 160);
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyColorToPixels(expected, color);
|
||||||
|
const actual = applyColorWasm(source.slice(), color);
|
||||||
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('совпадает с JS на случайных цветовых параметрах', () => {
|
||||||
|
for (let n = 0; n < 100; n++) {
|
||||||
|
const color = emptyColor();
|
||||||
|
color.temperature = Math.random() * 200 - 100;
|
||||||
|
color.tint = Math.random() * 200 - 100;
|
||||||
|
color.vibrance = Math.random() * 200 - 100;
|
||||||
|
color.saturation = Math.random() * 200 - 100;
|
||||||
|
color.grading.blending = Math.random() * 100;
|
||||||
|
color.grading.balance = Math.random() * 200 - 100;
|
||||||
|
color.grading.shadows = {
|
||||||
|
hue: Math.random() * 360,
|
||||||
|
saturation: Math.random() * 200 - 100,
|
||||||
|
luminance: Math.random() * 200 - 100
|
||||||
|
};
|
||||||
|
color.grading.midtones = {
|
||||||
|
hue: Math.random() * 360,
|
||||||
|
saturation: Math.random() * 200 - 100,
|
||||||
|
luminance: Math.random() * 200 - 100
|
||||||
|
};
|
||||||
|
color.grading.highlights = {
|
||||||
|
hue: Math.random() * 360,
|
||||||
|
saturation: Math.random() * 200 - 100,
|
||||||
|
luminance: Math.random() * 200 - 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = new Uint8ClampedArray(64);
|
||||||
|
for (let i = 0; i < source.length; i += 4) {
|
||||||
|
source[i] = (i * 7) % 256;
|
||||||
|
source[i + 1] = (i * 13) % 256;
|
||||||
|
source[i + 2] = (i * 29) % 256;
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyColorToPixels(expected, color);
|
||||||
|
const actual = applyColorWasm(source.slice(), color);
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (expected[i] !== actual[i]) {
|
||||||
|
throw new Error(
|
||||||
|
`mismatch n=${n} i=${i} expected=${expected[i]} actual=${actual[i]} color=${JSON.stringify(color)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+132
-25
@@ -1,23 +1,35 @@
|
|||||||
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
|
import {
|
||||||
|
applyColorToPixels,
|
||||||
|
applyLightToPixels,
|
||||||
|
buildCurveLUT,
|
||||||
|
GRADING_WHEELS,
|
||||||
|
MIXER_BANDS,
|
||||||
|
type ColorParams,
|
||||||
|
type LightParams
|
||||||
|
} from './edit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WASM-модуль фильтров «Свет».
|
* WASM-модуль фильтров «Свет» и «Цвет».
|
||||||
*
|
*
|
||||||
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
||||||
* Он считает правки по пикселям — это горячий путь предпросмотра в
|
* Он попиксельно применяет правки — это горячий путь предпросмотра в
|
||||||
* просмотрщике. Если модуль недоступен, используется JS-реализация.
|
* просмотрщике. Если модуль недоступен или считает иначе, чем эталонная
|
||||||
|
* JS-реализация, используется JS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const POINTS_OFFSET = 0;
|
const LUTS_OFFSET = 0;
|
||||||
|
const COLOR_PARAMS_OFFSET = 2048;
|
||||||
|
const COLOR_PARAM_COUNT = 42;
|
||||||
const PIXELS_OFFSET = 4096;
|
const PIXELS_OFFSET = 4096;
|
||||||
const PAGE = 65536;
|
const PAGE = 65536;
|
||||||
|
const LUT_LEN = 256;
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -25,6 +37,7 @@ interface LightExports {
|
|||||||
whites: number,
|
whites: number,
|
||||||
blacks: number
|
blacks: number
|
||||||
): void;
|
): void;
|
||||||
|
apply_color(data: number, len: number, params: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let exportsRef: LightExports | null = null;
|
let exportsRef: LightExports | null = null;
|
||||||
@@ -36,7 +49,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
|
|||||||
if (!loading) {
|
if (!loading) {
|
||||||
loading = (async () => {
|
loading = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, { cache: 'no-store' });
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const bytes = await res.arrayBuffer();
|
const bytes = await res.arrayBuffer();
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -67,17 +80,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
|
|||||||
return mem;
|
return mem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */
|
/** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
|
||||||
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void {
|
function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
|
||||||
const ex = exportsRef;
|
const curve = light.curve ?? {};
|
||||||
if (!ex) return;
|
const tables = [
|
||||||
const floats = new Float32Array(points.length * 2);
|
buildCurveLUT(curve.rgb),
|
||||||
for (let i = 0; i < points.length; i++) {
|
buildCurveLUT(curve.red),
|
||||||
floats[i * 2] = points[i].x;
|
buildCurveLUT(curve.green),
|
||||||
floats[i * 2 + 1] = points[i].y;
|
buildCurveLUT(curve.blue)
|
||||||
}
|
];
|
||||||
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats);
|
const bytes = new Uint8Array(LUT_LEN * tables.length);
|
||||||
ex.build_lut(POINTS_OFFSET, points.length, channel);
|
tables.forEach((table, i) => bytes.set(table, i * LUT_LEN));
|
||||||
|
new Uint8Array(mem.buffer, LUTS_OFFSET, bytes.length).set(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,16 +105,12 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
|
|||||||
const mem = ensureMemory(bytes);
|
const mem = ensureMemory(bytes);
|
||||||
if (!mem) return false;
|
if (!mem) return false;
|
||||||
|
|
||||||
const curve = light.curve ?? {};
|
writeLuts(mem, light);
|
||||||
buildLut(mem, curve.rgb ?? [], 0);
|
|
||||||
buildLut(mem, curve.red ?? [], 1);
|
|
||||||
buildLut(mem, curve.green ?? [], 2);
|
|
||||||
buildLut(mem, curve.blue ?? [], 3);
|
|
||||||
|
|
||||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||||
ex.apply(
|
ex.apply(
|
||||||
PIXELS_OFFSET,
|
PIXELS_OFFSET,
|
||||||
data.length,
|
data.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -112,8 +122,105 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сверяет модуль с эталонной JS-реализацией на маленьком образце.
|
||||||
|
*
|
||||||
|
* Модуль и JS должны считать одинаково; если модуль расходится (другая
|
||||||
|
* сборка, битая версия в кэше) — доверяем JS, чтобы предпросмотр не был
|
||||||
|
* испорчен.
|
||||||
|
*/
|
||||||
|
function wasmMatchesReference(light: LightParams): boolean {
|
||||||
|
const probe = new Uint8ClampedArray(16 * 4);
|
||||||
|
for (let i = 0; i < probe.length; i += 4) {
|
||||||
|
probe[i] = (i * 7) % 256;
|
||||||
|
probe[i + 1] = (i * 13) % 256;
|
||||||
|
probe[i + 2] = (i * 29) % 256;
|
||||||
|
probe[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = new Uint8ClampedArray(probe);
|
||||||
|
applyLightToPixels(expected, light);
|
||||||
|
const actual = new Uint8ClampedArray(probe);
|
||||||
|
if (!applyLightWasm(actual, light)) return false;
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (expected[i] !== actual[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
||||||
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||||
if (applyLightWasm(data, light)) return;
|
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
|
||||||
applyLightToPixels(data, light);
|
applyLightToPixels(data, light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Пишет f64-блок цветовых параметров в память модуля. */
|
||||||
|
function writeColorParams(mem: WebAssembly.Memory, color: ColorParams): void {
|
||||||
|
const params = new Float64Array(COLOR_PARAM_COUNT);
|
||||||
|
params[0] = color.temperature / 100;
|
||||||
|
params[1] = color.tint / 100;
|
||||||
|
params[2] = color.vibrance / 100;
|
||||||
|
params[3] = color.saturation / 100;
|
||||||
|
MIXER_BANDS.forEach((band, index) => {
|
||||||
|
const value = color.mixer[band.key];
|
||||||
|
const base = 4 + index * 3;
|
||||||
|
params[base] = value.hue / 100;
|
||||||
|
params[base + 1] = value.saturation / 100;
|
||||||
|
params[base + 2] = value.luminance / 100;
|
||||||
|
});
|
||||||
|
GRADING_WHEELS.forEach((wheel, index) => {
|
||||||
|
const value = color.grading[wheel.key];
|
||||||
|
const base = 28 + index * 2;
|
||||||
|
params[base] = value.hue;
|
||||||
|
params[base + 1] = value.saturation / 100;
|
||||||
|
params[34 + index] = (value.luminance ?? 0) / 100;
|
||||||
|
});
|
||||||
|
params[37] = color.grading.blending / 100;
|
||||||
|
params[38] = color.grading.balance / 100;
|
||||||
|
params[39] = color.correction.red / 100;
|
||||||
|
params[40] = color.correction.green / 100;
|
||||||
|
params[41] = color.correction.blue / 100;
|
||||||
|
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет цветовые правки к пикселям. Возвращает false, если модуль не
|
||||||
|
* готов и вызывающий код должен использовать JS-реализацию.
|
||||||
|
*/
|
||||||
|
export function applyColorWasm(data: Uint8ClampedArray, color: ColorParams): boolean {
|
||||||
|
const ex = exportsRef;
|
||||||
|
if (!ex) return false;
|
||||||
|
const bytes = PIXELS_OFFSET + data.length;
|
||||||
|
const mem = ensureMemory(bytes);
|
||||||
|
if (!mem) return false;
|
||||||
|
|
||||||
|
writeColorParams(mem, color);
|
||||||
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||||
|
ex.apply_color(PIXELS_OFFSET, data.length, COLOR_PARAMS_OFFSET);
|
||||||
|
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сверяет цветовой модуль с эталонной JS-реализацией на образце. */
|
||||||
|
function colorWasmMatchesReference(color: ColorParams): boolean {
|
||||||
|
const probe = new Uint8ClampedArray(16 * 4);
|
||||||
|
for (let i = 0; i < probe.length; i += 4) {
|
||||||
|
probe[i] = (i * 7) % 256;
|
||||||
|
probe[i + 1] = (i * 13) % 256;
|
||||||
|
probe[i + 2] = (i * 29) % 256;
|
||||||
|
probe[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = new Uint8ClampedArray(probe);
|
||||||
|
applyColorToPixels(expected, color);
|
||||||
|
const actual = new Uint8ClampedArray(probe);
|
||||||
|
if (!applyColorWasm(actual, color)) return false;
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (expected[i] !== actual[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применяет цвет: сначала WASM, при его недоступности — JS. */
|
||||||
|
export function applyColor(data: Uint8ClampedArray, color: ColorParams): void {
|
||||||
|
if (colorWasmMatchesReference(color) && applyColorWasm(data, color)) return;
|
||||||
|
applyColorToPixels(data, color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Режим пипетки: панель редактирования включает его, а просмотрщик сообщает
|
||||||
|
* цвет пикселя, по которому кликнули.
|
||||||
|
*/
|
||||||
|
export type EyedropperMode = 'none' | 'wb';
|
||||||
|
|
||||||
|
export interface EyedropperSample {
|
||||||
|
photoId: string;
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EyedropperStore {
|
||||||
|
mode = $state<EyedropperMode>('none');
|
||||||
|
photoId = $state('');
|
||||||
|
sample = $state<EyedropperSample | null>(null);
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
start(mode: EyedropperMode, photoId: string) {
|
||||||
|
this.mode = mode;
|
||||||
|
this.photoId = photoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.mode = 'none';
|
||||||
|
this.photoId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
report(photoId: string, r: number, g: number, b: number) {
|
||||||
|
if (this.mode === 'none' || this.photoId !== photoId) return;
|
||||||
|
this.seq += 1;
|
||||||
|
this.sample = { photoId, r, g, b, seq: this.seq };
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eyedropper = new EyedropperStore();
|
||||||
@@ -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,470 @@
|
|||||||
|
import {
|
||||||
|
buildCurveLUT,
|
||||||
|
GRADING_WHEELS,
|
||||||
|
hslToRGB,
|
||||||
|
MIXER_BANDS,
|
||||||
|
type ColorParams,
|
||||||
|
type LightParams
|
||||||
|
} from './edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPU-превью правок на WebGL2.
|
||||||
|
*
|
||||||
|
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст,
|
||||||
|
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL,
|
||||||
|
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра,
|
||||||
|
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERT = `#version 300 es
|
||||||
|
in vec2 a_pos;
|
||||||
|
uniform vec2 u_uvOffset;
|
||||||
|
uniform vec2 u_uvScale;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
vec2 uv = a_pos * 0.5 + 0.5;
|
||||||
|
v_uv = u_uvOffset + uv * u_uvScale;
|
||||||
|
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const FRAG = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 outColor;
|
||||||
|
|
||||||
|
uniform sampler2D u_image;
|
||||||
|
uniform sampler2D u_luts;
|
||||||
|
|
||||||
|
uniform float u_exposureMul;
|
||||||
|
uniform float u_contrastK;
|
||||||
|
uniform float u_highlights;
|
||||||
|
uniform float u_shadows;
|
||||||
|
uniform float u_whites;
|
||||||
|
uniform float u_blacks;
|
||||||
|
|
||||||
|
uniform float u_temperature;
|
||||||
|
uniform float u_tint;
|
||||||
|
uniform float u_vibrance;
|
||||||
|
uniform float u_saturation;
|
||||||
|
uniform float u_blending;
|
||||||
|
uniform float u_balance;
|
||||||
|
uniform vec3 u_correction;
|
||||||
|
|
||||||
|
uniform float u_mixerHue[8];
|
||||||
|
uniform float u_mixerSat[8];
|
||||||
|
uniform float u_mixerLum[8];
|
||||||
|
uniform float u_gradeSat[3];
|
||||||
|
uniform float u_gradeLum[3];
|
||||||
|
uniform vec3 u_gradeTint[3];
|
||||||
|
|
||||||
|
const float CENTERS[8] = float[8](0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0);
|
||||||
|
|
||||||
|
vec4 lutAt(float v) {
|
||||||
|
float idx = clamp(floor(v + 0.5), 0.0, 255.0);
|
||||||
|
return texture(u_luts, vec2((idx + 0.5) / 256.0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
float lightChannel(float v, int ch) {
|
||||||
|
v *= u_exposureMul;
|
||||||
|
v = 128.0 + (v - 128.0) * u_contrastK;
|
||||||
|
float w = clamp((v - 128.0) / 127.0, 0.0, 1.0);
|
||||||
|
v += u_highlights * w * w;
|
||||||
|
w = clamp((128.0 - v) / 128.0, 0.0, 1.0);
|
||||||
|
v += u_shadows * w * w;
|
||||||
|
w = clamp((v - 192.0) / 63.0, 0.0, 1.0);
|
||||||
|
v += u_whites * w;
|
||||||
|
w = clamp((64.0 - v) / 64.0, 0.0, 1.0);
|
||||||
|
v += u_blacks * w;
|
||||||
|
vec4 l1 = lutAt(v);
|
||||||
|
float master = l1.r * 255.0;
|
||||||
|
vec4 l2 = lutAt(master);
|
||||||
|
float value = ch == 0 ? l2.g : (ch == 1 ? l2.b : l2.a);
|
||||||
|
return value * 255.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 rgb2hsl(vec3 c) {
|
||||||
|
float maxc = max(c.r, max(c.g, c.b));
|
||||||
|
float minc = min(c.r, min(c.g, c.b));
|
||||||
|
float l = (maxc + minc) * 0.5;
|
||||||
|
if (maxc == minc) return vec3(0.0, 0.0, l);
|
||||||
|
float d = maxc - minc;
|
||||||
|
float s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
|
||||||
|
float h;
|
||||||
|
if (maxc == c.r) {
|
||||||
|
h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||||
|
} else if (maxc == c.g) {
|
||||||
|
h = (c.b - c.r) / d + 2.0;
|
||||||
|
} else {
|
||||||
|
h = (c.r - c.g) / d + 4.0;
|
||||||
|
}
|
||||||
|
return vec3(h * 60.0, s, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
float hue2rgb(float p, float q, float t) {
|
||||||
|
if (t < 0.0) t += 1.0;
|
||||||
|
if (t > 1.0) t -= 1.0;
|
||||||
|
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||||
|
if (t < 1.0 / 2.0) return q;
|
||||||
|
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 hsl2rgb(vec3 hsl) {
|
||||||
|
float h = hsl.x / 360.0;
|
||||||
|
float s = hsl.y;
|
||||||
|
float l = hsl.z;
|
||||||
|
if (s == 0.0) return vec3(l);
|
||||||
|
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||||
|
float p = 2.0 * l - q;
|
||||||
|
return vec3(
|
||||||
|
hue2rgb(p, q, h + 1.0 / 3.0),
|
||||||
|
hue2rgb(p, q, h),
|
||||||
|
hue2rgb(p, q, h - 1.0 / 3.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
float bandWeight(float h, float center) {
|
||||||
|
float d = abs(h - center);
|
||||||
|
if (d > 180.0) d = 360.0 - d;
|
||||||
|
return clamp(1.0 - d / 60.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 tex = texture(u_image, v_uv);
|
||||||
|
if (tex.a == 0.0) {
|
||||||
|
outColor = vec4(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 c = tex.rgb * 255.0;
|
||||||
|
c.r = lightChannel(c.r, 0);
|
||||||
|
c.g = lightChannel(c.g, 1);
|
||||||
|
c.b = lightChannel(c.b, 2);
|
||||||
|
|
||||||
|
// Баланс белого.
|
||||||
|
c.r *= 1.0 + 0.2 * u_temperature + 0.1 * u_tint;
|
||||||
|
c.g *= 1.0 - 0.2 * u_tint;
|
||||||
|
c.b *= 1.0 - 0.2 * u_temperature + 0.1 * u_tint;
|
||||||
|
c = clamp(c, 0.0, 255.0);
|
||||||
|
|
||||||
|
vec3 hsl = rgb2hsl(c / 255.0);
|
||||||
|
float h = hsl.x;
|
||||||
|
float s = hsl.y;
|
||||||
|
float l = hsl.z;
|
||||||
|
|
||||||
|
if (u_saturation != 0.0 || u_vibrance != 0.0) {
|
||||||
|
s = clamp(s * (1.0 + u_saturation + u_vibrance * (1.0 - s)), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
float w = bandWeight(h, CENTERS[i]);
|
||||||
|
if (w > 0.0) {
|
||||||
|
h += u_mixerHue[i] * 30.0 * w;
|
||||||
|
s = clamp(s * (1.0 + u_mixerSat[i] * w), 0.0, 1.0);
|
||||||
|
l = clamp(l + u_mixerLum[i] * 0.2 * w, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h = mod(h, 360.0);
|
||||||
|
if (h < 0.0) h += 360.0;
|
||||||
|
|
||||||
|
float lb = clamp(l + u_balance * 0.5, 0.0, 1.0);
|
||||||
|
float sw = (1.0 - lb) * (1.0 - lb);
|
||||||
|
sw *= sw;
|
||||||
|
float hw = lb * lb;
|
||||||
|
hw *= hw;
|
||||||
|
float weights[3] = float[3](sw, 1.0 - sw - hw, hw);
|
||||||
|
|
||||||
|
l = clamp(
|
||||||
|
l +
|
||||||
|
u_gradeLum[0] * 0.2 * weights[0] +
|
||||||
|
u_gradeLum[1] * 0.2 * weights[1] +
|
||||||
|
u_gradeLum[2] * 0.2 * weights[2],
|
||||||
|
0.0, 1.0);
|
||||||
|
|
||||||
|
vec3 outc = hsl2rgb(vec3(h, s, l)) * 255.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
float strength = u_gradeSat[i] * 0.5 * u_blending * weights[i];
|
||||||
|
if (strength != 0.0) {
|
||||||
|
outc = outc * (1.0 - strength) + u_gradeTint[i] * strength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outc *= 1.0 + u_correction * 0.5;
|
||||||
|
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export interface PreviewRenderOptions {
|
||||||
|
image: TexImageSource;
|
||||||
|
imageVersion: string;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
crop: { x: number; y: number; width: number; height: number };
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
dest: { x: number; y: number; width: number; height: number };
|
||||||
|
light: LightParams;
|
||||||
|
color: ColorParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
if (!shader) return null;
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.error('webgl preview shader:', gl.getShaderInfoLog(shader));
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLuts(light: LightParams): Uint8Array {
|
||||||
|
const master = buildCurveLUT(light.curve?.rgb);
|
||||||
|
const red = buildCurveLUT(light.curve?.red);
|
||||||
|
const green = buildCurveLUT(light.curve?.green);
|
||||||
|
const blue = buildCurveLUT(light.curve?.blue);
|
||||||
|
const out = new Uint8Array(256 * 4);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
out[i * 4] = master[i];
|
||||||
|
out[i * 4 + 1] = red[i];
|
||||||
|
out[i * 4 + 2] = green[i];
|
||||||
|
out[i * 4 + 3] = blue[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebGLPreview {
|
||||||
|
private readonly gl: WebGL2RenderingContext;
|
||||||
|
private readonly canvas: HTMLCanvasElement;
|
||||||
|
private readonly program: WebGLProgram;
|
||||||
|
private readonly vao: WebGLVertexArrayObject;
|
||||||
|
private readonly buffer: WebGLBuffer;
|
||||||
|
private readonly imageTex: WebGLTexture;
|
||||||
|
private readonly lutTex: WebGLTexture;
|
||||||
|
private readonly u: Record<string, WebGLUniformLocation | null>;
|
||||||
|
private imageVersion = '';
|
||||||
|
|
||||||
|
private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.gl = gl;
|
||||||
|
|
||||||
|
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||||
|
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
||||||
|
if (!vs || !fs) throw new Error('shader compile failed');
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) throw new Error('program create failed');
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
gl.deleteShader(vs);
|
||||||
|
gl.deleteShader(fs);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
const log = gl.getProgramInfoLog(program);
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(`program link failed: ${log ?? ''}`);
|
||||||
|
}
|
||||||
|
this.program = program;
|
||||||
|
|
||||||
|
const vao = gl.createVertexArray();
|
||||||
|
const buffer = gl.createBuffer();
|
||||||
|
if (!vao || !buffer) throw new Error('buffer create failed');
|
||||||
|
gl.bindVertexArray(vao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||||
|
gl.bufferData(
|
||||||
|
gl.ARRAY_BUFFER,
|
||||||
|
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||||
|
gl.STATIC_DRAW
|
||||||
|
);
|
||||||
|
const aPos = gl.getAttribLocation(program, 'a_pos');
|
||||||
|
gl.enableVertexAttribArray(aPos);
|
||||||
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.bindVertexArray(null);
|
||||||
|
this.vao = vao;
|
||||||
|
this.buffer = buffer;
|
||||||
|
|
||||||
|
const imageTex = gl.createTexture();
|
||||||
|
const lutTex = gl.createTexture();
|
||||||
|
if (!imageTex || !lutTex) throw new Error('texture create failed');
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, lutTex);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
this.imageTex = imageTex;
|
||||||
|
this.lutTex = lutTex;
|
||||||
|
|
||||||
|
const loc = (name: string) => gl.getUniformLocation(program, name);
|
||||||
|
this.u = {
|
||||||
|
u_image: loc('u_image'),
|
||||||
|
u_luts: loc('u_luts'),
|
||||||
|
u_uvOffset: loc('u_uvOffset'),
|
||||||
|
u_uvScale: loc('u_uvScale'),
|
||||||
|
u_exposureMul: loc('u_exposureMul'),
|
||||||
|
u_contrastK: loc('u_contrastK'),
|
||||||
|
u_highlights: loc('u_highlights'),
|
||||||
|
u_shadows: loc('u_shadows'),
|
||||||
|
u_whites: loc('u_whites'),
|
||||||
|
u_blacks: loc('u_blacks'),
|
||||||
|
u_temperature: loc('u_temperature'),
|
||||||
|
u_tint: loc('u_tint'),
|
||||||
|
u_vibrance: loc('u_vibrance'),
|
||||||
|
u_saturation: loc('u_saturation'),
|
||||||
|
u_blending: loc('u_blending'),
|
||||||
|
u_balance: loc('u_balance'),
|
||||||
|
u_correction: loc('u_correction'),
|
||||||
|
u_mixerHue: loc('u_mixerHue[0]'),
|
||||||
|
u_mixerSat: loc('u_mixerSat[0]'),
|
||||||
|
u_mixerLum: loc('u_mixerLum[0]'),
|
||||||
|
u_gradeSat: loc('u_gradeSat[0]'),
|
||||||
|
u_gradeLum: loc('u_gradeLum[0]'),
|
||||||
|
u_gradeTint: loc('u_gradeTint[0]')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(canvas: HTMLCanvasElement): WebGLPreview | null {
|
||||||
|
const gl = canvas.getContext('webgl2', {
|
||||||
|
alpha: true,
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
antialias: false,
|
||||||
|
depth: false,
|
||||||
|
stencil: false,
|
||||||
|
preserveDrawingBuffer: false
|
||||||
|
});
|
||||||
|
if (!gl) return null;
|
||||||
|
try {
|
||||||
|
return new WebGLPreview(canvas, gl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('webgl preview init:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.deleteTexture(this.imageTex);
|
||||||
|
gl.deleteTexture(this.lutTex);
|
||||||
|
gl.deleteBuffer(this.buffer);
|
||||||
|
gl.deleteVertexArray(this.vao);
|
||||||
|
gl.deleteProgram(this.program);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(opts: PreviewRenderOptions): boolean {
|
||||||
|
const gl = this.gl;
|
||||||
|
const { width, height } = opts.viewport;
|
||||||
|
if (!width || !height) return false;
|
||||||
|
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
this.canvas.style.width = `${width}px`;
|
||||||
|
this.canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
|
gl.viewport(0, 0, width, height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
gl.useProgram(this.program);
|
||||||
|
|
||||||
|
if (this.imageVersion !== opts.imageVersion) {
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, opts.image);
|
||||||
|
this.imageVersion = opts.imageVersion;
|
||||||
|
}
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
|
||||||
|
gl.uniform1i(this.u.u_image, 0);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
|
||||||
|
gl.texImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
256,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
buildLuts(opts.light)
|
||||||
|
);
|
||||||
|
gl.uniform1i(this.u.u_luts, 1);
|
||||||
|
|
||||||
|
const light = opts.light;
|
||||||
|
gl.uniform1f(this.u.u_exposureMul, Math.pow(2, light.exposure));
|
||||||
|
gl.uniform1f(this.u.u_contrastK, 1 + light.contrast / 100);
|
||||||
|
gl.uniform1f(this.u.u_highlights, (light.highlights / 100) * 80);
|
||||||
|
gl.uniform1f(this.u.u_shadows, (light.shadows / 100) * 80);
|
||||||
|
gl.uniform1f(this.u.u_whites, (light.whites / 100) * 80);
|
||||||
|
gl.uniform1f(this.u.u_blacks, (light.blacks / 100) * 80);
|
||||||
|
|
||||||
|
const color = opts.color;
|
||||||
|
gl.uniform1f(this.u.u_temperature, color.temperature / 100);
|
||||||
|
gl.uniform1f(this.u.u_tint, color.tint / 100);
|
||||||
|
gl.uniform1f(this.u.u_vibrance, color.vibrance / 100);
|
||||||
|
gl.uniform1f(this.u.u_saturation, color.saturation / 100);
|
||||||
|
gl.uniform1f(this.u.u_blending, color.grading.blending / 100);
|
||||||
|
gl.uniform1f(this.u.u_balance, color.grading.balance / 100);
|
||||||
|
gl.uniform3f(
|
||||||
|
this.u.u_correction,
|
||||||
|
color.correction.red / 100,
|
||||||
|
color.correction.green / 100,
|
||||||
|
color.correction.blue / 100
|
||||||
|
);
|
||||||
|
|
||||||
|
const hue = new Float32Array(8);
|
||||||
|
const sat = new Float32Array(8);
|
||||||
|
const lum = new Float32Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const band = color.mixer[MIXER_BANDS[i].key];
|
||||||
|
hue[i] = band.hue / 100;
|
||||||
|
sat[i] = band.saturation / 100;
|
||||||
|
lum[i] = band.luminance / 100;
|
||||||
|
}
|
||||||
|
gl.uniform1fv(this.u.u_mixerHue, hue);
|
||||||
|
gl.uniform1fv(this.u.u_mixerSat, sat);
|
||||||
|
gl.uniform1fv(this.u.u_mixerLum, lum);
|
||||||
|
|
||||||
|
const gradeSat = new Float32Array(3);
|
||||||
|
const gradeLum = new Float32Array(3);
|
||||||
|
const gradeTint = new Float32Array(9);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const wheel = color.grading[GRADING_WHEELS[i].key];
|
||||||
|
gradeSat[i] = wheel.saturation / 100;
|
||||||
|
gradeLum[i] = wheel.luminance / 100;
|
||||||
|
const [r, g, b] = hslToRGB(wheel.hue, 1, 0.5);
|
||||||
|
gradeTint[i * 3] = r;
|
||||||
|
gradeTint[i * 3 + 1] = g;
|
||||||
|
gradeTint[i * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
gl.uniform1fv(this.u.u_gradeSat, gradeSat);
|
||||||
|
gl.uniform1fv(this.u.u_gradeLum, gradeLum);
|
||||||
|
gl.uniform3fv(this.u.u_gradeTint, gradeTint);
|
||||||
|
|
||||||
|
const u0 = opts.crop.x / opts.imageWidth;
|
||||||
|
const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth;
|
||||||
|
const vTop = opts.crop.y / opts.imageHeight;
|
||||||
|
const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight;
|
||||||
|
gl.uniform2f(this.u.u_uvOffset, u0, 1 - vBottom);
|
||||||
|
gl.uniform2f(this.u.u_uvScale, u1 - u0, vBottom - vTop);
|
||||||
|
|
||||||
|
gl.viewport(
|
||||||
|
Math.round(opts.dest.x),
|
||||||
|
Math.round(height - opts.dest.y - opts.dest.height),
|
||||||
|
Math.max(1, Math.round(opts.dest.width)),
|
||||||
|
Math.max(1, Math.round(opts.dest.height))
|
||||||
|
);
|
||||||
|
gl.bindVertexArray(this.vao);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
gl.bindVertexArray(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
|
|||||||
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
||||||
async function handleAsset(request: Request): Promise<Response> {
|
async function handleAsset(request: Request): Promise<Response> {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
// wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы
|
||||||
|
// предпросмотр правок, поэтому его всегда берём из сети.
|
||||||
|
if (new URL(request.url).pathname.endsWith('.wasm')) {
|
||||||
|
try {
|
||||||
|
const fresh = await fetch(request, { cache: 'no-store' });
|
||||||
|
if (fresh.ok) await cache.put(request, fresh.clone());
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
const stale = await cache.match(request);
|
||||||
|
if (stale) return stale;
|
||||||
|
}
|
||||||
|
}
|
||||||
const cached = await cache.match(request);
|
const cached = await cache.match(request);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
|
|||||||
Binary file not shown.
+296
-95
@@ -1,8 +1,12 @@
|
|||||||
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка.
|
//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
|
||||||
//!
|
//!
|
||||||
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
||||||
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
||||||
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go).
|
//! рендера.
|
||||||
|
//!
|
||||||
|
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
|
||||||
|
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
|
||||||
|
//! функции, поэтому работает без математической библиотеки.
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use core::panic::PanicInfo;
|
use core::panic::PanicInfo;
|
||||||
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
|
|||||||
loop {}
|
loop {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
||||||
const CHANNELS: usize = 4;
|
const CHANNELS: usize = 4;
|
||||||
|
|
||||||
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS];
|
/// Длина таблицы соответствия одного канала.
|
||||||
|
const LUT_LEN: usize = 256;
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
fn clamp01(v: f32) -> f32 {
|
fn clamp01(v: f32) -> f32 {
|
||||||
@@ -39,57 +44,16 @@ fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Строит таблицу соответствия 0..255 по точкам кривой.
|
|
||||||
///
|
|
||||||
/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x.
|
|
||||||
/// Точки вне диапазона обрезаются, дубликаты по x пропускаются.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) {
|
|
||||||
unsafe {
|
|
||||||
let luts = &mut *core::ptr::addr_of_mut!(LUTS);
|
|
||||||
let ch = (channel as usize).min(CHANNELS - 1);
|
|
||||||
let lut = &mut luts[ch];
|
|
||||||
if count < 2 {
|
|
||||||
for (i, slot) in lut.iter_mut().enumerate() {
|
|
||||||
*slot = i as u8;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let n = count as usize;
|
|
||||||
let mut seg = 0usize;
|
|
||||||
for v in 0..256 {
|
|
||||||
let x = v as f32 / 255.0;
|
|
||||||
while seg + 1 < n && x > *points.add((seg + 1) * 2) {
|
|
||||||
seg += 1;
|
|
||||||
}
|
|
||||||
let x0 = *points.add(seg * 2);
|
|
||||||
let y0 = *points.add(seg * 2 + 1);
|
|
||||||
let x1 = *points.add((seg + 1) * 2);
|
|
||||||
let y1 = *points.add((seg + 1) * 2 + 1);
|
|
||||||
let y = if x <= x0 {
|
|
||||||
y0
|
|
||||||
} else if x >= x1 {
|
|
||||||
y1
|
|
||||||
} else if x1 - x0 < 1e-6 {
|
|
||||||
y1
|
|
||||||
} else {
|
|
||||||
let t = (x - x0) / (x1 - x0);
|
|
||||||
y0 + t * (y1 - y0)
|
|
||||||
};
|
|
||||||
lut[v] = (clamp01(y) * 255.0 + 0.5) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
||||||
///
|
///
|
||||||
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental
|
/// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
|
||||||
/// функции, поэтому работает без математической библиотеки.
|
/// синий), подготовленные вызывающим кодом.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "C" fn apply(
|
pub extern "C" fn apply(
|
||||||
data: *mut u8,
|
data: *mut u8,
|
||||||
len: u32,
|
len: u32,
|
||||||
|
luts: *const u8,
|
||||||
exposure_mul: f32,
|
exposure_mul: f32,
|
||||||
contrast_k: f32,
|
contrast_k: f32,
|
||||||
highlights: f32,
|
highlights: f32,
|
||||||
@@ -97,52 +61,20 @@ pub extern "C" fn apply(
|
|||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
) {
|
) {
|
||||||
unsafe {
|
let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
|
||||||
let luts = &*core::ptr::addr_of!(LUTS);
|
let master = &tables[0..LUT_LEN];
|
||||||
let master = &luts[0];
|
let red = &tables[LUT_LEN..2 * LUT_LEN];
|
||||||
let red = &luts[1];
|
let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
|
||||||
let green = &luts[2];
|
let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
|
||||||
let blue = &luts[3];
|
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
||||||
let buf = core::slice::from_raw_parts_mut(data, len as usize);
|
let mut i = 0usize;
|
||||||
let mut i = 0usize;
|
while i + 3 < buf.len() {
|
||||||
while i + 3 < buf.len() {
|
if buf[i + 3] != 0 {
|
||||||
if buf[i + 3] != 0 {
|
buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red);
|
||||||
buf[i] = adjust(
|
buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green);
|
||||||
buf[i] as f32,
|
buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue);
|
||||||
exposure_mul,
|
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
red,
|
|
||||||
);
|
|
||||||
buf[i + 1] = adjust(
|
|
||||||
buf[i + 1] as f32,
|
|
||||||
exposure_mul,
|
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
green,
|
|
||||||
);
|
|
||||||
buf[i + 2] = adjust(
|
|
||||||
buf[i + 2] as f32,
|
|
||||||
exposure_mul,
|
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
blue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
i += 4;
|
|
||||||
}
|
}
|
||||||
|
i += 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +90,8 @@ fn adjust(
|
|||||||
shadows: f32,
|
shadows: f32,
|
||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
master: &[u8; 256],
|
master: &[u8],
|
||||||
channel: &[u8; 256],
|
channel: &[u8],
|
||||||
) -> u8 {
|
) -> u8 {
|
||||||
let mut v = value * exposure_mul;
|
let mut v = value * exposure_mul;
|
||||||
v = 128.0 + (v - 128.0) * contrast_k;
|
v = 128.0 + (v - 128.0) * contrast_k;
|
||||||
@@ -176,3 +108,272 @@ fn adjust(
|
|||||||
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
||||||
channel[idx]
|
channel[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Количество f64-параметров цвета.
|
||||||
|
const COLOR_PARAM_COUNT: usize = 42;
|
||||||
|
|
||||||
|
/// Центры полос цветового микшера в градусах.
|
||||||
|
const BAND_CENTERS: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0];
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn clampf(v: f64, lo: f64, hi: f64) -> f64 {
|
||||||
|
if v < lo {
|
||||||
|
lo
|
||||||
|
} else if v > hi {
|
||||||
|
hi
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn clamp01d(v: f64) -> f64 {
|
||||||
|
clampf(v, 0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn to_u8(v: f64) -> u8 {
|
||||||
|
(clampf(v, 0.0, 255.0) + 0.5) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1.
|
||||||
|
fn rgb_to_hsl(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||||
|
let r = r / 255.0;
|
||||||
|
let g = g / 255.0;
|
||||||
|
let b = b / 255.0;
|
||||||
|
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
||||||
|
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
||||||
|
let l = (max + min) / 2.0;
|
||||||
|
if max == min {
|
||||||
|
return (0.0, 0.0, l);
|
||||||
|
}
|
||||||
|
let d = max - min;
|
||||||
|
let s = if l > 0.5 {
|
||||||
|
d / (2.0 - max - min)
|
||||||
|
} else {
|
||||||
|
d / (max + min)
|
||||||
|
};
|
||||||
|
let mut h;
|
||||||
|
if max == r {
|
||||||
|
h = (g - b) / d;
|
||||||
|
if g < b {
|
||||||
|
h += 6.0;
|
||||||
|
}
|
||||||
|
} else if max == g {
|
||||||
|
h = (b - r) / d + 2.0;
|
||||||
|
} else {
|
||||||
|
h = (r - g) / d + 4.0;
|
||||||
|
}
|
||||||
|
(h * 60.0, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
|
||||||
|
if t < 0.0 {
|
||||||
|
t += 1.0;
|
||||||
|
}
|
||||||
|
if t > 1.0 {
|
||||||
|
t -= 1.0;
|
||||||
|
}
|
||||||
|
if t < 1.0 / 6.0 {
|
||||||
|
p + (q - p) * 6.0 * t
|
||||||
|
} else if t < 1.0 / 2.0 {
|
||||||
|
q
|
||||||
|
} else if t < 2.0 / 3.0 {
|
||||||
|
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||||
|
} else {
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HSL -> RGB (0..255).
|
||||||
|
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||||
|
if s == 0.0 {
|
||||||
|
let v = l * 255.0;
|
||||||
|
return (v, v, v);
|
||||||
|
}
|
||||||
|
let q = if l < 0.5 {
|
||||||
|
l * (1.0 + s)
|
||||||
|
} else {
|
||||||
|
l + s - l * s
|
||||||
|
};
|
||||||
|
let p = 2.0 * l - q;
|
||||||
|
let hk = h / 360.0;
|
||||||
|
(
|
||||||
|
hue_to_rgb(p, q, hk + 1.0 / 3.0) * 255.0,
|
||||||
|
hue_to_rgb(p, q, hk) * 255.0,
|
||||||
|
hue_to_rgb(p, q, hk - 1.0 / 3.0) * 255.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn band_weight(h: f64, center: f64) -> f64 {
|
||||||
|
let mut d = h - center;
|
||||||
|
if d < 0.0 {
|
||||||
|
d = -d;
|
||||||
|
}
|
||||||
|
if d > 180.0 {
|
||||||
|
d = 360.0 - d;
|
||||||
|
}
|
||||||
|
clamp01d(1.0 - d / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Применяет правки «Цвет» к пикселям RGBA в памяти.
|
||||||
|
///
|
||||||
|
/// `params` — блок из `COLOR_PARAM_COUNT` значений f64, подготовленный
|
||||||
|
/// вызывающим кодом. Порядок операций повторяет серверный рендер и JS.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn apply_color(data: *mut u8, len: u32, params: *const f64) {
|
||||||
|
let p = unsafe { core::slice::from_raw_parts(params, COLOR_PARAM_COUNT) };
|
||||||
|
let temperature = p[0];
|
||||||
|
let tint = p[1];
|
||||||
|
let vibrance = p[2];
|
||||||
|
let saturation = p[3];
|
||||||
|
let blending = p[37];
|
||||||
|
let balance = p[38];
|
||||||
|
let blend = blending;
|
||||||
|
|
||||||
|
// Микшер часто пуст (правят только колёса) — тогда не тратим на него время.
|
||||||
|
let mut has_mixer = false;
|
||||||
|
let mut band = 0usize;
|
||||||
|
while band < 8 {
|
||||||
|
let base = 4 + band * 3;
|
||||||
|
if p[base] != 0.0 || p[base + 1] != 0.0 || p[base + 2] != 0.0 {
|
||||||
|
has_mixer = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
band += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tints = [(0.0f64, 0.0f64, 0.0f64); 3];
|
||||||
|
let mut w = 0usize;
|
||||||
|
while w < 3 {
|
||||||
|
tints[w] = hsl_to_rgb(p[28 + w * 2], 1.0, 0.5);
|
||||||
|
w += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < buf.len() {
|
||||||
|
let alpha = unsafe { *buf.get_unchecked(i + 3) };
|
||||||
|
if alpha != 0 {
|
||||||
|
let mut r = unsafe { *buf.get_unchecked(i) } as f64;
|
||||||
|
let mut g = unsafe { *buf.get_unchecked(i + 1) } as f64;
|
||||||
|
let mut b = unsafe { *buf.get_unchecked(i + 2) } as f64;
|
||||||
|
|
||||||
|
// Баланс белого.
|
||||||
|
r *= 1.0 + 0.2 * temperature + 0.1 * tint;
|
||||||
|
g *= 1.0 - 0.2 * tint;
|
||||||
|
b *= 1.0 - 0.2 * temperature + 0.1 * tint;
|
||||||
|
r = clampf(r, 0.0, 255.0);
|
||||||
|
g = clampf(g, 0.0, 255.0);
|
||||||
|
b = clampf(b, 0.0, 255.0);
|
||||||
|
|
||||||
|
// Быстрый путь: только колёса/яркость, без микшера и насыщенности.
|
||||||
|
// Тогда HSL-переход не нужен — lightness считается напрямую.
|
||||||
|
let fast = !has_mixer && saturation == 0.0 && vibrance == 0.0;
|
||||||
|
let weights: [f64; 3];
|
||||||
|
let mut rr: f64;
|
||||||
|
let mut gg: f64;
|
||||||
|
let mut bb: f64;
|
||||||
|
|
||||||
|
if fast {
|
||||||
|
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
||||||
|
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
||||||
|
let l = (max + min) / 510.0;
|
||||||
|
let lb = clamp01d(l + balance * 0.5);
|
||||||
|
let shadow_w = {
|
||||||
|
let t = (1.0 - lb) * (1.0 - lb);
|
||||||
|
t * t
|
||||||
|
};
|
||||||
|
let highlight_w = {
|
||||||
|
let t = lb * lb;
|
||||||
|
t * t
|
||||||
|
};
|
||||||
|
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
||||||
|
|
||||||
|
let mut target_l = l;
|
||||||
|
let mut wheel = 0usize;
|
||||||
|
while wheel < 3 {
|
||||||
|
target_l += p[34 + wheel] * 0.2 * weights[wheel];
|
||||||
|
wheel += 1;
|
||||||
|
}
|
||||||
|
target_l = clamp01d(target_l);
|
||||||
|
let delta = (target_l - l) * 255.0;
|
||||||
|
rr = r + delta;
|
||||||
|
gg = g + delta;
|
||||||
|
bb = b + delta;
|
||||||
|
} else {
|
||||||
|
// RGB -> HSL.
|
||||||
|
let (mut h, mut s, mut l) = rgb_to_hsl(r, g, b);
|
||||||
|
|
||||||
|
// Насыщенность и вибрация.
|
||||||
|
if saturation != 0.0 || vibrance != 0.0 {
|
||||||
|
s = clamp01d(s * (1.0 + saturation + vibrance * (1.0 - s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветовой микшер.
|
||||||
|
if has_mixer {
|
||||||
|
let mut band = 0usize;
|
||||||
|
while band < 8 {
|
||||||
|
let base = 4 + band * 3;
|
||||||
|
let weight = band_weight(h, BAND_CENTERS[band]);
|
||||||
|
if weight != 0.0 {
|
||||||
|
h += p[base] * 30.0 * weight;
|
||||||
|
s = clamp01d(s * (1.0 + p[base + 1] * weight));
|
||||||
|
l = clamp01d(l + p[base + 2] * 0.2 * weight);
|
||||||
|
}
|
||||||
|
band += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h = h % 360.0;
|
||||||
|
if h < 0.0 {
|
||||||
|
h += 360.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lb = clamp01d(l + balance * 0.5);
|
||||||
|
let shadow_w = {
|
||||||
|
let t = (1.0 - lb) * (1.0 - lb);
|
||||||
|
t * t
|
||||||
|
};
|
||||||
|
let highlight_w = {
|
||||||
|
let t = lb * lb;
|
||||||
|
t * t
|
||||||
|
};
|
||||||
|
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
||||||
|
let mut wheel = 0usize;
|
||||||
|
while wheel < 3 {
|
||||||
|
l = clamp01d(l + p[34 + wheel] * 0.2 * weights[wheel]);
|
||||||
|
wheel += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||||
|
rr = r2;
|
||||||
|
gg = g2;
|
||||||
|
bb = b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точечный цвет: три колеса по зонам яркости.
|
||||||
|
let mut wheel = 0usize;
|
||||||
|
while wheel < 3 {
|
||||||
|
let strength = p[28 + wheel * 2 + 1] * 0.5 * blend * weights[wheel];
|
||||||
|
if strength != 0.0 {
|
||||||
|
let t = tints[wheel];
|
||||||
|
rr = rr * (1.0 - strength) + t.0 * strength;
|
||||||
|
gg = gg * (1.0 - strength) + t.1 * strength;
|
||||||
|
bb = bb * (1.0 - strength) + t.2 * strength;
|
||||||
|
}
|
||||||
|
wheel += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветокоррекция.
|
||||||
|
rr *= 1.0 + p[39] * 0.5;
|
||||||
|
gg *= 1.0 + p[40] * 0.5;
|
||||||
|
bb *= 1.0 + p[41] * 0.5;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
*buf.get_unchecked_mut(i) = to_u8(rr);
|
||||||
|
*buf.get_unchecked_mut(i + 1) = to_u8(gg);
|
||||||
|
*buf.get_unchecked_mut(i + 2) = to_u8(bb);
|
||||||
|
} }
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-21
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. `proposal.md` — Why. Ограничения, важные для подхода:
|
||||||
|
|
||||||
|
- `Store.Clone` (Postgres) и `SQLiteStore.Clone` (SQLite) копируют привязки
|
||||||
|
`photo_objects` одной вставкой `INSERT ... SELECT` без фильтра по ролям, поэтому
|
||||||
|
копия наследует и обычные варианты, и edit-варианты.
|
||||||
|
- Обычные варианты действительно должны разделяться: воркер создаёт их через
|
||||||
|
`createObject(ctx, siblings, ...)` , где `siblings` — все фото, делящие оригинал
|
||||||
|
(`PhotosByOriginalObject`). Edit-варианты, наоборот, создаются с владельцем
|
||||||
|
`[]string{photoID}` и привязаны только к одному фото.
|
||||||
|
- Параметры правок лежат в колонке `photos.edit_params` и при клонировании не
|
||||||
|
переносятся: `INSERT INTO photos (...)` в `Clone` её не включает. То есть рассинхрон
|
||||||
|
возникает именно на уровне привязок объектов.
|
||||||
|
- `ResetEdit` удаляет и параметры, и привязки edit-объектов
|
||||||
|
(`DeleteObjectBindings`). Значит, у фото с пустыми `edit_params` edit-привязок быть
|
||||||
|
не должно — кроме как последствия копирования.
|
||||||
|
- Выдача URL смотрит только на привязки (`attachURLs`), а фронтенд выбирает
|
||||||
|
`edit_thumb_url ?? thumb_url`. Привязка — источник правды о том, какие варианты
|
||||||
|
есть у фото; чинить выдачу URL вместо привязки нельзя.
|
||||||
|
- `ClaimGarbage` собирает готовые объекты без привязок, поэтому удаление привязки
|
||||||
|
само по себе освобождает объект — отдельная уборка не нужна.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Копия фото не наследует отредактированную версию: показывается исходник, параметры
|
||||||
|
правок пусты.
|
||||||
|
- Postgres и SQLite ведут себя одинаково, список исключаемых ролей задан один раз.
|
||||||
|
- Уже существующие копии в базе перестают показывать отредактированную версию без
|
||||||
|
ручного вмешательства.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Не переносим параметры правок на копию: правка принадлежит фото, а не копии.
|
||||||
|
- Не рендерим отредактированную версию заново для копии.
|
||||||
|
- Не меняем поведение переноса (`MoveBatch`) и разделяемых обычных вариантов.
|
||||||
|
- Не трогаем клиентов и HTTP API: формат ответа не меняется.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Фильтровать роли в `Clone`, а не в выдаче URL
|
||||||
|
|
||||||
|
Копирование исключает edit-роли из переносимых привязок:
|
||||||
|
|
||||||
|
```
|
||||||
|
INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
|
WHERE photo_id = $2 AND role <> ALL($3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Альтернатива: прятать edit-URL, если у фото нет `edit_params`.** Отклонено:
|
||||||
|
привязка осталась бы в базе, GC считал бы объект живым, а модель и привязка
|
||||||
|
расходились бы. Привязка — источник правды.
|
||||||
|
- **Альтернатива: копировать и параметры, и готовую версию.** Отклонено: меняет
|
||||||
|
семантику «правка принадлежит фото» и требует отдельного рендера для копии.
|
||||||
|
|
||||||
|
### 2. Список edit-ролей — в одном месте
|
||||||
|
|
||||||
|
Роли уже объявлены в `backend/internal/photos/edit_render.go`
|
||||||
|
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`), оба хранилища
|
||||||
|
находятся в том же пакете. Добавляется общий список ролей и хелпер проверки, чтобы
|
||||||
|
Postgres и SQLite не расходились и в SQL не попадали строковые литералы.
|
||||||
|
|
||||||
|
- **Альтернатива: писать роли литералами в каждом `Clone`.** Отклонено: список
|
||||||
|
размножится, добавление новой edit-роли забудет одно из хранилищ.
|
||||||
|
|
||||||
|
### 3. Разовая чистка существующих копий — миграцией
|
||||||
|
|
||||||
|
В базе могут остаться привязки `edit*` у фото с пустыми `edit_params` — это и есть
|
||||||
|
следы бага копирования. Чистка выполняется миграцией в обоих диалектах
|
||||||
|
(Postgres и SQLite нумеруются независимо):
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}')
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Альтернатива: чистить лениво при чтении.** Отклонено: правило прячется в двух
|
||||||
|
местах и не отражается в данных.
|
||||||
|
- **Альтернатива: ничего не чистить.** Отклонено: старые копии продолжат показывать
|
||||||
|
отредактированную версию, и расхождение останется в данных.
|
||||||
|
- Удалённые привязки не осиротевают объекты: отредактированная версия остаётся
|
||||||
|
привязана к своему фото, поэтому её объекты живут, пока живёт фото, и обычный
|
||||||
|
`ClaimGarbage` уберёт их вместе с ним.
|
||||||
|
|
||||||
|
### 4. Проверка — в существующем тесте копирования
|
||||||
|
|
||||||
|
`backend/internal/photos/copy_test.go` уже проверяет, что копия делит объекты с
|
||||||
|
оригиналом. Добавляется сценарий: после рендера правок копия получает обычные
|
||||||
|
варианты, но не edit-объекты, и её `edit_params` пусты.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Копии, созданные до фикса, продолжат показывать правки до применения миграции] →
|
||||||
|
Миграция чистит привязки у фото с пустыми `edit_params`; после неё поведение
|
||||||
|
единообразно.
|
||||||
|
- [Пользователь может потерять «неожиданно» отредактированную копию] → Копия без
|
||||||
|
параметров правок всё равно не позволяла их править осмысленно: первая новая правка
|
||||||
|
перезаписывала обработку с нуля.
|
||||||
|
- [Опечатка в списке ролей оставит edit-привязку в копии] → Список ролей берётся из
|
||||||
|
общих констант, а тест проверяет конкретные роли.
|
||||||
|
- [Миграция удалит привязки у фото, которые потеряли `edit_params` не из-за копирования]
|
||||||
|
→ Таких фото нет: `ResetEdit` удаляет и параметры, и привязки одновременно.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Миграция Postgres (`000016_*`) и SQLite (`000008_*`) удаляют «чужие» edit-привязки.
|
||||||
|
Обе идемпотентны (`DELETE` без `IF EXISTS` по данным).
|
||||||
|
2. Изменения в `Clone` — только код, схема не меняется.
|
||||||
|
3. Откат: миграции `down` возвращают удалённые привязки не могут — откат выполняется
|
||||||
|
только откатом кода; правки фото при этом сохраняются у исходных фото.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Отредактированная версия фото задумана принадлежащей только тому фото, чьи правки её
|
||||||
|
породили: воркер создаёт edit-объекты с владельцем `[]string{photoID}` и комментирует
|
||||||
|
это прямо. Но копирование фото (`Clone`) переносит **все** привязки `photo_objects`,
|
||||||
|
включая роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`. Копия фото, сделанная
|
||||||
|
после рендера правок, поэтому показывает отредактированную картинку, но не наследует
|
||||||
|
`edit_params`: в панели правок у копии параметров нет, и первая же новая правка
|
||||||
|
перезаписывает исходную обработку с нуля. Поведение расходится с замыслом и с
|
||||||
|
описанием в спеке.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Копирование фото перестаёт переносить отредактированную версию: в копию
|
||||||
|
попадают только обычные роли (`original`, `thumb`, `medium`, `preview`, `playback`).
|
||||||
|
- Копия фото с правками показывается в исходном виде, параметры правок копии
|
||||||
|
остаются пустыми — как и сейчас.
|
||||||
|
- Обычные варианты по-прежнему разделяются между копиями одного оригинала: правка
|
||||||
|
принадлежит фото, а не объекту.
|
||||||
|
- Спека `media-objects` приводится к этому правилу: уточняется, кому принадлежит
|
||||||
|
отредактированная версия и что происходит при копировании.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `media-objects`: копирование фото не переносит отредактированную версию на копию;
|
||||||
|
отредактированная версия остаётся привязанной только к фото, чьи правки её создали.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `backend/internal/photos/store.go` — `Store.Clone` (Postgres): при копировании
|
||||||
|
`photo_objects` исключаются роли `edit*`.
|
||||||
|
- `backend/internal/photos/sqlite_store.go` — `SQLiteStore.Clone` (SQLite): то же
|
||||||
|
поведение.
|
||||||
|
- `backend/internal/photos/edit_render.go` — константы ролей `edit*` используются как
|
||||||
|
список исключений (без дублирования строк в двух хранилищах).
|
||||||
|
- `backend/internal/photos/copy_test.go` — тест на копию фото с отрендеренной
|
||||||
|
отредактированной версией.
|
||||||
|
- Схема БД, HTTP API и клиенты не меняются; миграций нет.
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Совместные объекты
|
||||||
|
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
||||||
|
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
||||||
|
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
|
||||||
|
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
|
||||||
|
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
|
||||||
|
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
|
||||||
|
виде и SHALL NOT наследовать параметры правок.
|
||||||
|
|
||||||
|
#### Scenario: Копирование
|
||||||
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
|
- **THEN** создаётся новая запись, ссылающаяся на те же объекты, без операций с хранилищем
|
||||||
|
|
||||||
|
#### Scenario: Перенос
|
||||||
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
|
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
||||||
|
|
||||||
|
#### Scenario: Правка не распространяется на копии
|
||||||
|
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
|
||||||
|
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
|
||||||
|
остаётся в исходном виде
|
||||||
|
|
||||||
|
#### Scenario: Копия отредактированного фото
|
||||||
|
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
|
||||||
|
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
|
||||||
|
не наследует параметры правок
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Список edit-ролей
|
||||||
|
|
||||||
|
- [x] 1.1 В `backend/internal/photos/edit_render.go` собрать общий список ролей
|
||||||
|
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`) и хелпер проверки
|
||||||
|
роли; проверить, что список используется в обоих хранилищах и не дублируется
|
||||||
|
строковыми литералами
|
||||||
|
|
||||||
|
## 2. Копирование без отредактированной версии
|
||||||
|
|
||||||
|
- [x] 2.1 В `Store.Clone` (`backend/internal/photos/store.go`) исключить edit-роли из
|
||||||
|
переносимых привязок `photo_objects`; проверить тестом `copy_test.go`: копия
|
||||||
|
получает обычные варианты и не получает edit-объекты
|
||||||
|
- [x] 2.2 В `SQLiteStore.Clone` (`backend/internal/photos/sqlite_store.go`) сделать то же
|
||||||
|
для SQLite; проверить тем же тестом, прогнанным на SQLite-хранилище
|
||||||
|
|
||||||
|
## 3. Разовая чистка существующих копий
|
||||||
|
|
||||||
|
- [x] 3.1 Добавить миграцию Postgres `backend/migrations/000016_*.up.sql` и `.down.sql`,
|
||||||
|
удаляющую edit-привязки у фото с пустыми `edit_params`; проверить на тестовой базе,
|
||||||
|
что привязки исходных фото с правками остались, а «чужие» удалены
|
||||||
|
- [x] 3.2 Добавить миграцию SQLite `backend/migrations/sqlite/000008_*.up.sql` и
|
||||||
|
`.down.sql` с тем же действием; проверить тем же сценарием на SQLite
|
||||||
|
- [x] 3.3 Убедиться, что удалённые объекты подбираются сборщиком мусора
|
||||||
|
(`ClaimGarbage` не возвращает объекты с привязками); проверить существующим тестом
|
||||||
|
`objects_test.go`
|
||||||
|
|
||||||
|
## 4. Интеграционная проверка
|
||||||
|
|
||||||
|
- [x] 4.1 Прогнать `cd backend && go test ./internal/photos/ -v`; убедиться, что тесты
|
||||||
|
зелёные
|
||||||
|
- [x] 4.2 Прогнать `make test`; убедиться, что весь бэкенд зелёный
|
||||||
|
- [x] 4.3 Проверить вручную: отредактировать фото, скопировать его в другой альбом и
|
||||||
|
убедиться, что копия показывает исходник, а панель правок у копии пустая
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-23
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Неразрушающее редактирование фото уже работает: параметры хранятся в
|
||||||
|
`photo.edit_params` (`map[string]any` JSON-блоб), сервер рендерит копию в роли
|
||||||
|
`edit`, `edit_thumb`, `edit_medium`, `edit_preview`, оригинал не меняется.
|
||||||
|
Исторически поддерживался только блок «Свет» (`LightParams`), живой превью шёл
|
||||||
|
через WASM (`frontend/wasm/light/`). Теперь добавлен блок «Цвет» и GPU-превью:
|
||||||
|
основной live-предпросмотр считает WebGL2, а CPU-путь (TypeScript/WASM) остаётся
|
||||||
|
фолбэком и эталоном паритета. Хранение в JSON-блобе миграции не требует: фото без
|
||||||
|
ключа `color` рендерятся как прежде.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Добавить «Цвет»: баланс белого (температура/оттенок + пипетка), сочность
|
||||||
|
(vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекция
|
||||||
|
(тени/средние тона/света + яркость зон, наложение, баланс), поканальная
|
||||||
|
коррекция.
|
||||||
|
- Показывать правки в просмотрщике сразу, не дожидаясь серверного рендера.
|
||||||
|
- Сохранить неразрушающий серверный рендер как источник истины и обратную
|
||||||
|
совместимость со старыми `edit_params`.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Синхронизация правок между устройствами и Android-клиент.
|
||||||
|
- Изменение ролей хранения или формата объектов.
|
||||||
|
- Миграция схемы базы данных.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**Цвет хранится в том же `edit_params`, рядом с `light`.**
|
||||||
|
- Обоснование: `EditParams` расширяется полем `Color ColorParams`; JSON-блоб и
|
||||||
|
клиентская модель принимают новый ключ без схемных изменений.
|
||||||
|
- Альтернатива: отдельная колонка/таблица — отвергнута, потребовала бы миграцию.
|
||||||
|
|
||||||
|
**Порядок операций: свет, затем цвет.**
|
||||||
|
- Обоснование: цветовые коррекции логично применять к уже скорректированной по
|
||||||
|
тональности картинке; сервер и клиент следуют одному порядку.
|
||||||
|
- Альтернатива: цвет до света — отвергнуто, расходится с поведением Lightroom и
|
||||||
|
текущим конвейером.
|
||||||
|
|
||||||
|
**Живой предпросмотр считается на GPU (WebGL2), CPU/WASM — фолбэк.**
|
||||||
|
- Обоснование: попиксельный CPU-фильтр на полном viewport блокировал main thread
|
||||||
|
(замеры: десятки мс на кадр). Шейдер повторяет свет/цвет и считает кадр на
|
||||||
|
видеокарте; текстура источника загружается один раз на смену изображения.
|
||||||
|
- Фолбэк: если WebGL2 недоступен или шейдер не собрался, используется прежний
|
||||||
|
CPU-путь (WASM, затем JS) с адаптивным буфером. Это медленнее, но сохраняет
|
||||||
|
функциональность.
|
||||||
|
- Альтернатива: вынести CPU-фильтр в Web Worker/OffscreenCanvas — отвергнуто как
|
||||||
|
более сложное при уже готовом GPU-пути.
|
||||||
|
|
||||||
|
**Паритет реализаций ограничен явно.**
|
||||||
|
- Go (сервер) — источник истины для сохранённого фото; покрыт Go-тестами.
|
||||||
|
- TypeScript и Rust/WASM — эталонная пара для CPU-фолбэка; паритет проверяется
|
||||||
|
`wasmMatchesReference` на пробе и больших градиентах.
|
||||||
|
- GLSL/WebGL2 — быстрый предпросмотр; проверяется компиляцией/линковкой шейдера,
|
||||||
|
пиксельный паритет с сервером не гарантируется. Финальное качество даёт сервер.
|
||||||
|
|
||||||
|
**Тональные зоны и быстрый путь.**
|
||||||
|
- Веса зон: тени `(1-l)^4`, света `l^4`, средние тона — остаток; это не даёт
|
||||||
|
колесу теней заметно задевать средние тона и уменьшает резкую границу на
|
||||||
|
контрастных снимках.
|
||||||
|
- `blending` по умолчанию 50 (диапазон 0..100), `balance` — -100..100.
|
||||||
|
- Яркость зон применяется аддитивным сдвигом, без деления на пиксель.
|
||||||
|
- Если микшер и насыщенность/вибрация не задействованы, цвет считается быстрым
|
||||||
|
путём без RGB→HSL→RGB; иначе — полный HSL-путь.
|
||||||
|
|
||||||
|
**Пустота цветовых правок.**
|
||||||
|
- Наложение/баланс и оттенок колеса без силы и яркости не считаются правкой:
|
||||||
|
фото не получает цветовую версию, сброс остаётся доступным по другим полям.
|
||||||
|
|
||||||
|
**Пипетка баланса белого.**
|
||||||
|
- Клиент сэмплирует пиксель из исходного (неотредактированного) превью и решает
|
||||||
|
обратную задачу баланса белого: подбирает температуру/оттенок, при которых
|
||||||
|
точка становится нейтральной. Пресет переходит в «Пользовательский».
|
||||||
|
|
||||||
|
**Поканальная коррекция — в модели и рендере, но не в UI.**
|
||||||
|
- `ColorCorrection` (red/green/blue) принимается API и учитывается рендером, но в
|
||||||
|
панели не выведена: в макете «Цветокоррекция» — это колёса тональных зон.
|
||||||
|
|
||||||
|
**UI.**
|
||||||
|
- Раздел «Цвет»: баланс белого с пресетами и пипеткой, сочность/насыщенность,
|
||||||
|
микшер (8 оттенков × тон/насыщенность/яркость), цветокоррекция (3 колеса,
|
||||||
|
яркость зон, наложение, баланс).
|
||||||
|
- Слайдеры с градиентными дорожками, цветовые колёса, двойной клик по контролу
|
||||||
|
сбрасывает значение.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
[WebGL2 недоступен] → фолбэк на CPU/WASM с адаптивным буфером; функциональность
|
||||||
|
сохраняется, скорость ниже.
|
||||||
|
|
||||||
|
[Расхождение шейдера и сервера] → серверный рендер остаётся источником истины;
|
||||||
|
шейдер проходит проверку компиляции, а CPU-пара JS/WASM — тесты паритета.
|
||||||
|
|
||||||
|
[Большая текстура оригинала] → загрузка один раз на смену источника; при
|
||||||
|
переключении зума/источника возможна разовая пауза.
|
||||||
|
|
||||||
|
[Резкая граница на контрастных снимках] → более узкие веса зон и аддитивная
|
||||||
|
яркость; полностью убрать границу нельзя, если она есть в самом снимке.
|
||||||
|
|
||||||
|
[Обратная совместимость] → отсутствие `color` трактуется как нулевые правки.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Миграция схемы не требуется: новый ключ `color` дополняет существующий `light`.
|
||||||
|
Откат — удаление ключа `color` из `edit_params`.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
В инструменте правок фото сейчас только раздел «Свет» (экспозиция, контраст, света,
|
||||||
|
тени, белые, чёрные, точечная кривая). Пользователи не могут скорректировать цвет:
|
||||||
|
баланс белого, вибрацию, насыщенность, отдельные оттенки и тонирование. Добавить
|
||||||
|
цветовые правки — востребованное расширение неразрушающего редактирования, которое
|
||||||
|
уже работает в Galo.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Расширить `EditParams` новым блоком `Color`: баланс белого (температура/оттенок
|
||||||
|
+ пипетка по нейтральной точке), сочность (vibrance), насыщенность, цветовой
|
||||||
|
микшер (8 оттенков × тон/насыщенность/яркость), цветокоррекция (тени/средние
|
||||||
|
тона/света + яркость зон, наложение, баланс) и поканальная коррекция.
|
||||||
|
- Сервер (Go): рендер цветовых правок на пикселях исходного объекта, порядок
|
||||||
|
операций — свет, затем цвет; результат записывается в те же роли `edit`,
|
||||||
|
`edit_thumb`, `edit_medium`, `edit_preview`.
|
||||||
|
- Клиент (TypeScript/Svelte): раздел «Цвет» с ползунками, цветовыми колёсами и
|
||||||
|
микшером; живой предпросмотр на WebGL2, CPU/WASM-фолбэк с тестом паритета
|
||||||
|
(`wasmMatchesReference`); пипетка баланса белого; двойной клик по контролу
|
||||||
|
сбрасывает значение.
|
||||||
|
- WASM (Rust): расширение `frontend/wasm/light/` цветовыми операциями и
|
||||||
|
пересборка `static/light.wasm` (фолбэк предпросмотра).
|
||||||
|
- Хранение: `photo.edit_params` остаётся `map[string]any` JSON-блобом — старые
|
||||||
|
фото без ключа `color` рендерятся без цветовых правок, миграций схемы не
|
||||||
|
требуется.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `photos`: требование «Неразрушающее редактирование фотографии» расширяется
|
||||||
|
цветовыми параметрами правок. Потребуется дельта-спека
|
||||||
|
`specs/photos/spec.md` в рамках этого изменения.
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет — цветовое редактирование входит в существующую способность `photos`.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `backend/internal/photos/edit.go` — `ColorParams`, `EditParams.Color`,
|
||||||
|
`Normalized`/`IsEmpty`/`JSON`.
|
||||||
|
- `backend/internal/photos/edit_render.go` — `applyColor` / `applyColorRows`,
|
||||||
|
`decodeAndAdjust` применяет свет и цвет.
|
||||||
|
- `backend/internal/photos/handlers.go` — приём `color` в `PATCH /photos/:id/edit`.
|
||||||
|
- `frontend/src/lib/edit.ts` — `ColorParams`, цветовые ползунки,
|
||||||
|
`readEditParams`/`isEmptyEditParams`/`normalizeEditParams`, `applyColorToPixels`,
|
||||||
|
`whiteBalanceFromRGB`.
|
||||||
|
- `frontend/src/lib/light-wasm.ts` — вызов WASM для цвета, поддержка паритета.
|
||||||
|
- `frontend/src/lib/webgl-preview.ts` — WebGL2-шейдер и рендер превью.
|
||||||
|
- `frontend/src/lib/stores/eyedropper.svelte.ts` — режим пипетки.
|
||||||
|
- `frontend/src/lib/components/PhotoEditPanel.svelte` — раздел «Цвет».
|
||||||
|
- `frontend/src/lib/components/EditSlider.svelte`, `ColorWheel.svelte` — контролы.
|
||||||
|
- `frontend/src/lib/components/Viewer.svelte` — live-превью (WebGL/CPU) и сэмплирование пипетки.
|
||||||
|
- `frontend/src/lib/components/PhotoPanel.svelte` — передача признака просмотрщика.
|
||||||
|
- `frontend/wasm/light/` (Rust) + `static/light.wasm` — цветовые операции.
|
||||||
|
- Тесты: `edit_test.go`, `edit.test.ts`, `light-wasm.test.ts`,
|
||||||
|
`backend/internal/e2e/color_edit_test.go`.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Неразрушающее редактирование фотографии
|
||||||
|
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок (свет и цвет) SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с исходными вариантами. Рендер SHALL выполняться по исходному объекту фото, а не по его уменьшенным вариантам. Исходный объект SHALL оставаться неизменным, а скачивание SHALL отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию. Правки SHALL применяться только к изображениям.
|
||||||
|
|
||||||
|
#### Scenario: Сохранение правок
|
||||||
|
- **WHEN** пользователь сохраняет правки фото (свет и/или цвет)
|
||||||
|
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
||||||
|
|
||||||
|
#### Scenario: Фоновая подготовка версий
|
||||||
|
- **WHEN** правки фото сохранены
|
||||||
|
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
||||||
|
|
||||||
|
#### Scenario: Рендер по оригиналу
|
||||||
|
- **WHEN** готовится отредактированная версия фото
|
||||||
|
- **THEN** световые и цветовые правки применяются к пикселям исходного объекта, а не к его уменьшенной копии
|
||||||
|
|
||||||
|
#### Scenario: Неразрушачность
|
||||||
|
- **WHEN** фото отредактировано
|
||||||
|
- **THEN** исходный объект и его варианты остаются неизменными
|
||||||
|
|
||||||
|
#### Scenario: Сброс правок
|
||||||
|
- **WHEN** пользователь сбрасывает правки
|
||||||
|
- **THEN** параметры и отредактированная версия удаляются, а фото снова показывается в исходном виде
|
||||||
|
|
||||||
|
#### Scenario: Права на правки
|
||||||
|
- **WHEN** правки сохраняет пользователь без прав на это фото
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Редактирование видео
|
||||||
|
- **WHEN** пользователь пытается отредактировать видео
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Цветовые правки сохраняются и рендерятся
|
||||||
|
- **WHEN** пользователь сохраняет цветовые правки фото: баланс белого (температура/оттенок), сочность (vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекцию (тени/средние тона/света, яркость зон, наложение, баланс) или поканальную коррекцию
|
||||||
|
- **THEN** параметры цвета сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
||||||
|
|
||||||
|
#### Scenario: Цветовые правки применяются к пикселям исходного объекта
|
||||||
|
- **WHEN** готовится отредактированная версия фото с цветовыми правками
|
||||||
|
- **THEN** цветовые правки применяются к пикселям исходного объекта после световых правок, а не к его уменьшенной копии
|
||||||
|
|
||||||
|
#### Scenario: Пустые цветовые правки не сохраняются
|
||||||
|
- **WHEN** в цветовых параметрах заданы только наложение/баланс или оттенок колеса без силы и яркости
|
||||||
|
- **THEN** правки считаются пустыми и фото не получает цветовую версию
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Пипетка баланса белого
|
||||||
|
Клиент SHALL позволять выбрать нейтральную точку на фото и SHALL устанавливать температуру и оттенок так, чтобы цвет этой точки стал нейтральным. Выборка SHALL браться из исходного (неотредактированного) превью, а пресет баланса белого SHALL переходить в «Пользовательский».
|
||||||
|
|
||||||
|
#### Scenario: Выбор нейтральной точки
|
||||||
|
- **WHEN** пользователь включает пипетку и кликает по нейтральному участку фото
|
||||||
|
- **THEN** температура и оттенок обновляются, и цвет выбранной точки становится нейтральным
|
||||||
|
|
||||||
|
#### Scenario: Пипетка доступна только в просмотрщике
|
||||||
|
- **WHEN** фото не открыто в просмотрщике
|
||||||
|
- **THEN** пипетка недоступна
|
||||||
|
|
||||||
|
### Requirement: Живой предпросмотр цветовых правок
|
||||||
|
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. После завершения рендера SHALL показываться серверная версия.
|
||||||
|
|
||||||
|
#### Scenario: Правки видны сразу
|
||||||
|
- **WHEN** пользователь двигает цветовой контрол
|
||||||
|
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
|
||||||
|
|
||||||
|
#### Scenario: Серверная версия заменяет предпросмотр
|
||||||
|
- **WHEN** серверный рендер цветовых правок завершён
|
||||||
|
- **THEN** просмотрщик показывает отрендеренную версию
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Backend: цветовая модель и рендер (Go)
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить `ColorParams` и `EditParams.Color` в `backend/internal/photos/edit.go`, обновить `Normalized`/`IsEmpty`/`JSON` с учетом цвета — проверить `go build ./internal/photos/`
|
||||||
|
- [x] 1.2 Реализовать `applyColor` / `applyColorRows` и включить цвет в `decodeAndAdjust` после света (`backend/internal/photos/edit_render.go`) — проверить `go test ./internal/photos/ -run TestColor -v`
|
||||||
|
- [x] 1.3 Добавить тесты цветовых параметров (`edit_test.go`): валидация диапазонов, `IsEmpty`, рендер с цветом — проверить `go test ./internal/photos/ -v`
|
||||||
|
|
||||||
|
## 2. Фронтенд: цветовая модель (TypeScript)
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить `ColorParams`, цветовые ползунки, `emptyColor`, обновить `readEditParams`/`isEmptyEditParams`/`normalizeEditParams` в `frontend/src/lib/edit.ts` — проверить `npm run check` (в `frontend`)
|
||||||
|
- [x] 2.2 Реализовать `applyColorToPixels` в `edit.ts`, зеркальное серверной математике — проверить `npm run test` (в `frontend`, `edit.test.ts`)
|
||||||
|
- [x] 2.3 Поддержать цвет в `light-wasm.ts` и тест паритета `wasmMatchesReference` — проверить `npm run test` (в `frontend`, `light-wasm.test.ts`)
|
||||||
|
|
||||||
|
## 3. WASM модуль цвета (Rust)
|
||||||
|
|
||||||
|
- [x] 3.1 Реализовать цветовые операции (баланс белого, вибрация, насыщенность, микшер, точечный цвет) в `frontend/wasm/light/` — проверить сборку модуля
|
||||||
|
- [x] 3.2 Пересобрать `static/light.wasm` — проверить файл обновился
|
||||||
|
|
||||||
|
## 4. UI: панель и превью цвета
|
||||||
|
|
||||||
|
- [x] 4.1 Добавить раздел «Цвет» в `PhotoEditPanel.svelte` (баланс белого, сочность, насыщенность, микшер, цветокоррекция) — проверить открытие/сворачивание панели
|
||||||
|
- [x] 4.2 Поддержать цвет в live-превью `Viewer.svelte` — проверить изменение превью при движении ползунков
|
||||||
|
- [x] 4.3 Подключить сохранение цветовых параметров через `photosApi.edit` — проверить сохранение на сервере
|
||||||
|
|
||||||
|
## 5. Интеграция и проверка
|
||||||
|
|
||||||
|
- [x] 5.1 Проверить эндпоинт правки (`PATCH /api/photos/:id/edit`) с цветовыми параметрами — проверить ответ с `edit_url`
|
||||||
|
- [x] 5.2 Проверить сброс цветовых правок — проверить возврат к оригиналу
|
||||||
|
- [x] 5.3 Проверить обратную совместимость: фото без ключа `color` в `edit_params` рендерятся без цветовых правок — проверить `make test` (бэкенд)
|
||||||
|
|
||||||
|
## 6. WebGL-предпросмотр
|
||||||
|
|
||||||
|
- [x] 6.1 Реализовать `frontend/src/lib/webgl-preview.ts`: WebGL2-шейдер света/цвета, LUT-текстура кривых, загрузка текстуры изображения — проверить сборку `npm run build`
|
||||||
|
- [x] 6.2 Подключить GL-first и CPU/WASM-фолбэк в `Viewer.svelte` (два канваса, `useGL`) — проверить переключение при отсутствии WebGL2
|
||||||
|
- [x] 6.3 Проверить компиляцию и линковку шейдера в headless Firefox — зелёный экран `OK`
|
||||||
|
- [x] 6.4 Пропускать свет, если он не менялся, и микшер, если он пуст — проверить `go test ./internal/photos/ ./internal/e2e/`
|
||||||
|
- [x] 6.5 Снизить стоимость CPU-фолбэка: адаптивный буфер, беззвучный быстрый путь без HSL и аддитивная яркость — проверить замер WASM/визуально
|
||||||
|
|
||||||
|
## 7. Пипетка баланса белого
|
||||||
|
|
||||||
|
- [x] 7.1 Добавить `frontend/src/lib/stores/eyedropper.svelte.ts` (режим, фото, сэмпл) — проверить `npm run check`
|
||||||
|
- [x] 7.2 Добавить `whiteBalanceFromRGB` в `edit.ts` (обратная задача баланса белого) — проверить `npm run test` (`edit.test.ts`)
|
||||||
|
- [x] 7.3 Сэмплировать пиксель базового превью в `Viewer.svelte` по клику — проверить ручным сценарием
|
||||||
|
- [x] 7.4 Подключить кнопку пипетки и подсказку в `PhotoEditPanel.svelte`, пресет «Пользовательский» — проверить `npm run check`
|
||||||
|
- [x] 7.5 Покрыть расчёт нейтральной точки тестом — проверить `npm run test`
|
||||||
|
|
||||||
|
## 8. UI и тональные правки
|
||||||
|
|
||||||
|
- [x] 8.1 Добавить `EditSlider.svelte` (градиентная/заполненная дорожка, двойной клик — сброс) — проверить `npm run check`
|
||||||
|
- [x] 8.2 Добавить `ColorWheel.svelte` (оттенок/насыщенность, двойной клик — сброс) — проверить открытие/вращение
|
||||||
|
- [x] 8.3 Переименовать «Красивость» → «Сочность» — проверить подпись ползунка
|
||||||
|
- [x] 8.4 Сузить веса зон теней/светов и сделать яркость аддитивной — проверить отсутствие резкой границы на контрастном снимке
|
||||||
|
- [x] 8.5 Добавить пресеты баланса белого и «Пользовательский» — проверить переключение пресетов
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Архивировать изменение после удовлетворения требований ревью проекта.
|
||||||
|
- Проверить архивированный результат.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-23
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Сейчас кривая хранится в `light.curve` как точки на канал, `normalizeCurve`
|
||||||
|
сортирует/зажимает/дедуплицирует их, а по умолчанию массив пуст. UI рисует
|
||||||
|
статичные углы и прямые сегменты; `buildCurveLUT` (TS) и `curveLUT` (Go)
|
||||||
|
интерполируют линейно. Пресетов нет. Настройки профиля лежат в
|
||||||
|
`users.preferences` (JSON-блоб); `PATCH /auth/me/preferences` сейчас **заменяет**
|
||||||
|
весь объект и знает только `photo_info`, поэтому добавление пресетов без мержа
|
||||||
|
затирало бы другие разделы.
|
||||||
|
|
||||||
|
См. `proposal.md` — Why и `specs/photos/spec.md`, `specs/auth/spec.md` — требования.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Крайние узлы как обычные точки; первый клик вставляет между ними; узлы двигаются
|
||||||
|
по x/y; вне них кривая плоская.
|
||||||
|
- Плавная монотонная интерполяция в SVG-пути и в LUT; Go и TS считают одинаково.
|
||||||
|
- Встроенные пресеты контраста и пользовательские пресеты (кривые всех четырёх
|
||||||
|
каналов) в профиле; сохранение/применение/переименование/удаление;
|
||||||
|
импорт/экспорт JSON.
|
||||||
|
- Серверный мерж разделов настроек с валидацией.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Пресеты как «look» из четырёх кривых; Android-клиент; шаринг пресетов между
|
||||||
|
пользователями (только файл); undo/redo.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**Крайние точки — обычные узлы.**
|
||||||
|
- Первая и последняя точки массива являются крайними; по умолчанию это `(0,0)` и
|
||||||
|
`(1,1)`. Пустой канал в UI показывает эти две точки; первое взаимодействие
|
||||||
|
записывает их в `light.curve`.
|
||||||
|
- Вне крайних точек значение постоянно (текущее поведение `curveEval`), поэтому
|
||||||
|
сдвинутый по x узел даёт плоскую «полку», как на скрине.
|
||||||
|
- При перетаскивании точки остаются отсортированными по x; UI не даёт точке
|
||||||
|
пересечь соседнюю (x ограничивается между соседями), на коммите — `normalizeCurve`.
|
||||||
|
- Альтернатива: фиксировать x крайних на 0/1 — отвергнуто, пользователь хочет
|
||||||
|
двигать и по x.
|
||||||
|
|
||||||
|
**Интерполяция — монотонный кубический Эрмит (PCHIP).**
|
||||||
|
- Наклоны отрезков `d_i`, касательные по Fritsch–Carlson (нулевые при смене
|
||||||
|
знака/нулевом наклоне, ограничение против выбросов). Участок между точками
|
||||||
|
считается по кубической формуле Эрмита.
|
||||||
|
- SVG-путь: те же касательные переводятся в кубические Безье
|
||||||
|
(`P1 = P0 + (h/3, m_i*h/3)`, `P2 = P3 - (h/3, m_{i+1}*h/3)`), плюс плоские
|
||||||
|
сегменты от x=0 до первой и от последней до x=1.
|
||||||
|
- LUT 256: те же значения; `buildCurveLUT` (TS) и `curveLUT` (Go) обязаны
|
||||||
|
совпадать. WASM и WebGL получают готовый LUT из TS — их менять не нужно.
|
||||||
|
- Альтернативы: Catmull-Rom (выбросы/гало на тоновой кривой), линейная
|
||||||
|
интерполяция (текущая, отвергнута).
|
||||||
|
|
||||||
|
**Пресет — кривые всех четырёх каналов.**
|
||||||
|
- `CurvePreset { name: string; channels: { rgb: CurvePoint[]; red: CurvePoint[];
|
||||||
|
green: CurvePoint[]; blue: CurvePoint[] } }`.
|
||||||
|
- Каждый канал содержит 2..16 точек; пустых каналов в пресете нет — линейный
|
||||||
|
канал хранится как `(0,0),(1,1)`.
|
||||||
|
- Встроенные `Линейный`, `Средний контраст`, `Сильный контраст` — клиентские
|
||||||
|
константы. Контраст задаётся кривой RGB, каналы R/G/B линейные. Предлагаемые
|
||||||
|
точки RGB:
|
||||||
|
- Средний: `(0,0),(0.25,0.2),(0.5,0.5),(0.75,0.8),(1,1)`
|
||||||
|
- Сильный: `(0,0),(0.2,0.12),(0.5,0.5),(0.8,0.88),(1,1)`
|
||||||
|
- Сохранение берёт `effectiveCurvePoints` каждого канала, поэтому в пресет
|
||||||
|
всегда попадают все четыре кривые.
|
||||||
|
- Применение заменяет кривые всех четырёх каналов; текущий выбранный канал не
|
||||||
|
меняется.
|
||||||
|
- Подпись: имя пресета, если кривые всех четырёх каналов совпадают с пресетом
|
||||||
|
(с допуском), иначе `Пользовательский`.
|
||||||
|
|
||||||
|
**Хранение и мерж настроек.**
|
||||||
|
- `preferencesPayload` расширяется `curve_presets`; поля-указатели позволяют
|
||||||
|
отличить «не передано» от «пусто».
|
||||||
|
- Handler читает текущие `users.preferences`, мержит переданные разделы
|
||||||
|
(`photo_info`, `curve_presets`), валидирует, пишет и возвращает полный объект.
|
||||||
|
Сохранение «Сведений» с `{photo_info}` сохраняет пресеты; сохранение пресетов
|
||||||
|
сохраняет `photo_info`.
|
||||||
|
- Валидация: не больше 100 пресетов, имя 1..64, все четыре канала присутствуют
|
||||||
|
и содержат 2..16 точек, `x,y` конечные в 0..1. Некорректный раздел
|
||||||
|
отклоняется целиком.
|
||||||
|
- Альтернативы: отдельный endpoint (больше поверхности), клиент шлёт весь объект
|
||||||
|
(устаревшие вкладки затирают чужие изменения).
|
||||||
|
|
||||||
|
**Переключение фото.**
|
||||||
|
- Вкладка правой панели хранится в `AlbumPanel` и передаётся в `PhotoPanel`
|
||||||
|
через `bind:tab`, поэтому переживает перемонтирование панели при загрузке
|
||||||
|
следующего фото.
|
||||||
|
- Cleanup эффекта `PhotoEditPanel` регистрируется на каждом запуске; при смене
|
||||||
|
`photo.id` накопленные правки отправляются до инициализации нового фото.
|
||||||
|
- Ответы сохранений проверяются по id фото и номеру операции: устаревшее
|
||||||
|
сохранение не подменяет текущее фото и его параметры. Панель рендерит только
|
||||||
|
фото, совпадающее с текущим маршрутом.
|
||||||
|
|
||||||
|
**Пользовательские действия и файлы.**
|
||||||
|
- Сохранить: диалог имени, кривые всех четырёх каналов; при совпадении имени —
|
||||||
|
подтверждение перезаписи.
|
||||||
|
- Переименовать/удалить: действия в меню пользовательского пресета.
|
||||||
|
- Экспорт: JSON `{ version: 1, presets: CurvePreset[] }` с кривыми всех каналов,
|
||||||
|
только пользовательские пресеты (встроенные всегда доступны).
|
||||||
|
- Импорт: файл-пикер, валидация, upsert по имени, сохранение через preferences;
|
||||||
|
при некорректном файле профиль не меняется.
|
||||||
|
|
||||||
|
**Совместимость формата.**
|
||||||
|
- Старый формат `{ name, channel, points }` читается из профиля и импорта:
|
||||||
|
точки попадают в указанный канал, остальные становятся линейными.
|
||||||
|
- При следующем сохранении список перезаписывается новым форматом со всеми
|
||||||
|
четырьмя каналами.
|
||||||
|
|
||||||
|
**UI.**
|
||||||
|
- Статичный текст «Линейный» заменяется кнопкой, открывающей popover-меню:
|
||||||
|
встроенные, пользовательские, «Сохранить…», «Импортировать…», «Экспортировать…».
|
||||||
|
- В SVG крайние точки — такие же перетаскиваемые кружки, как остальные; первый
|
||||||
|
клик по свободному полю вставляет точку между крайними; двойной клик сбрасывает.
|
||||||
|
- Сохраняется существующая клавиатурная правка точек.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
[Старые кривые начнут выглядеть иначе (плавно вместо линейно)] → это осознанное
|
||||||
|
изменение рендера; точки не мигрируются, плоские участки вне крайних сохраняются.
|
||||||
|
|
||||||
|
[Расхождение PCHIP в Go и TS] → одна формула, тесты с захардкоженными значениями
|
||||||
|
LUT в обоих языках; WASM/WebGL используют готовый LUT.
|
||||||
|
|
||||||
|
[Гонка при мерже preferences] → мерж на сервере по разделам: разные разделы не
|
||||||
|
затирают друг друга; одновременная запись одного раздела — last-write-wins,
|
||||||
|
приемлемо для настроек одного пользователя.
|
||||||
|
|
||||||
|
[Рост списка пресетов / большой импорт] → лимиты количества, размера имени и
|
||||||
|
точек; handler уже ограничивает тело 1 МБ.
|
||||||
|
|
||||||
|
[Старые пресеты хранятся в формате одного канала] → клиент конвертирует их при
|
||||||
|
чтении/импорте, а первое сохранение пресетов перезаписывает профиль новым
|
||||||
|
форматом.
|
||||||
|
|
||||||
|
[Пересечение точек при перетаскивании] → ограничение x соседями и нормализация
|
||||||
|
на коммите.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Схема не меняется: `curve_presets` — ключ в JSON-блобе `users.preferences`.
|
||||||
|
Старый формат пресетов конвертируется на клиенте при чтении. Откат: удалить
|
||||||
|
ключ; старые кривые остаются в `light.curve` без изменений данных.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет. Точные точки «Среднего/Сильного контраста» — настраиваемый дефолт, не влияет
|
||||||
|
на контракт.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Тоновая кривая сейчас ведёт себя не как инструмент из Lightroom: крайние узлы не
|
||||||
|
существуют до первых кликов, линия строится по прямым сегментам, а готовых
|
||||||
|
пресетов контраста и пользовательских пресетов нет. Пользователь не может быстро
|
||||||
|
применить типовой контраст или сохранить свою кривую. Дополнительно
|
||||||
|
`PATCH /auth/me/preferences` заменяет весь объект настроек, поэтому хранение
|
||||||
|
пресетов в профиле без мержа затирало бы другие разделы.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Кривая.** Крайние узлы `(0,0)` и `(1,1)` существуют сразу и являются обычными
|
||||||
|
точками; первый клик добавляет точку между ними. Крайние узлы двигаются по x и
|
||||||
|
y, вне них кривая постоянна («полки», как на скрине). Линия и рендер используют
|
||||||
|
плавную монотонную кубическую интерполяцию (PCHIP): SVG-путь и LUT в Go/TS.
|
||||||
|
«Сбросить» возвращает линейную кривую.
|
||||||
|
- **Встроенные пресеты.** `Линейный`, `Средний контраст`, `Сильный контраст` —
|
||||||
|
каждый описывает все четыре канала: контраст задаётся кривой RGB, каналы
|
||||||
|
R/G/B остаются линейными. Подпись показывает активный пресет; при ручной
|
||||||
|
правке любого канала — `Пользовательский`.
|
||||||
|
- **Пользовательские пресеты.** Пресет описывает кривые всех каналов
|
||||||
|
(`name`, `channels: { rgb, red, green, blue }`). Сохранение текущих кривых
|
||||||
|
всех каналов, применение заменяет все четыре кривые, переименование и
|
||||||
|
удаление. Хранение в `users.preferences.curve_presets` (профиль пользователя),
|
||||||
|
приватно для пользователя.
|
||||||
|
- **Мерж настроек.** `PATCH /auth/me/preferences` принимает `photo_info` и
|
||||||
|
`curve_presets` по отдельности, мержит переданные разделы с сохранёнными,
|
||||||
|
валидирует и возвращает полный merged-объект. Сохранение «Сведений» больше не
|
||||||
|
затирает пресеты, и наоборот.
|
||||||
|
- **Импорт/экспорт.** JSON-файл `{ version: 1, presets: [...] }`: экспорт
|
||||||
|
пользовательских пресетов, импорт с добавлением в профиль (при совпадении имени
|
||||||
|
— перезапись).
|
||||||
|
- **Совместимость.** Миграций схемы нет: пресеты лежат в JSON-блобе
|
||||||
|
`preferences`. Старые пресеты формата `{ channel, points }` конвертируются в
|
||||||
|
четыре канала при чтении и перезаписываются при следующем сохранении.
|
||||||
|
Существующие сохранённые кривые начнут интерполироваться плавно, поэтому их
|
||||||
|
визуальный результат может немного измениться.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет — поведение входит в существующие способности `photos` и `auth`.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `photos`: добавляются требования «Тоновая кривая» (крайние узлы, плавная
|
||||||
|
интерполяция, встроенные пресеты, сброс) и «Пользовательские пресеты кривой»
|
||||||
|
(пресет из четырёх каналов, сохранение/применение/переименование/удаление,
|
||||||
|
импорт/экспорт).
|
||||||
|
- `auth`: требование «Хранение пользовательских настроек интерфейса»
|
||||||
|
расширяется мержем разделов и хранением пользовательских пресетов кривой;
|
||||||
|
добавляется сценарий «сохранение одного раздела не затирает другие».
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `backend/internal/auth/handlers.go` — `preferencesPayload` с `curve_presets`,
|
||||||
|
мерж разделов, валидация имени и кривых всех четырёх каналов.
|
||||||
|
- `backend/internal/auth/store.go` / `sqlite_store.go` — без изменений схемы;
|
||||||
|
запись merged-JSON через существующий `UpdatePreferences`.
|
||||||
|
- `backend/internal/photos/edit.go` — `curveEval`/`curveLUT` переходят на PCHIP;
|
||||||
|
тесты Go на гладкость/LUT.
|
||||||
|
- `frontend/src/lib/edit.ts` — PCHIP в `buildCurveLUT`, типы и нормализация
|
||||||
|
пресетов, точки кривой.
|
||||||
|
- `frontend/src/lib/components/PhotoEditPanel.svelte` — узлы-углы, плавный SVG-путь,
|
||||||
|
меню пресетов, сохранение/переименование/удаление, импорт/экспорт.
|
||||||
|
- `frontend/src/lib/types.ts`, `stores/auth.svelte.ts`, `api.ts` — тип
|
||||||
|
`Preferences.curve_presets`, сохранение через существующий endpoint.
|
||||||
|
- Новые вспомогательные модули: меню/диалоги пресетов, импорт/экспорт JSON.
|
||||||
|
- Тесты: `edit_test.go`, `edit.test.ts`, тесты мержа/валидации preferences,
|
||||||
|
e2e сохранения пресетов в профиле.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Хранение пользовательских настроек интерфейса
|
||||||
|
Система SHALL хранить пользовательские настройки (отображение интерфейса и пресеты кривой) в профиле пользователя и SHALL возвращать их при получении текущего пользователя. Настройки SHALL быть приватными для пользователя. Отсутствие настроек SHALL трактоваться как значения по умолчанию. Сохранение одного раздела настроек SHALL не затирать остальные разделы.
|
||||||
|
|
||||||
|
#### Scenario: Получение настроек
|
||||||
|
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
||||||
|
- **THEN** ответ содержит его настройки отображения и пресеты кривой (или пустой объект по умолчанию)
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь сохраняет настройки
|
||||||
|
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
||||||
|
|
||||||
|
#### Scenario: Сохранение раздела не затирает другие
|
||||||
|
- **WHEN** пользователь сохраняет только один раздел настроек (например, сведения о фото или пресеты кривой)
|
||||||
|
- **THEN** остальные разделы профиля сохраняются без изменений
|
||||||
|
|
||||||
|
#### Scenario: Изоляция настроек
|
||||||
|
- **WHEN** другой пользователь запрашивает свой профиль
|
||||||
|
- **THEN** он не получает настройки первого пользователя
|
||||||
|
|
||||||
|
#### Scenario: Некорректные настройки
|
||||||
|
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
|
||||||
|
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Тоновая кривая
|
||||||
|
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
|
||||||
|
|
||||||
|
#### Scenario: Крайние узлы существуют с самого начала
|
||||||
|
- **WHEN** пользователь открывает инструмент «Кривая»
|
||||||
|
- **THEN** у каждого канала уже есть крайние точки, и их можно перетаскивать
|
||||||
|
|
||||||
|
#### Scenario: Добавление точки
|
||||||
|
- **WHEN** пользователь кликает по свободному полю кривой
|
||||||
|
- **THEN** между крайними точками добавляется новая точка, а крайние остаются на месте
|
||||||
|
|
||||||
|
#### Scenario: Подвижные крайние узлы
|
||||||
|
- **WHEN** пользователь двигает крайнюю точку по x или y
|
||||||
|
- **THEN** вне крайних точек кривая остаётся плоской, а между точками — плавной
|
||||||
|
|
||||||
|
#### Scenario: Плавная интерполяция
|
||||||
|
- **WHEN** кривая сохранена и отрендерена
|
||||||
|
- **THEN** предпросмотр и итоговое фото используют одну и ту же плавную монотонную кривую
|
||||||
|
|
||||||
|
#### Scenario: Встроенные пресеты
|
||||||
|
- **WHEN** пользователь выбирает «Линейный», «Средний контраст» или «Сильный контраст»
|
||||||
|
- **THEN** кривые всех четырёх каналов заменяются кривыми пресета (контраст — в RGB, R/G/B — линейные), а подпись показывает выбранный пресет
|
||||||
|
|
||||||
|
#### Scenario: Пользовательская правка
|
||||||
|
- **WHEN** пользователь вручную меняет точки кривой
|
||||||
|
- **THEN** подпись показывает «Пользовательский»
|
||||||
|
|
||||||
|
#### Scenario: Сброс
|
||||||
|
- **WHEN** пользователь сбрасывает кривую
|
||||||
|
- **THEN** текущий канал возвращается к линейной кривой с крайними точками
|
||||||
|
|
||||||
|
#### Scenario: Независимые каналы
|
||||||
|
- **WHEN** пользователь выбирает канал R, G или B
|
||||||
|
- **THEN** он меняет только выбранный канал, остальные каналы остаются прежними
|
||||||
|
|
||||||
|
### Requirement: Переключение фото в редакторе
|
||||||
|
При переключении фото правая панель SHALL сохранять выбранную вкладку. Несохранённые правки SHALL сохраняться тому фото, для которого они сделаны, в том числе если пользователь уже переключился на другое фото. Ответы фоновых запросов SHALL NOT подменять текущее фото или его параметры.
|
||||||
|
|
||||||
|
#### Scenario: Вкладка не меняется
|
||||||
|
- **WHEN** пользователь переключает фото
|
||||||
|
- **THEN** выбранная вкладка правой панели остаётся прежней
|
||||||
|
|
||||||
|
#### Scenario: Правки уходят текущему фото
|
||||||
|
- **WHEN** пользователь меняет настройки после переключения
|
||||||
|
- **THEN** изменения сохраняются текущему фото, а не предыдущему
|
||||||
|
|
||||||
|
#### Scenario: Фоновое сохранение не подменяет фото
|
||||||
|
- **WHEN** сохранение предыдущего фото завершается после переключения
|
||||||
|
- **THEN** панель продолжает показывать текущее фото, а его параметры не меняются
|
||||||
|
|
||||||
|
### Requirement: Пользовательские пресеты кривой
|
||||||
|
Пользователь SHALL сохранять текущие кривые всех каналов (RGB, R, G и B) как именованный пресет, применять, переименовывать и удалять его. Пресет SHALL содержать кривую для каждого из четырёх каналов. Пресеты SHALL храниться в профиле пользователя и SHALL быть приватными. Импорт и экспорт SHALL выполняться JSON-файлом; импорт SHALL добавлять пресеты в профиль.
|
||||||
|
|
||||||
|
#### Scenario: Сохранение пресета
|
||||||
|
- **WHEN** пользователь сохраняет текущие кривые под именем
|
||||||
|
- **THEN** пресет появляется в списке и сохраняется в профиле вместе с кривыми RGB, R, G и B
|
||||||
|
|
||||||
|
#### Scenario: Применение пресета
|
||||||
|
- **WHEN** пользователь выбирает пользовательский пресет
|
||||||
|
- **THEN** кривые всех четырёх каналов заменяются сохранёнными, а текущий канал не меняется
|
||||||
|
|
||||||
|
#### Scenario: Переименование и удаление
|
||||||
|
- **WHEN** пользователь переименовывает или удаляет пресет
|
||||||
|
- **THEN** список пресетов и профиль обновляются
|
||||||
|
|
||||||
|
#### Scenario: Приватность пресетов
|
||||||
|
- **WHEN** другой пользователь открывает свои пресеты
|
||||||
|
- **THEN** он не видит пресеты первого пользователя
|
||||||
|
|
||||||
|
#### Scenario: Экспорт пресетов
|
||||||
|
- **WHEN** пользователь экспортирует пресеты
|
||||||
|
- **THEN** он получает JSON-файл с пользовательскими пресетами и кривыми всех четырёх каналов
|
||||||
|
|
||||||
|
#### Scenario: Импорт пресетов
|
||||||
|
- **WHEN** пользователь импортирует корректный JSON-файл
|
||||||
|
- **THEN** пресеты добавляются или обновляются в его профиле
|
||||||
|
|
||||||
|
#### Scenario: Некорректный файл или пресет
|
||||||
|
- **WHEN** имя или кривая хотя бы одного канала недопустимы
|
||||||
|
- **THEN** система отклоняет пресет и не меняет профиль
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Общая математика кривой (TS + Go)
|
||||||
|
|
||||||
|
- [x] 1.1 Заменить линейную интерполяцию в `frontend/src/lib/edit.ts` на монотонный кубический Эрмит (PCHIP): наклоны, касательные, оценка между точками, плоские участки вне крайних — проверить `npm run test` (`edit.test.ts`, значения LUT и монотонность)
|
||||||
|
- [x] 1.2 Повторить ту же формулу в `backend/internal/photos/edit.go` (`curveEval`/`curveLUT`) — проверить `go test ./internal/photos/ -run TestCurve -v`
|
||||||
|
- [x] 1.3 Сверить Go и TS на нескольких кривых (линейная, средний/сильный контраст, кривая с крайними внутри) захардкоженными значениями LUT — проверить оба набора тестов
|
||||||
|
|
||||||
|
## 2. Backend: мерж настроек и пресеты
|
||||||
|
|
||||||
|
- [x] 2.1 Расширить `preferencesPayload` в `backend/internal/auth/handlers.go` полем `curve_presets` и валидацией (лимит количества, имя 1..64, 2..16 точек на канал, x/y в 0..1) — проверить `go test ./internal/auth/ -v`
|
||||||
|
- [x] 2.2 Сделать серверный мерж разделов: читать текущие `preferences`, мержить `photo_info`/`curve_presets`, писать и возвращать merged-объект — проверить тестом «сохранение photo_info не затирает пресеты и наоборот»
|
||||||
|
- [x] 2.3 Добавить e2e-проверку сохранения пресетов в профиле и их приватности — проверить `go test ./internal/e2e/ -run TestCurvePresets -v`
|
||||||
|
|
||||||
|
## 3. UI: узлы и плавная кривая
|
||||||
|
|
||||||
|
- [x] 3.1 Показывать крайние точки `(0,0)` и `(1,1)` по умолчанию; первый клик вставляет точку между ними; крайние двигаются по x/y, вне них — плоские участки — проверить вручную и `npm run check`
|
||||||
|
- [x] 3.2 Рисовать SVG-путь кубическими Безье по тем же касательным (плоские сегменты до/после крайних) — проверить визуально, что линия плавная
|
||||||
|
- [x] 3.3 Сохранить клавиатурную правку точек, двойной клик-сброс и ограничение x между соседями — проверить `npm run check` и ручным сценарием
|
||||||
|
|
||||||
|
## 4. UI: пресеты и файлы
|
||||||
|
|
||||||
|
- [x] 4.1 Заменить статичный «Линейный» на меню встроенных пресетов (`Линейный`, `Средний контраст`, `Сильный контраст`) с активной подписью/«Пользовательский» — проверить `npm run check`
|
||||||
|
- [x] 4.2 Добавить тип `Preferences.curve_presets` и сохранение/применение/переименование/удаление пользовательских пресетов через существующий endpoint — проверить `npm run test` и сохранение в профиле
|
||||||
|
- [x] 4.3 Реализовать импорт/экспорт JSON `{ version: 1, presets: [...] }` с валидацией и upsert по имени — проверить ручным импортом/экспортом и `npm run check`
|
||||||
|
|
||||||
|
## 5. Интеграция и проверка
|
||||||
|
|
||||||
|
- [x] 5.1 Прогнать `npm run check`, `npm run test`, `go test ./...`, `make build` — всё зелёное
|
||||||
|
- [x] 5.2 Проверить, что WebGL и WASM используют новый LUT, а старые сохранённые кривые рендерятся плавно без миграции данных — визуально и тестами
|
||||||
|
- [x] 5.3 Проверить приватность и лимиты пресетов (чужой профиль, слишком длинное имя, >16 точек) — тестами `internal/auth`/`internal/e2e`
|
||||||
|
|
||||||
|
## 6. Правка: пресет описывает все каналы
|
||||||
|
|
||||||
|
- [x] 6.1 Обновить `CurvePreset` в `frontend/src/lib/types.ts` и `curve-presets.ts`: `channels` для RGB/R/G/B, встроенные пресеты и поиск активного пресета по всем каналам — проверить `npm run test`
|
||||||
|
- [x] 6.2 Обновить серверную модель и валидацию `curve_presets` в `backend/internal/auth/handlers.go`: объект `channels` со всеми четырьмя каналами — проверить `go test ./internal/auth/ -v`
|
||||||
|
- [x] 6.3 Обновить сохранение и применение в `PhotoEditPanel.svelte` и `CurvePresetMenu.svelte`: сохранять и заменять все четыре канала, не переключать текущий канал — проверить `npm run check`
|
||||||
|
- [x] 6.4 Обновить импорт/экспорт и совместимость со старым форматом `{name, channel, points}`, тесты и e2e — проверить `npm run test` и `go test ./internal/e2e/ -run TestCurvePresets -v`
|
||||||
|
|
||||||
|
## 7. Переключение фото
|
||||||
|
|
||||||
|
- [x] 7.1 Сохранять вкладку правой панели при смене фото, вынеся её состояние в `AlbumPanel` — проверить в браузере
|
||||||
|
- [x] 7.2 Исправить регистрацию cleanup в `PhotoEditPanel`, чтобы накопленные правки отправлялись при переключении — проверить в браузере
|
||||||
|
- [x] 7.3 Защитить панель от устаревших ответов сохранения/загрузки и кратковременного `photo = null` — проверить `npm run check`
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Архивировать изменение после удовлетворения требований ревью проекта.
|
||||||
|
- Проверить архивированный результат.
|
||||||
@@ -59,18 +59,20 @@ E.164 — и паролю.
|
|||||||
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
|
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
|
||||||
|
|
||||||
### Requirement: Хранение пользовательских настроек интерфейса
|
### Requirement: Хранение пользовательских настроек интерфейса
|
||||||
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
|
Система SHALL хранить пользовательские настройки (отображение интерфейса и пресеты кривой) в профиле пользователя и SHALL возвращать их при получении текущего пользователя. Настройки SHALL быть приватными для пользователя. Отсутствие настроек SHALL трактоваться как значения по умолчанию. Сохранение одного раздела настроек SHALL не затирать остальные разделы.
|
||||||
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
|
|
||||||
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
|
|
||||||
|
|
||||||
#### Scenario: Получение настроек
|
#### Scenario: Получение настроек
|
||||||
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
||||||
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
|
- **THEN** ответ содержит его настройки отображения и пресеты кривой (или пустой объект по умолчанию)
|
||||||
|
|
||||||
#### Scenario: Сохранение настроек
|
#### Scenario: Сохранение настроек
|
||||||
- **WHEN** пользователь сохраняет настройки отображения
|
- **WHEN** пользователь сохраняет настройки
|
||||||
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
||||||
|
|
||||||
|
#### Scenario: Сохранение раздела не затирает другие
|
||||||
|
- **WHEN** пользователь сохраняет только один раздел настроек (например, сведения о фото или пресеты кривой)
|
||||||
|
- **THEN** остальные разделы профиля сохраняются без изменений
|
||||||
|
|
||||||
#### Scenario: Изоляция настроек
|
#### Scenario: Изоляция настроек
|
||||||
- **WHEN** другой пользователь запрашивает свой профиль
|
- **WHEN** другой пользователь запрашивает свой профиль
|
||||||
- **THEN** он не получает настройки первого пользователя
|
- **THEN** он не получает настройки первого пользователя
|
||||||
|
|||||||
@@ -53,3 +53,22 @@ Android-клиента и устанавливает его на выбранн
|
|||||||
#### Scenario: Явная цель
|
#### Scenario: Явная цель
|
||||||
- **WHEN** `make` запущен с именем цели, например `make frontend`
|
- **WHEN** `make` запущен с именем цели, например `make frontend`
|
||||||
- **THEN** выполняется именно эта цель
|
- **THEN** выполняется именно эта цель
|
||||||
|
|
||||||
|
### Requirement: Сборка WASM-модуля фильтров
|
||||||
|
Фильтры правок в браузере SHALL считаться WASM-модулем, собираемым из исходников на
|
||||||
|
Rust в каталоге `frontend/wasm/light`. Сборка фронтенда SHALL обновлять модуль перед
|
||||||
|
сборкой статики. Если инструмент сборки модуля недоступен, SHALL использоваться готовый
|
||||||
|
модуль из репозитория, а сборка SHALL NOT прерываться. Готовый модуль SHALL храниться
|
||||||
|
в репозитории, чтобы фронтенд собирался без Rust-инструментов.
|
||||||
|
|
||||||
|
#### Scenario: Сборка фронтенда с модулем
|
||||||
|
- **WHEN** выполняется сборка фронтенда
|
||||||
|
- **THEN** перед сборкой статики обновляется WASM-модуль фильтров
|
||||||
|
|
||||||
|
#### Scenario: Нет Rust-инструментов
|
||||||
|
- **WHEN** инструмент сборки модуля недоступен
|
||||||
|
- **THEN** используется готовый модуль из репозитория, а сборка завершается успешно
|
||||||
|
|
||||||
|
#### Scenario: Готовый модуль в репозитории
|
||||||
|
- **WHEN** репозиторий проверяется на чистоту
|
||||||
|
- **THEN** собранный модуль фильтров присутствует в нём
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
|
|
||||||
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
||||||
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
||||||
ролям original, thumb, medium, preview и playback.
|
ролям original, thumb, medium, preview, playback, edit, edit_thumb, edit_medium и
|
||||||
|
edit_preview.
|
||||||
|
|
||||||
#### Scenario: Привязка ролей
|
#### Scenario: Привязка ролей
|
||||||
|
|
||||||
@@ -42,10 +43,20 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
- **WHEN** видео обработано
|
- **WHEN** видео обработано
|
||||||
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
|
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
|
||||||
|
|
||||||
|
#### Scenario: Привязка отредактированной версии
|
||||||
|
|
||||||
|
- **WHEN** фото отредактировано и отредактированная версия отрендерена
|
||||||
|
- **THEN** фото ссылается на объекты edit, edit_thumb, edit_medium и edit_preview рядом с
|
||||||
|
исходными вариантами
|
||||||
|
|
||||||
### Requirement: Совместные объекты
|
### Requirement: Совместные объекты
|
||||||
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
||||||
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
||||||
объекты хранилища.
|
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
|
||||||
|
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
|
||||||
|
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
|
||||||
|
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
|
||||||
|
виде и SHALL NOT наследовать параметры правок.
|
||||||
|
|
||||||
#### Scenario: Копирование
|
#### Scenario: Копирование
|
||||||
- **WHEN** пользователь копирует фото в другой альбом
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
@@ -55,6 +66,16 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
- **WHEN** пользователь переносит фото в другой альбом
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
||||||
|
|
||||||
|
#### Scenario: Правка не распространяется на копии
|
||||||
|
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
|
||||||
|
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
|
||||||
|
остаётся в исходном виде
|
||||||
|
|
||||||
|
#### Scenario: Копия отредактированного фото
|
||||||
|
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
|
||||||
|
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
|
||||||
|
не наследует параметры правок
|
||||||
|
|
||||||
### Requirement: Сборка мусора по ссылкам
|
### Requirement: Сборка мусора по ссылкам
|
||||||
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
|
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
|
||||||
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
|
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
|
||||||
@@ -93,7 +114,9 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
однократным выполнением и повтором при сбое; готовый результат SHALL
|
однократным выполнением и повтором при сбое; готовый результат SHALL
|
||||||
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
|
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
|
||||||
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
|
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
|
||||||
SHALL различать короткие задания изображений и длительные задания видео.
|
SHALL различать короткие задания изображений и длительные задания видео. Рендер
|
||||||
|
отредактированных версий SHALL использовать отдельную очередь, чтобы правки не
|
||||||
|
блокировали генерацию обычных вариантов.
|
||||||
|
|
||||||
#### Scenario: Повтор после рестарта
|
#### Scenario: Повтор после рестарта
|
||||||
- **WHEN** процесс перезапускается с незавершёнными заданиями
|
- **WHEN** процесс перезапускается с незавершёнными заданиями
|
||||||
@@ -115,6 +138,10 @@ SHALL различать короткие задания изображений
|
|||||||
- **WHEN** выполняется длительная подготовка видео
|
- **WHEN** выполняется длительная подготовка видео
|
||||||
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
|
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
|
||||||
|
|
||||||
|
#### Scenario: Рендер правок не блокирует варианты
|
||||||
|
- **WHEN** в фоне выполняется рендер отредактированной версии
|
||||||
|
- **THEN** генерация обычных вариантов продолжается независимо от него
|
||||||
|
|
||||||
### Requirement: Аватар пользователя как объект журнала
|
### Requirement: Аватар пользователя как объект журнала
|
||||||
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
|
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
|
||||||
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
|
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
|
||||||
|
|||||||
@@ -283,21 +283,20 @@ SHALL быть доступно и для фото без альбома (нап
|
|||||||
- **THEN** контрольные суммы входят в данные полученных фото
|
- **THEN** контрольные суммы входят в данные полученных фото
|
||||||
|
|
||||||
### Requirement: Неразрушающее редактирование фотографии
|
### Requirement: Неразрушающее редактирование фотографии
|
||||||
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок
|
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок (свет и цвет) SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с исходными вариантами. Рендер SHALL выполняться по исходному объекту фото, а не по его уменьшенным вариантам. Исходный объект SHALL оставаться неизменным, а скачивание SHALL отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию. Правки SHALL применяться только к изображениям.
|
||||||
SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как
|
|
||||||
отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с
|
|
||||||
исходными вариантами. Исходный объект SHALL оставаться неизменным, а скачивание SHALL
|
|
||||||
отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию.
|
|
||||||
Правки SHALL применять только к изображениям.
|
|
||||||
|
|
||||||
#### Scenario: Сохранение правок
|
#### Scenario: Сохранение правок
|
||||||
- **WHEN** пользователь сохраняет правки фото
|
- **WHEN** пользователь сохраняет правки фото (свет и/или цвет)
|
||||||
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
||||||
|
|
||||||
#### Scenario: Фоновая подготовка версий
|
#### Scenario: Фоновая подготовка версий
|
||||||
- **WHEN** правки фото сохранены
|
- **WHEN** правки фото сохранены
|
||||||
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
||||||
|
|
||||||
|
#### Scenario: Рендер по оригиналу
|
||||||
|
- **WHEN** готовится отредактированная версия фото
|
||||||
|
- **THEN** световые и цветовые правки применяются к пикселям исходного объекта, а не к его уменьшенной копии
|
||||||
|
|
||||||
#### Scenario: Неразрушачность
|
#### Scenario: Неразрушачность
|
||||||
- **WHEN** фото отредактировано
|
- **WHEN** фото отредактировано
|
||||||
- **THEN** исходный объект и его варианты остаются неизменными
|
- **THEN** исходный объект и его варианты остаются неизменными
|
||||||
@@ -313,3 +312,118 @@ SHALL храниться в метаданных фото, а отредакти
|
|||||||
#### Scenario: Редактирование видео
|
#### Scenario: Редактирование видео
|
||||||
- **WHEN** пользователь пытается отредактировать видео
|
- **WHEN** пользователь пытается отредактировать видео
|
||||||
- **THEN** система отклоняет запрос
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Цветовые правки сохраняются и рендерятся
|
||||||
|
- **WHEN** пользователь сохраняет цветовые правки фото: баланс белого (температура/оттенок), сочность (vibrance), насыщенность, цветовой микшер по оттенкам, цветокоррекцию (тени/средние тона/света, яркость зон, наложение, баланс) или поканальную коррекцию
|
||||||
|
- **THEN** параметры цвета сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
||||||
|
|
||||||
|
#### Scenario: Цветовые правки применяются к пикселям исходного объекта
|
||||||
|
- **WHEN** готовится отредактированная версия фото с цветовыми правками
|
||||||
|
- **THEN** цветовые правки применяются к пикселям исходного объекта после световых правок, а не к его уменьшенной копии
|
||||||
|
|
||||||
|
#### Scenario: Пустые цветовые правки не сохраняются
|
||||||
|
- **WHEN** в цветовых параметрах заданы только наложение/баланс или оттенок колеса без силы и яркости
|
||||||
|
- **THEN** правки считаются пустыми и фото не получает цветовую версию
|
||||||
|
|
||||||
|
### Requirement: Пипетка баланса белого
|
||||||
|
Клиент SHALL позволять выбрать нейтральную точку на фото и SHALL устанавливать температуру и оттенок так, чтобы цвет этой точки стал нейтральным. Выборка SHALL браться из исходного (неотредактированного) превью, а пресет баланса белого SHALL переходить в «Пользовательский».
|
||||||
|
|
||||||
|
#### Scenario: Выбор нейтральной точки
|
||||||
|
- **WHEN** пользователь включает пипетку и кликает по нейтральному участку фото
|
||||||
|
- **THEN** температура и оттенок обновляются, и цвет выбранной точки становится нейтральным
|
||||||
|
|
||||||
|
#### Scenario: Пипетка доступна только в просмотрщике
|
||||||
|
- **WHEN** фото не открыто в просмотрщике
|
||||||
|
- **THEN** пипетка недоступна
|
||||||
|
|
||||||
|
### Requirement: Живой предпросмотр цветовых правок
|
||||||
|
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. После завершения рендера SHALL показываться серверная версия.
|
||||||
|
|
||||||
|
#### Scenario: Правки видны сразу
|
||||||
|
- **WHEN** пользователь двигает цветовой контрол
|
||||||
|
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
|
||||||
|
|
||||||
|
#### Scenario: Серверная версия заменяет предпросмотр
|
||||||
|
- **WHEN** серверный рендер цветовых правок завершён
|
||||||
|
- **THEN** просмотрщик показывает отрендеренную версию
|
||||||
|
|
||||||
|
### Requirement: Тоновая кривая
|
||||||
|
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
|
||||||
|
|
||||||
|
#### Scenario: Крайние узлы существуют с самого начала
|
||||||
|
- **WHEN** пользователь открывает инструмент «Кривая»
|
||||||
|
- **THEN** у каждого канала уже есть крайние точки, и их можно перетаскивать
|
||||||
|
|
||||||
|
#### Scenario: Добавление точки
|
||||||
|
- **WHEN** пользователь кликает по свободному полю кривой
|
||||||
|
- **THEN** между крайними точками добавляется новая точка, а крайние остаются на месте
|
||||||
|
|
||||||
|
#### Scenario: Подвижные крайние узлы
|
||||||
|
- **WHEN** пользователь двигает крайнюю точку по x или y
|
||||||
|
- **THEN** вне крайних точек кривая остаётся плоской, а между точками — плавной
|
||||||
|
|
||||||
|
#### Scenario: Плавная интерполяция
|
||||||
|
- **WHEN** кривая сохранена и отрендерена
|
||||||
|
- **THEN** предпросмотр и итоговое фото используют одну и ту же плавную монотонную кривую
|
||||||
|
|
||||||
|
#### Scenario: Встроенные пресеты
|
||||||
|
- **WHEN** пользователь выбирает «Линейный», «Средний контраст» или «Сильный контраст»
|
||||||
|
- **THEN** кривые всех четырёх каналов заменяются кривыми пресета (контраст — в RGB, R/G/B — линейные), а подпись показывает выбранный пресет
|
||||||
|
|
||||||
|
#### Scenario: Пользовательская правка
|
||||||
|
- **WHEN** пользователь вручную меняет точки кривой
|
||||||
|
- **THEN** подпись показывает «Пользовательский»
|
||||||
|
|
||||||
|
#### Scenario: Сброс
|
||||||
|
- **WHEN** пользователь сбрасывает кривую
|
||||||
|
- **THEN** текущий канал возвращается к линейной кривой с крайними точками
|
||||||
|
|
||||||
|
#### Scenario: Независимые каналы
|
||||||
|
- **WHEN** пользователь выбирает канал R, G или B
|
||||||
|
- **THEN** он меняет только выбранный канал, остальные каналы остаются прежними
|
||||||
|
|
||||||
|
### Requirement: Переключение фото в редакторе
|
||||||
|
При переключении фото правая панель SHALL сохранять выбранную вкладку. Несохранённые правки SHALL сохраняться тому фото, для которого они сделаны, в том числе если пользователь уже переключился на другое фото. Ответы фоновых запросов SHALL NOT подменять текущее фото или его параметры.
|
||||||
|
|
||||||
|
#### Scenario: Вкладка не меняется
|
||||||
|
- **WHEN** пользователь переключает фото
|
||||||
|
- **THEN** выбранная вкладка правой панели остаётся прежней
|
||||||
|
|
||||||
|
#### Scenario: Правки уходят текущему фото
|
||||||
|
- **WHEN** пользователь меняет настройки после переключения
|
||||||
|
- **THEN** изменения сохраняются текущему фото, а не предыдущему
|
||||||
|
|
||||||
|
#### Scenario: Фоновое сохранение не подменяет фото
|
||||||
|
- **WHEN** сохранение предыдущего фото завершается после переключения
|
||||||
|
- **THEN** панель продолжает показывать текущее фото, а его параметры не меняются
|
||||||
|
|
||||||
|
### Requirement: Пользовательские пресеты кривой
|
||||||
|
Пользователь SHALL сохранять текущие кривые всех каналов (RGB, R, G и B) как именованный пресет, применять, переименовывать и удалять его. Пресет SHALL содержать кривую для каждого из четырёх каналов. Пресеты SHALL храниться в профиле пользователя и SHALL быть приватными. Импорт и экспорт SHALL выполняться JSON-файлом; импорт SHALL добавлять пресеты в профиль.
|
||||||
|
|
||||||
|
#### Scenario: Сохранение пресета
|
||||||
|
- **WHEN** пользователь сохраняет текущие кривые под именем
|
||||||
|
- **THEN** пресет появляется в списке и сохраняется в профиле вместе с кривыми RGB, R, G и B
|
||||||
|
|
||||||
|
#### Scenario: Применение пресета
|
||||||
|
- **WHEN** пользователь выбирает пользовательский пресет
|
||||||
|
- **THEN** кривые всех четырёх каналов заменяются сохранёнными, а текущий канал не меняется
|
||||||
|
|
||||||
|
#### Scenario: Переименование и удаление
|
||||||
|
- **WHEN** пользователь переименовывает или удаляет пресет
|
||||||
|
- **THEN** список пресетов и профиль обновляются
|
||||||
|
|
||||||
|
#### Scenario: Приватность пресетов
|
||||||
|
- **WHEN** другой пользователь открывает свои пресеты
|
||||||
|
- **THEN** он не видит пресеты первого пользователя
|
||||||
|
|
||||||
|
#### Scenario: Экспорт пресетов
|
||||||
|
- **WHEN** пользователь экспортирует пресеты
|
||||||
|
- **THEN** он получает JSON-файл с пользовательскими пресетами и кривыми всех четырёх каналов
|
||||||
|
|
||||||
|
#### Scenario: Импорт пресетов
|
||||||
|
- **WHEN** пользователь импортирует корректный JSON-файл
|
||||||
|
- **THEN** пресеты добавляются или обновляются в его профиле
|
||||||
|
|
||||||
|
#### Scenario: Некорректный файл или пресет
|
||||||
|
- **WHEN** имя или кривая хотя бы одного канала недопустимы
|
||||||
|
- **THEN** система отклоняет пресет и не меняет профиль
|
||||||
|
|||||||
@@ -263,9 +263,9 @@ SHALL выдвигать правую панель поверх содержим
|
|||||||
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||||
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
|
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
|
||||||
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
|
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
|
||||||
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
вписанный вид, а из вписанного — в 100% от оригинала с центрированием по центру кадра.
|
||||||
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
При зуме SHALL догружаться оригинал изображения. Зум SHALL работать и в полноэкранном
|
||||||
работать и в полноэкранном режиме.
|
режиме.
|
||||||
|
|
||||||
#### Scenario: Зум колесом к указателю
|
#### Scenario: Зум колесом к указателю
|
||||||
- **WHEN** пользователь вращает колесо мыши над фото
|
- **WHEN** пользователь вращает колесо мыши над фото
|
||||||
@@ -278,16 +278,27 @@ SHALL выдвигать правую панель поверх содержим
|
|||||||
|
|
||||||
#### Scenario: Пробел
|
#### Scenario: Пробел
|
||||||
- **WHEN** пользователь нажимает `Space`
|
- **WHEN** пользователь нажимает `Space`
|
||||||
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
- **THEN** из вписанного вида изображение переходит в 100% от оригинала по центру кадра, а из
|
||||||
|
любого зума возвращается во вписанный
|
||||||
|
|
||||||
#### Scenario: Оригинал в зуме
|
#### Scenario: Оригинал в зуме
|
||||||
- **WHEN** пользователь входит в зум
|
- **WHEN** пользователь входит в зум
|
||||||
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||||
|
|
||||||
|
### Requirement: Навигация в зуме
|
||||||
|
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Клавиши
|
||||||
|
со стрелками в зуме SHALL перемещать изображение, а переключение соседних фото SHALL
|
||||||
|
оставаться за мышью и кнопками у краёв. В зуме пользователь SHALL перемещать изображение
|
||||||
|
перетаскиванием и прокруткой.
|
||||||
|
|
||||||
#### Scenario: Стрелки соседних фото
|
#### Scenario: Стрелки соседних фото
|
||||||
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||||
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||||
|
|
||||||
|
#### Scenario: Стрелки в зуме
|
||||||
|
- **WHEN** фото увеличено, и пользователь жмёт стрелки клавиатуры
|
||||||
|
- **THEN** изображение перемещается, а переключение соседних фото не происходит
|
||||||
|
|
||||||
#### Scenario: Панорамирование
|
#### Scenario: Панорамирование
|
||||||
- **WHEN** фото увеличено
|
- **WHEN** фото увеличено
|
||||||
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
||||||
@@ -782,27 +793,29 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
||||||
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
||||||
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
||||||
показывать отредактированную версию фото, когда она отрендерена.
|
показывать отредактированную версию фото, когда она отрендерена. Обновление фото SHALL
|
||||||
|
сохранять открытую вкладку панели.
|
||||||
#### Scenario: Отложенная отправка
|
|
||||||
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
|
||||||
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
|
|
||||||
|
|
||||||
#### Scenario: Вкладки панели
|
#### Scenario: Вкладки панели
|
||||||
- **WHEN** открыта правая панель фото
|
- **WHEN** открыта правая панель фото
|
||||||
- **THEN** видны вкладки «Сведения» и «Редактирование»
|
- **THEN** видны вкладки «Сведения» и «Редактирование»
|
||||||
|
|
||||||
|
#### Scenario: Отложенная отправка
|
||||||
|
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
||||||
|
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
|
||||||
|
|
||||||
#### Scenario: Сохранение правок
|
#### Scenario: Сохранение правок
|
||||||
- **WHEN** пользователь отпускает ползунок
|
- **WHEN** пользователь отпускает ползунок
|
||||||
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
||||||
|
|
||||||
#### Scenario: Правки видны сразу
|
#### Scenario: Правки видны сразу
|
||||||
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
|
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
|
||||||
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера
|
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера — и во
|
||||||
|
вписанном виде, и в зуме
|
||||||
|
|
||||||
#### Scenario: Фильтры считаются в браузере
|
#### Scenario: Фильтры считаются в браузере
|
||||||
- **WHEN** браузер рисует предпросмотр правок
|
- **WHEN** браузер рисует предпросмотр правок
|
||||||
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом
|
- **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом
|
||||||
|
|
||||||
#### Scenario: Сброс правок
|
#### Scenario: Сброс правок
|
||||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||||
@@ -811,3 +824,25 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
#### Scenario: Зум при правках
|
#### Scenario: Зум при правках
|
||||||
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
||||||
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
|
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
|
||||||
|
|
||||||
|
### Requirement: График тональной кривой
|
||||||
|
График кривой SHALL показывать гистограмму каналов фото: выбранный канал — своим
|
||||||
|
цветом, мастер-канал — всеми тремя. Гистограмма SHALL пересчитываться с учётом
|
||||||
|
текущих правок. Точки кривой SHALL добавляться кликом по полю графика, перетаскиваться
|
||||||
|
и перемещаться стрелками клавиатуры. Блок «Кривая» SHALL сворачиваться и разворачиваться
|
||||||
|
по клику на заголовок, по умолчанию SHALL быть свёрнут, а кнопки выбора канала SHALL
|
||||||
|
оставаться доступными.
|
||||||
|
|
||||||
|
#### Scenario: Живая гистограмма
|
||||||
|
- **WHEN** пользователь двигает ползунок или точку кривой
|
||||||
|
- **THEN** гистограмма на графике пересчитывается с учётом текущих правок
|
||||||
|
|
||||||
|
#### Scenario: Точки кривой
|
||||||
|
- **WHEN** пользователь кликает по полю графика, перетаскивает точку или жмёт стрелки с
|
||||||
|
фокусом на точке
|
||||||
|
- **THEN** точка добавляется, перемещается и меняет кривую
|
||||||
|
|
||||||
|
#### Scenario: Сворачивание кривой
|
||||||
|
- **WHEN** пользователь открывает инструмент «Свет»
|
||||||
|
- **THEN** блок «Кривая» свёрнут, кнопки выбора канала доступны, а по клику на заголовок
|
||||||
|
график разворачивается и сворачивается обратно
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Отчёт: изменения, сделанные без спецификаций
|
||||||
|
|
||||||
|
- **Дата:** 2026-10-21
|
||||||
|
- **Диапазон:** `45a953f^..eb2d478` — 21 коммит, 2026-10-20 — 2026-10-21
|
||||||
|
- **Ветка:** `develop`
|
||||||
|
- **Предмет:** правки веб-интерфейса просмотра и нового инструмента «Свет» (редактирование фото), сделанные без обновления спецификаций OpenSpec
|
||||||
|
- **Результат:** спеки дописаны (`748143c`), заведено изменение `fix-copy-edit-variant-sharing`
|
||||||
|
|
||||||
|
## 1. Метод
|
||||||
|
|
||||||
|
1. Разобран диапазон коммитов `45a953f^..eb2d478`: для каждого проверено, затронул ли
|
||||||
|
он `openspec/specs`.
|
||||||
|
2. Для коммитов без спек — прочитаны затронутые участки кода
|
||||||
|
(`frontend/src/lib/components/Viewer.svelte`, `PhotoEditPanel.svelte`,
|
||||||
|
`PhotoPanel.svelte`, `frontend/src/lib/edit.ts`, `backend/internal/photos/worker.go`,
|
||||||
|
`store.go`, `sqlite_store.go`).
|
||||||
|
3. Сопоставлено фактическое поведение с текстом существующих требований
|
||||||
|
`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`.
|
||||||
|
4. Каждое найденное расхождение зафиксировано как требование (SHALL) со сценариями
|
||||||
|
WHEN/THEN; после правок спеки проверены `openspec validate --specs`.
|
||||||
|
|
||||||
|
## 2. Сводка
|
||||||
|
|
||||||
|
| Показатель | Значение |
|
||||||
|
| --- | --- |
|
||||||
|
| Коммитов в диапазоне | 21 |
|
||||||
|
| Из них со спеками | 11 |
|
||||||
|
| Из них без спек | 10 |
|
||||||
|
| Затронутых спецификаций | 4 (`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`) |
|
||||||
|
| Новых требований | 3 («График тональной кривой», «Навигация в зуме», «Сборка WASM-модуля фильтров») |
|
||||||
|
| Уточнённых требований | 6 |
|
||||||
|
| Новых сценариев | 12 |
|
||||||
|
| Ошибок валидации | 0 (`23 passed, 0 failed`) |
|
||||||
|
|
||||||
|
## 3. Покрытие по коммитам
|
||||||
|
|
||||||
|
| Коммит | Изменение | Где описано |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `45a953f` | скрытие боковых панелей, хоткеи размера миниатюр | `ui-workspace` → «Скрытие боковых панелей» (уже было) |
|
||||||
|
| `50b9983` | размер миниатюр не меняется при скрытых панелях | там же, сценарий «Размер миниатюр при скрытых панелях» |
|
||||||
|
| `35f8439` | зум клавишами `+` / `-` | `ui-workspace` → «Зум в просмотре» (уже было) |
|
||||||
|
| `0a9e527` | лента миниатюр следует за текущим фото | уже было |
|
||||||
|
| `961dff1` | `Ctrl+F` — полноэкранный режим приложения | уже было |
|
||||||
|
| `afaaaab` | офлайн-режим с кешем эскизов и превью | `ui-workspace` → «Офлайн-режим веб-приложения» (уже было) |
|
||||||
|
| `0548b1a` | вкладка «Редактирование», инструмент «Свет» | `ui-workspace` → «Вкладка «Редактирование»» (уточнено) |
|
||||||
|
| `cd3d17c` | правки применяются сразу; вкладка не сбрасывается при обновлении фото | уточнено: «Обновление фото SHALL сохранять открытую вкладку панели» |
|
||||||
|
| `6d3d7cb` | ускорение рендера правок в 9 раз | сознательно не специфицировано (внутренняя оптимизация) |
|
||||||
|
| `4d2069d`, `520886a` | клиентский рендер правок, WASM-фильтры | «Фильтры считаются в браузере» (уже было) |
|
||||||
|
| `2adcb13` | предпросмотр правок работает и без зума | уточнено: «Правки видны сразу — и во вписанном виде, и в зуме» |
|
||||||
|
| `500ec10`, `f8ee937` | починка зума, центрирование | «Зум в просмотре»: `Space` = 100% **от оригинала по центру кадра** |
|
||||||
|
| `b2a509f` | в зуме стрелки панорамируют фото | **новое** требование «Навигация в зуме» |
|
||||||
|
| `2668d87` | искажение цветов в предпросмотре | уже было (сценарий про сверку с эталоном) |
|
||||||
|
| `d904b2f` | невидимая линия тональной кривой | сознательно не специфицировано (багфикс) |
|
||||||
|
| `b64574b` | гистограммы каналов в редакторе кривой | **новое** требование «График тональной кривой» |
|
||||||
|
| `48ed0bb` | гистограмма пересчитывается при правке | там же, сценарий «Живая гистограмма» |
|
||||||
|
| `332f35b`, `eb2d478` | сворачивание блока «Кривая», свёрнут по умолчанию | там же, сценарий «Сворачивание кривой» |
|
||||||
|
| `520886a` | сборка WASM-модуля фильтров (`npm run wasm`, cargo) | `dev-make-workflow` → **новое** требование «Сборка WASM-модуля фильтров» |
|
||||||
|
| `520886a`, `worker.go` | роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` | `media-objects` → «Журнал объектов» + сценарий «Привязка отредактированной версии» |
|
||||||
|
| `worker.go` | отдельная очередь рендера правок (`edit_jobs`) | `media-objects` → «Генерация вариантов» + «Рендер правок не блокирует варианты» |
|
||||||
|
| `worker.go` | рендер правок по исходному объекту | `photos` → «Неразрушающее редактирование фотографии» + «Рендер по оригиналу» |
|
||||||
|
|
||||||
|
## 4. Новые и уточнённые требования
|
||||||
|
|
||||||
|
**Новые требования:**
|
||||||
|
|
||||||
|
- `ui-workspace` → «График тональной кривой»: гистограмма каналов, живой пересчёт,
|
||||||
|
добавление и перетаскивание точек, сворачиваемый блок «Кривая» (по умолчанию свёрнут,
|
||||||
|
кнопки выбора канала остаются доступными).
|
||||||
|
- `ui-workspace` → «Навигация в зуме»: клавиши-стрелки панорамируют фото и не
|
||||||
|
переключают соседние снимки, переключение остаётся за мышью и кнопками у краёв.
|
||||||
|
- `dev-make-workflow` → «Сборка WASM-модуля фильтров»: Rust-исходники в
|
||||||
|
`frontend/wasm/light`, обновление модуля перед сборкой статики, откат на готовый
|
||||||
|
модуль из репозитория, если cargo недоступен.
|
||||||
|
|
||||||
|
**Уточнённые требования:**
|
||||||
|
|
||||||
|
- `ui-workspace` → «Вкладка «Редактирование»»: добавлено сохранение вкладки при
|
||||||
|
обновлении фото; предпросмотр правок работает в любом режиме просмотра.
|
||||||
|
- `ui-workspace` → «Зум в просмотре»: `Space` показывает 100% **от оригинала** по
|
||||||
|
центру кадра.
|
||||||
|
- `media-objects` → «Журнал объектов»: перечислены роли `edit`, `edit_thumb`,
|
||||||
|
`edit_medium`, `edit_preview`.
|
||||||
|
- `media-objects` → «Совместные объекты»: уточнено, что отредактированная версия
|
||||||
|
создаётся и привязывается только к фото, чьи правки её породили.
|
||||||
|
- `media-objects` → «Генерация вариантов»: рендер правок идёт в отдельной очереди.
|
||||||
|
- `photos` → «Неразрушающее редактирование фотографии»: рендер идёт по исходному
|
||||||
|
объекту, а не по уменьшенной копии.
|
||||||
|
|
||||||
|
## 5. Что сознательно осталось без спецификаций
|
||||||
|
|
||||||
|
| Изменение | Почему |
|
||||||
|
| --- | --- |
|
||||||
|
| `6d3d7cb` — ускорение рендера в 9 раз | внутренняя оптимизация, внешнее поведение не изменилось |
|
||||||
|
| `d904b2f` — видимость линии кривой | багфикс: требование «кривая показывается» уже было |
|
||||||
|
| `500ec10` — контейнер прокрутки зума | деталь вёрстки, покрыта требованием «Панорамирование» |
|
||||||
|
| правки переноса/центрирования внутри `f8ee937` | описаны пользовательские последствия, а не механизм |
|
||||||
|
|
||||||
|
## 6. Найденное расхождение кода и замысла
|
||||||
|
|
||||||
|
`Store.Clone` и `SQLiteStore.Clone` копируют **все** привязки `photo_objects`,
|
||||||
|
включая роли `edit*`, хотя воркер создаёт edit-объекты с владельцем
|
||||||
|
`[]string{photoID}` и комментирует это так: «The edit belongs to this photo only, so
|
||||||
|
the variants are not shared with the copies of the same original».
|
||||||
|
|
||||||
|
Последствия:
|
||||||
|
|
||||||
|
| Сценарий | Фактическое поведение | Ожидаемое |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Копия **до** правки | копия в исходном виде | так и должно быть |
|
||||||
|
| Копия **после** рендера | копия показывает отредактированную картинку, но `edit_params` пусты: в панели правок параметров нет, первая же новая правка перезаписывает обработку с нуля | копия в исходном виде |
|
||||||
|
|
||||||
|
Оформлено как изменение `fix-copy-edit-variant-sharing`
|
||||||
|
(`openspec/changes/fix-copy-edit-variant-sharing/`):
|
||||||
|
|
||||||
|
- `proposal.md` — зачем;
|
||||||
|
- `design.md` — фильтр ролей в `Clone` вместо правки выдачи URL, единый список
|
||||||
|
edit-ролей, разовая чистка существующих копий миграцией в обоих диалектах;
|
||||||
|
- `specs/media-objects/spec.md` — дельта требования «Совместные объекты»;
|
||||||
|
- `tasks.md` — 4 группы задач (список ролей → копирование → миграции → проверки).
|
||||||
|
|
||||||
|
## 7. Проверка
|
||||||
|
|
||||||
|
```
|
||||||
|
openspec validate --specs → 23 passed, 0 failed
|
||||||
|
openspec validate fix-copy-edit-variant-sharing --type change → valid
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Реализация `fix-copy-edit-variant-sharing`
|
||||||
|
|
||||||
|
Изменение реализовано полностью (9/9 задач):
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
| --- | --- |
|
||||||
|
| Общий список edit-ролей `EditRoles()` и предикат `IsEditRole()` | `backend/internal/photos/edit_render.go` |
|
||||||
|
| `Clone` не переносит edit-привязки (Postgres: `role <> ALL($3::text[])`) | `backend/internal/photos/store.go` |
|
||||||
|
| То же для SQLite (`role NOT IN (...)`) | `backend/internal/photos/sqlite_store.go` |
|
||||||
|
| Сброс правок использует общий список ролей | `backend/internal/photos/edit_service.go` |
|
||||||
|
| Разовая чистка копий, созданных до фикса | `backend/migrations/000016_*.sql`, `backend/migrations/sqlite/000008_*.sql` |
|
||||||
|
| Контрактная проверка копирования (оба хранилища) | `backend/internal/repo/contract/contract.go`, кейс `Clone` |
|
||||||
|
| Сервисные тесты: копия и миграция | `backend/internal/photos/copy_test.go`, `backend/internal/e2e/copy_edit_test.go` |
|
||||||
|
|
||||||
|
Проверки:
|
||||||
|
|
||||||
|
- `make test` (с Postgres) — весь бэкенд зелёный;
|
||||||
|
- контрактный кейс `Clone` проходит на SQLite и Postgres; без фикса он падает
|
||||||
|
(«clone shares the edited version»);
|
||||||
|
- e2e на SQLite: копия показывает исходник, `edit_params` пусты, миграция убирает
|
||||||
|
«чужие» привязки, не трогая привязки владельца;
|
||||||
|
- ручная проверка через HTTP и браузер: в просмотре копии грузится `thumb.webp`
|
||||||
|
(не `edit_preview`), все ползунки панели «Редактирование» в нуле.
|
||||||
|
|
||||||
|
## 9. Что дальше
|
||||||
|
|
||||||
|
- Ревью и архивация изменения `/opsx-archive` — дельта `media-objects` переедет в
|
||||||
|
основную спеку.
|
||||||
|
- Изменение требует Postgres для полного прогона: в этом окружении поднимался
|
||||||
|
временный контейнер `postgres:16-alpine` на порту 55432
|
||||||
|
(`TEST_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:55432/galo?sslmode=disable'`).
|
||||||
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Описание релиза Galo — 21 октября 2026
|
||||||
|
|
||||||
|
**База релиза:** `main` (`84ce4d1`, 10 октября 2026) → `develop` (`afe85f7`, 21 октября 2026)
|
||||||
|
**Объём:** 138 коммитов, 4 спецификации обновлены, 1 изменение реализовано
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коротко о главном
|
||||||
|
|
||||||
|
1. **Локальный режим без внешних сервисов.** Слой метаданных переключается на
|
||||||
|
встроенный SQLite, хранилище блобов — на локальную файловую систему. По умолчанию
|
||||||
|
приложение работает полностью на одной машине: без Postgres и без S3.
|
||||||
|
2. **Редактирование фотографий в браузере.** Вкладка «Редактирование» с инструментом
|
||||||
|
«Свет»: экспозиция, контраст, света, тени, белые и чёрные, тональная кривая по
|
||||||
|
каналам с живой гистограммой. Правки видны сразу, без ожидания сервера.
|
||||||
|
3. **Новый интерфейс рабочего пространства.** Альбомы с обложками, тёмная, светлая и
|
||||||
|
нейтральная темы, единая система векторных иконок, гало-эмблема и фавикон.
|
||||||
|
4. **Нативный Android-клиент.** Отдельное приложение: просмотр альбомов, загрузка с
|
||||||
|
устройства, двусторонняя синхронизация с сервером, безопасная разгрузка превью.
|
||||||
|
5. **Видео и офлайн.** Видео в общих альбомах с фоновой подготовкой и перемоткой;
|
||||||
|
офлайн-кеш оболочки, списков и превью.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новые возможности
|
||||||
|
|
||||||
|
### Локальный режим: SQLite и файловая система
|
||||||
|
|
||||||
|
- **Слой метаданных** выбирается конфигурацией: внешний PostgreSQL или встроенный
|
||||||
|
SQLite с тем же поведением для одного экземпляра приложения.
|
||||||
|
- **Хранилище блобов** выбирается отдельно: S3-совместимое (Garage) или локальная
|
||||||
|
файловая система. Клиент никогда не видит хранилище — блобы всегда отдаются через
|
||||||
|
бэкенд по подписанному URL.
|
||||||
|
- По умолчанию (`DB_BACKEND=sqlite`, `STORAGE_BACKEND=fs`) приложение поднимается без
|
||||||
|
Postgres и S3: данные лежат в `galo.db` и каталоге `data/`.
|
||||||
|
- **Секрет подписи JWT и URL объектов** генерируются при первом старте и хранятся в
|
||||||
|
слое метаданных — переживают перезапуск и рестарт SQLite.
|
||||||
|
- Реконсиляция хранилища с журналом объектов: `make reconcile` (отчёт),
|
||||||
|
`make reconcile-delete` (удаление осиротевших блобов). Сборку мусора ведёт журнал,
|
||||||
|
хранилище не сканируется.
|
||||||
|
|
||||||
|
### Слой объектов
|
||||||
|
|
||||||
|
- Физическое объектное хранилище с **непрозрачными ключами**: идентификаторы альбомов и
|
||||||
|
фото в ключах не участвуют, сопоставление живёт только в метаданных.
|
||||||
|
- **Журнал объектов**: у каждого объекта ровно одна запись с ключом, MIME-типом,
|
||||||
|
размером, габаритами и состоянием; фото ссылается на объекты по ролям
|
||||||
|
(`original`, `thumb`, `medium`, `preview`, `playback`, `edit`, `edit_thumb`,
|
||||||
|
`edit_medium`, `edit_preview`).
|
||||||
|
- Один физический объект может разделяться несколькими фото: перенос и копирование
|
||||||
|
меняют только ссылки, хранилище не трогается.
|
||||||
|
- Сборка мусора по ссылкам: объект удаляется, когда на него не ссылается ни одно фото
|
||||||
|
и ни один аватар.
|
||||||
|
|
||||||
|
### Редактирование фотографий
|
||||||
|
|
||||||
|
- Вкладка «Редактирование» в правой панели фото, инструмент «Свет»: экспозиция,
|
||||||
|
контраст, света, тени, белые, чёрные.
|
||||||
|
- **Тональная кривая**: выбор канала (R/G/B/мастер), точки добавляются кликом по
|
||||||
|
полю графика, перетаскиваются и двигаются стрелками с клавиатуры.
|
||||||
|
- **Гистограмма каналов** пересчитывается на лету с учётом текущих правок.
|
||||||
|
- Блок «Кривая» сворачивается (по умолчанию свёрнут), кнопки выбора канала остаются
|
||||||
|
доступными.
|
||||||
|
- Предпросмотр правок рисуется **в браузере** — WASM-модулем на Rust, с резервной
|
||||||
|
JS-реализацией, если модуль недоступен или считает иначе.
|
||||||
|
- Правки применяются сразу; на сервер уходят через 10 секунд без изменений или при
|
||||||
|
переключении фото. Сервер рендерит отредактированную версию в фоне отдельной
|
||||||
|
очередью и сообщает о готовности.
|
||||||
|
- Переключатель «Сброс» удаляет и параметры правок, и отредактированную версию.
|
||||||
|
- Редактирование неразрушающее: оригинал остаётся неизменным, скачивание отдаёт
|
||||||
|
исходник. Рендер идёт по исходному объекту, а не по уменьшенной копии.
|
||||||
|
|
||||||
|
### Интерфейс рабочего пространства
|
||||||
|
|
||||||
|
- Альбомы с обложками, «лайтрумовская» компоновка: рельс альбомов, сетка, единая
|
||||||
|
панель фотографии.
|
||||||
|
- Три темы — тёмная, светлая, нейтральная — с семантическими токенами; переключатель
|
||||||
|
тем переехал в шапку, нейтральная тема затемнена.
|
||||||
|
- **Единая система иконок**: встроенная векторная графика (Material Symbols) вместо
|
||||||
|
эмодзи и символов системного шрифта — иконки наследуют цвет темы и не тянут
|
||||||
|
внешние ресурсы.
|
||||||
|
- **Гало-эмблема** (диск солнца в отделённом кольце) и фирменный фавикон: отдаются
|
||||||
|
самим приложением, наследуют тему, читаются в размере 16–24 px.
|
||||||
|
- Панель сведений о фото со **сворачиваемыми секциями** и настраиваемым набором
|
||||||
|
атрибутов: состав EXIF хранится в профиле пользователя.
|
||||||
|
- Полноэкранный просмотр: своя панель, кнопка «на весь экран», возврат по `Esc`.
|
||||||
|
|
||||||
|
### Просмотр и навигация
|
||||||
|
|
||||||
|
- **Зум** колесом мыши (с привязкой к курсору, до 200%), клавишами `+` и `-`;
|
||||||
|
`Space` возвращает во вписанный вид, а из вписанного — в 100% от оригинала по
|
||||||
|
центру кадра.
|
||||||
|
- В зуме стрелки клавиатуры **панорамируют** фото, переключение соседних снимков
|
||||||
|
остаётся за мышью и кнопками у краёв.
|
||||||
|
- **Скрытие боковых панелей** альбома: кнопка в панели сетки и хоткей `Tab`. Панели
|
||||||
|
уезжают за края, содержимое занимает всю ширину, панели возвращаются при наведении
|
||||||
|
на кромку окна. Размер миниатюр при этом сохраняется — в строке помещается больше
|
||||||
|
фото.
|
||||||
|
- Лента миниатюр в просмотре следует за текущим фото; на превью работает наведение-
|
||||||
|
зум.
|
||||||
|
- Полноэкранный режим приложения по `Ctrl+F`, навигация с клавиатуры в сетке с
|
||||||
|
возвратом фокуса, открытие фото тапом на тач-экранах.
|
||||||
|
- Просмотр 360-панорам по тегу `pano360`, корректное направление перетаскивания.
|
||||||
|
- Пока грузится оригинал для зума — спиннер; на Android — щипок-зум.
|
||||||
|
|
||||||
|
### Работа с фотографиями
|
||||||
|
|
||||||
|
- Мультивыбор в сетке, контекстные меню, **перенос и копирование** снимков, корзина
|
||||||
|
на уровне альбома и общие счётчики.
|
||||||
|
- **Теги** фото: правки расходятся по сетке и просмотру.
|
||||||
|
- Переименование фото и удаление из просмотра с подтверждением.
|
||||||
|
- EXIF извлекается полностью при загрузке; для старых снимков — дозаполнение
|
||||||
|
(`cmd/backfill-exif`).
|
||||||
|
|
||||||
|
### Офлайн-режим
|
||||||
|
|
||||||
|
- Service worker кеширует оболочку приложения, списки и эскизы/превью фотографий:
|
||||||
|
открытые альбомы и ранее загруженные снимки доступны без сети.
|
||||||
|
- Модуль фильтров в офлайне не кешируется — он всегда подтягивается свежим.
|
||||||
|
|
||||||
|
### Видео
|
||||||
|
|
||||||
|
- Загрузка видео в те же альбомы, что и фото (`kind=video`).
|
||||||
|
- Фоновая подготовка: постер (кадр кадра → обычные варианты сетки) и web-совместимый
|
||||||
|
вариант для воспроизведения (ремукс без перекодирования либо транскод в
|
||||||
|
h264/yuv420p/aac с `+faststart`), с поддержкой перемотки (`Range`).
|
||||||
|
- Транскод идёт в отдельном малом пуле и не вытесняет генерацию вариантов
|
||||||
|
изображений; готовность транслируется подписчикам альбома.
|
||||||
|
- Без `ffmpeg`/`ffprobe` приём видео отклоняется, `/api/health` сообщает
|
||||||
|
`"video": "unavailable"`, изображения работают как прежде.
|
||||||
|
|
||||||
|
### Android-клиент
|
||||||
|
|
||||||
|
- Отдельный Gradle-модуль, работает с существующим HTTP API, сервер и веб не
|
||||||
|
трогает. Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`.
|
||||||
|
- **Двусторонняя синхронизация** альбома устройства с серверным альбомом: загрузки,
|
||||||
|
изменения состава, удаления.
|
||||||
|
- **Безопасная разгрузка**: с устройства уходит превью, оригинал остаётся на сервере;
|
||||||
|
потеря серверной копии распознаётся и показывается в интерфейсе.
|
||||||
|
- Статус синхронизации на карточке альбома, ход восстановления оригиналов,
|
||||||
|
сортировка списка, обновление списка альбомов в реальном времени.
|
||||||
|
- Установка на устройство одной командой: `make install-android-app` (с выбором
|
||||||
|
серийного номера при нескольких подключённых устройствах).
|
||||||
|
|
||||||
|
### Профиль и учётная запись
|
||||||
|
|
||||||
|
- Профиль пользователя: круглый аватар с загрузкой и кадрированием, публичная отдача
|
||||||
|
аватара по версионированной ссылке.
|
||||||
|
- Вход и регистрация по телефону (E.164) в дополнение к email, управление
|
||||||
|
идентификаторами, удаление аккаунта.
|
||||||
|
- Предпочтения пользователя (в том числе состав панели сведений) хранятся на сервере.
|
||||||
|
|
||||||
|
### Альбомы и живое обновление
|
||||||
|
|
||||||
|
- Обложки альбомов: отдельный жизненный цикл, обложка доводится до готовности и
|
||||||
|
доставляется подписчикам.
|
||||||
|
- Живое обновление: события жизненного цикла альбома и изменения состава фото через
|
||||||
|
SSE, подписчики видят изменения без перезагрузки.
|
||||||
|
- Квадратный вариант `medium` — резкие миниатюры в сетке; шаг переключения размера
|
||||||
|
миниатюр и направление ползунка приведены в порядок.
|
||||||
|
- Быстрая загрузка больших альбомов.
|
||||||
|
|
||||||
|
### Наблюдаемость и эксплуатация
|
||||||
|
|
||||||
|
- Настраиваемое логирование: `LOG_LEVEL` (`debug` включает access-логи и ожидаемые
|
||||||
|
4xx), `LOG_FORMAT` (`json`/`text`), request id в ответах и логах.
|
||||||
|
- `scripts/seed.py` и `make testdata` — наполнение запущенного инстанса тестовыми
|
||||||
|
данными через публичный API.
|
||||||
|
- Release-сборка: `make build-release` — статичный бинарник с вшитой версией и
|
||||||
|
UPX-сжатием, если он установлен.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исправления
|
||||||
|
|
||||||
|
- **Копия фото больше не наследует отредактированную версию.** Раньше копирование
|
||||||
|
переносило все привязки объектов, включая `edit*`: копия показывала
|
||||||
|
отредактированную картинку, но не наследовала параметры правок, и первая же новая
|
||||||
|
правка перезаписывала обработку с нуля. Теперь правка принадлежит своему фото, а
|
||||||
|
копия показывает исходник. Разовая миграция убирает привязки у копий, созданных до
|
||||||
|
фикса.
|
||||||
|
- Предпросмотр правок работает и без зума, а не только в увеличенном виде.
|
||||||
|
- Устранено искажение цветов в предпросмотре (WASM-фильтры считались по
|
||||||
|
преобразованным каналам).
|
||||||
|
- Гистограмма пересчитывается при правке, а не остаётся от исходного снимка.
|
||||||
|
- Линия тональной кривой видима в тёмной теме.
|
||||||
|
- Починен зум в просмотре: контейнер прокрутки и центрирование.
|
||||||
|
- Android: разгруженное превью больше не считается новой версией элемента.
|
||||||
|
- Сервер отдаёт контрольную сумму оригинала в ответе на загрузку — клиент может
|
||||||
|
сверить файл.
|
||||||
|
- Счётчик корзины и правки тегов синхронизируются с сеткой и просмотром.
|
||||||
|
- Ошибка загрузки крупного файла показывается явно, лимит поднят.
|
||||||
|
- `make run` пересобирает фронтенд, если он изменился.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические изменения
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Новые эндпоинты:
|
||||||
|
|
||||||
|
- `PATCH /api/photos/:id/edit` — сохранить параметры правок (тело: `{"light": {...}}`).
|
||||||
|
- `DELETE /api/photos/:id/edit` — сбросить правки вместе с отредактированной
|
||||||
|
версией.
|
||||||
|
- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`,
|
||||||
|
`POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences`,
|
||||||
|
`POST|DELETE /api/auth/me/avatar`, `GET /api/avatars/:userID/:version?size=large`.
|
||||||
|
- `POST /api/photo-batch/copy`, `POST /api/photo-batch/move` — пакетные операции над
|
||||||
|
снимками.
|
||||||
|
|
||||||
|
Изменившиеся ответы: у фото появились поля `edit_params`, `edit_url`,
|
||||||
|
`edit_thumb_url`, `edit_medium_url`, `edit_preview_url`; у видео — `playback_url`.
|
||||||
|
|
||||||
|
### Схема данных и миграции
|
||||||
|
|
||||||
|
- Postgres: миграции до `000016`; SQLite: до `000008` (нумерация диалектов
|
||||||
|
независимая, миграции вшиты в бинарник и применяются автоматически при старте;
|
||||||
|
`make migrate` применяет только Postgres).
|
||||||
|
- Новые роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` — рядом с
|
||||||
|
обычными `original`, `thumb`, `medium`, `preview`, `playback`.
|
||||||
|
- Новая колонка `photos.edit_params` — параметры неразрушающих правок.
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
- Фильтры правок считает WASM-модуль из `frontend/wasm/light` (Rust, `no_std`).
|
||||||
|
Пересборка нужна только при правке самого модуля; в репозитории лежит готовый
|
||||||
|
`light.wasm`, поэтому фронтенд собирается без Rust-инструментов.
|
||||||
|
- `npm run wasm` — отдельная пересборка модуля; обычная сборка фронтенда обновляет
|
||||||
|
его сама.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как обновиться
|
||||||
|
|
||||||
|
1. Собрать бинарник: `make build` (фронтенд → `backend/static` → `go:embed` → `bin/galo`)
|
||||||
|
или `make build-release` — с вшитой версией.
|
||||||
|
2. Остановить сервис, заменить бинарник. Фронтенк вшит в бинарник, отдельно
|
||||||
|
выкладывать нечего.
|
||||||
|
3. Запустить: миграции применятся автоматически при старте для выбранного бэкенда.
|
||||||
|
Для Postgres можно отдельно прогнать `make migrate`.
|
||||||
|
4. Для видео нужен `ffmpeg`/`ffprobe` на хосте; без них приём видео отключается.
|
||||||
|
5. Android-клиент ставится отдельно: `make install-android-app`.
|
||||||
|
|
||||||
|
Откат: бинарник возвращается предыдущей версии; миграции `down` не восстанавливают
|
||||||
|
удалённые привязки копий — это безопасно, отредактированные версии исходных фото
|
||||||
|
остаются на месте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Редактирование — только для изображений; видео не редактируется.
|
||||||
|
- Правки не порождают отдельной записи в истории: параметры хранятся в метаданных
|
||||||
|
фото, отредактированная версия — как отдельный объект.
|
||||||
|
- Офлайн-кеш ограничен оболочкой, списками и эскизами/превью: оригиналы для зума и
|
||||||
|
скачивания требуют сети.
|
||||||
|
- Android-клиент требует JDK 17 и Android SDK (`platforms;android-35`,
|
||||||
|
`build-tools;35.0.0`).
|
||||||
|
- Номер версии берётся из `git describe --tags`; в репозитории пока нет тегов, поэтому
|
||||||
|
версия в сборке — короткий SHA или `dev`. Для осмысленной нумерации нужен тег.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Для разработчиков
|
||||||
|
|
||||||
|
- Спецификации OpenSpec синхронизированы с поведением: обновлены `ui-workspace`
|
||||||
|
(редактирование, кривая, зум и навигация), `media-objects` (роли `edit*`, раздельное
|
||||||
|
владение правками, отдельная очередь рендера), `photos` (рендер по оригиналу),
|
||||||
|
`dev-make-workflow` (сборка WASM-модуля).
|
||||||
|
- Изменение `fix-copy-edit-variant-sharing` реализовано (9/9 задач) и ждёт ревью и
|
||||||
|
архивации: `openspec list` показывает его как `complete`.
|
||||||
|
- Контрактные тесты репозиториев (`internal/repo/contract`) гоняют один набор
|
||||||
|
проверок на SQLite и Postgres — новый кейс `Clone` проверяет, что копия не
|
||||||
|
наследует отредактированную версию, в обоих хранилищах.
|
||||||
|
- Интеграционные тесты Postgres требуют `TEST_DATABASE_URL`; без них они
|
||||||
|
пропускаются. Тесты e2e поднимают SQLite в `t.TempDir()` и внешних сервисов не
|
||||||
|
требуют.
|
||||||
|
- Сервис переименован в `galo`, бренд — «гало»; сборка `make build-release` пишет
|
||||||
|
`bin/galo-$(VERSION)`.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Описание релиза Galo — 23 октября 2026
|
||||||
|
|
||||||
|
**Версия:** `0.2.0`
|
||||||
|
**База релиза:** `0.1-alpha` (`723b19b`, 21 октября 2026) → `develop` (`9e39e3d`, 23 октября 2026)
|
||||||
|
**Объём:** 6 коммитов, 3 спецификации обновлены, 2 изменения реализованы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коротко о главном
|
||||||
|
|
||||||
|
1. **Инструмент «Цвет».** Баланс белого, вибрация и насыщенность, цветовой
|
||||||
|
микшер по восьми диапазонам оттенков, цветокоррекция по зонам яркости и
|
||||||
|
общая поканальная коррекция. Предпросмотр считает WebGL2, есть WASM/JS-фолбэк.
|
||||||
|
2. **Пресеты тоновой кривой.** Плавная монотонная кривая (PCHIP), подвижные
|
||||||
|
крайние точки и плоские полки. Встроенные и пользовательские пресеты всех
|
||||||
|
четырёх каналов хранятся в профиле, импортируются и экспортируются JSON-файлом.
|
||||||
|
3. **Настройки профиля больше не затирают друг друга.** Сохранение сведений о фото
|
||||||
|
и пресетов кривой мержится на сервере по разделам.
|
||||||
|
4. **Исправлено переключение фото в редакторе.** Вкладка правой панели сохраняется,
|
||||||
|
а несохранённые правки уходят тому фото, для которого сделаны.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новые возможности
|
||||||
|
|
||||||
|
### Инструмент «Цвет»
|
||||||
|
|
||||||
|
- **Баланс белого:** температура и оттенок, пипетка по нейтральной точке на фото.
|
||||||
|
- **Вибрация и насыщенность** с раздельными ползунками.
|
||||||
|
- **Цветовой микшер** по восьми диапазонам (красный, оранжевый, жёлтый, зелёный,
|
||||||
|
бирюзовый, синий, фиолетовый, пурпурный): тон, насыщенность и яркость каждого
|
||||||
|
диапазона.
|
||||||
|
- **Цветокоррекция по зонам:** колёса теней, средних тонов и светов, наложение и
|
||||||
|
баланс зон.
|
||||||
|
- **Поканальная коррекция** красного, зелёного и синего каналов.
|
||||||
|
- **Живой предпросмотр на WebGL2:** кадр считает GPU, поэтому изменения видны сразу.
|
||||||
|
Если WebGL2 недоступен, используется WASM/JS-фолбэк с тем же результатом.
|
||||||
|
- Ползунки с градиентами, цветовые колёса и сброс значения двойным кликом.
|
||||||
|
- Порядок операций совпадает в Go-рендере, WASM и JS-предпросмотре; паритет
|
||||||
|
закреплён тестами.
|
||||||
|
|
||||||
|
### Тоновая кривая и пресеты
|
||||||
|
|
||||||
|
- **Плавная кривая:** монотонная кубическая интерполяция (PCHIP) вместо прямых
|
||||||
|
отрезков — и в SVG-графике, и в LUT для предпросмотра и серверного рендера.
|
||||||
|
- **Крайние точки** `(0,0)` и `(1,1)` существуют сразу, двигаются по обеим осям;
|
||||||
|
вне крайних точек кривая остаётся плоской. Первый клик по полю вставляет точку
|
||||||
|
между крайними.
|
||||||
|
- **Встроенные пресеты:** «Линейный», «Средний контраст», «Сильный контраст».
|
||||||
|
Контраст задаётся в RGB, каналы R/G/B остаются линейными.
|
||||||
|
- **Пользовательские пресеты** описывают кривые всех четырёх каналов:
|
||||||
|
- сохранение, применение, переименование и удаление;
|
||||||
|
- хранение в `users.preferences.curve_presets` в профиле пользователя;
|
||||||
|
- приватность: чужие пресеты не видны;
|
||||||
|
- импорт и экспорт JSON `{ "version": 1, "presets": [...] }`, upsert по имени;
|
||||||
|
- старый формат одного канала конвертируется при чтении.
|
||||||
|
- Подпись показывает активный пресет; при ручной правке любого канала —
|
||||||
|
«Пользовательский». Сброс возвращает линейную кривую.
|
||||||
|
|
||||||
|
### Настройки профиля
|
||||||
|
|
||||||
|
- `PATCH /api/auth/me/preferences` теперь **мержит** разделы, а не заменяет весь
|
||||||
|
JSON-объект.
|
||||||
|
- Сохранение `photo_info` не стирает `curve_presets`, и наоборот.
|
||||||
|
- Валидация: не больше 100 пресетов, имя 1..64 символа, 2..16 точек на каждый из
|
||||||
|
четырёх каналов, `x/y` в диапазоне 0..1.
|
||||||
|
- Неизвестные разделы настроек сохраняются без изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исправления
|
||||||
|
|
||||||
|
- **Переключение фото в редакторе.** Вкладка правой панели больше не сбрасывается
|
||||||
|
на «Сведения». Накопленные правки отправляются фото, для которого сделаны, даже
|
||||||
|
если пользователь уже перешёл к следующему.
|
||||||
|
- **Фоновые сохранения не подменяют текущее фото.** Устаревшие ответы сохранения и
|
||||||
|
загрузки игнорируются; панель показывает только фото текущего маршрута.
|
||||||
|
- **Копия фото не наследует отредактированную версию.** Копия показывает исходник,
|
||||||
|
правка принадлежит своему фото (архивная синхронизация спецификации
|
||||||
|
`media-objects`).
|
||||||
|
- Устранены ошибки доступа к `photo = null` при кратковременной смене фото.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические изменения
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `PATCH /api/auth/me/preferences` — мерж известных разделов; в ответе возвращается
|
||||||
|
полный объединённый объект.
|
||||||
|
- `PATCH /api/photos/:id/edit` — параметры правок теперь включают блок `color` и
|
||||||
|
тоновую кривую; серверный рендер использует те же LUT, что и предпросмотр.
|
||||||
|
- `DELETE /api/photos/:id/edit` — сброс правок без изменений.
|
||||||
|
|
||||||
|
### Данные и миграции
|
||||||
|
|
||||||
|
- Схема БД не менялась: новые настройки лежат в JSON-блобе
|
||||||
|
`users.preferences`; параметры правок — в `photos.edit_params`.
|
||||||
|
- Новые роли объектов и миграции не добавлялись.
|
||||||
|
- Старые сохранённые кривые начнут интерполироваться плавно — визуальный результат
|
||||||
|
может немного измениться без миграции данных.
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
- Обновлён WASM-модуль фильтров (`frontend/static/light.wasm`).
|
||||||
|
- `make build-release` собирает статический бинарник с вшитой версией и сжимает
|
||||||
|
его UPX, если UPX доступен.
|
||||||
|
- Версия берётся из `git describe --tags`; релизный тег — `0.2.0`.
|
||||||
|
|
||||||
|
### Проверка
|
||||||
|
|
||||||
|
- `npm run check` — 0 ошибок и предупреждений.
|
||||||
|
- `npm run test` — 54 теста.
|
||||||
|
- `go test ./...` — все пакеты зелёные.
|
||||||
|
- E2E: сохранение/сброс цветовых правок, приватность и хранение пресетов кривой.
|
||||||
|
- Проверено в браузере: WebGL2-предпросмотр, пипетка, меню пресетов, сохранение
|
||||||
|
всех каналов, сохранение вкладки и правок при переключении фото.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как обновиться
|
||||||
|
|
||||||
|
1. Собрать релизный бинарник: `make build-release` (или взять готовый
|
||||||
|
`bin/galo-0.2.0`).
|
||||||
|
2. Остановить сервис и заменить бинарник. Фронтенд вшит в бинарник.
|
||||||
|
3. Запустить сервис: миграции применяются автоматически при старте для выбранного
|
||||||
|
бэкенда. Для этого релиза отдельных миграций нет.
|
||||||
|
4. Для видео по-прежнему нужны `ffmpeg`/`ffprobe` на хосте.
|
||||||
|
5. Android-клиент обновляется отдельно через `make install-android-app`.
|
||||||
|
|
||||||
|
Откат: вернуть предыдущий бинарник. Схема не менялась, данные правок и профиля
|
||||||
|
остаются совместимыми; новые ключи настроек старый бинарник просто игнорирует.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Редактирование цвета и кривой доступно только для изображений; видео не
|
||||||
|
редактируется.
|
||||||
|
- WebGL2-предпросмотр ускоряет отрисовку, но при его отсутствии используется
|
||||||
|
WASM/JS-фолбэк.
|
||||||
|
- Пресеты не синхронизируются между пользователями: перенос возможен только через
|
||||||
|
экспорт/импорт JSON.
|
||||||
|
- Правки не создают отдельной записи в истории: параметры хранятся в метаданных
|
||||||
|
фото, отредактированная версия — отдельный объект.
|
||||||
Reference in New Issue
Block a user