From c953008a86dae61b09a507d110bd1862a0270aad Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 24 Oct 2026 22:57:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(photos):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика --- .../internal/app/photo_edit_effects_test.go | 96 +++ backend/internal/e2e/effects_edit_test.go | 142 ++++ backend/internal/photos/edit.go | 241 +++++- backend/internal/photos/edit_bench_test.go | 69 ++ backend/internal/photos/edit_render.go | 17 +- backend/internal/photos/edit_test.go | 2 +- backend/internal/photos/effects_render.go | 659 +++++++++++++++ backend/internal/photos/effects_test.go | 708 ++++++++++++++++ backend/internal/photos/handlers.go | 7 +- backend/internal/photos/worker.go | 15 +- .../src/lib/components/PhotoEditPanel.svelte | 237 +++++- frontend/src/lib/components/PhotoPanel.svelte | 32 +- frontend/src/lib/components/Viewer.svelte | 16 +- frontend/src/lib/edit.test.ts | 187 +++++ frontend/src/lib/edit.ts | 346 +++++++- frontend/src/lib/editor-sections.test.ts | 81 ++ frontend/src/lib/editor-sections.ts | 92 +++ frontend/src/lib/webgl-preview.ts | 774 +++++++++++++++--- 18 files changed, 3548 insertions(+), 173 deletions(-) create mode 100644 backend/internal/app/photo_edit_effects_test.go create mode 100644 backend/internal/e2e/effects_edit_test.go create mode 100644 backend/internal/photos/effects_render.go create mode 100644 backend/internal/photos/effects_test.go create mode 100644 frontend/src/lib/editor-sections.test.ts create mode 100644 frontend/src/lib/editor-sections.ts diff --git a/backend/internal/app/photo_edit_effects_test.go b/backend/internal/app/photo_edit_effects_test.go new file mode 100644 index 0000000..c01b3d6 --- /dev/null +++ b/backend/internal/app/photo_edit_effects_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "galo/internal/auth" + "galo/internal/models" +) + +// The PATCH /photos/:id/edit endpoint must accept the whole edit payload, +// including the effects block: the HTTP layer has its own request struct, so a +// new parameter group can silently disappear if it is not added there. +func TestEditEndpointAcceptsEffects(t *testing.T) { + ctx := context.Background() + r, token, uid, repos := newAvatarServer(t) + + album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "Effects"} + if err := repos.Albums.Create(ctx, album); err != nil { + t.Fatalf("create album: %v", err) + } + if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil { + t.Fatalf("add owner member: %v", err) + } + + body, contentType := multipartFile(t, "file", "effects.png", makeTestPNG(t)) + rec := doAuthed(r, http.MethodPost, "/api/albums/"+album.ID+"/photos", body, contentType, token) + if rec.Code != http.StatusCreated { + t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String()) + } + var uploaded struct { + Photos []models.Photo `json:"photos"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil { + t.Fatalf("decode upload: %v", err) + } + if len(uploaded.Photos) != 1 { + t.Fatalf("uploaded photos = %d", len(uploaded.Photos)) + } + photoID := uploaded.Photos[0].ID + + // Explicit zero midpoint/feather/size/roughness must survive the round trip, + // and omitted strengths must stay zero. + payload := []byte(`{ + "effects": { + "texture": 40, + "clarity": -20, + "dehaze": 10, + "vignette": { + "mode": "color", + "amount": 50, + "midpoint": 0, + "feather": 0, + "roundness": 10, + "highlights": 5, + "color": "#ff0000" + }, + "grain": {"amount": 30, "size": 0, "roughness": 0} + } + }`) + rec = doAuthed(r, http.MethodPatch, "/api/photos/"+photoID+"/edit", bytes.NewBuffer(payload), "application/json", token) + if rec.Code != http.StatusOK { + t.Fatalf("edit = %d body=%s", rec.Code, rec.Body.String()) + } + var edited struct { + Photo models.Photo `json:"photo"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &edited); err != nil { + t.Fatalf("decode edit: %v", err) + } + effects, ok := edited.Photo.EditParams["effects"].(map[string]any) + if !ok { + t.Fatalf("effects missing from edit_params: %+v", edited.Photo.EditParams) + } + if got := effects["texture"]; got != float64(40) { + t.Fatalf("texture = %v, want 40", got) + } + vignette, ok := effects["vignette"].(map[string]any) + if !ok { + t.Fatalf("vignette missing from effects: %+v", effects) + } + if vignette["mode"] != "color" || vignette["midpoint"] != float64(0) || + vignette["feather"] != float64(0) || vignette["color"] != "#ff0000" { + t.Fatalf("vignette params not stored as sent: %+v", vignette) + } + grain, ok := effects["grain"].(map[string]any) + if !ok { + t.Fatalf("grain missing from effects: %+v", effects) + } + if grain["size"] != float64(0) || grain["roughness"] != float64(0) { + t.Fatalf("grain params not stored as sent: %+v", grain) + } +} diff --git a/backend/internal/e2e/effects_edit_test.go b/backend/internal/e2e/effects_edit_test.go new file mode 100644 index 0000000..c09f367 --- /dev/null +++ b/backend/internal/e2e/effects_edit_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "bytes" + "context" + "path/filepath" + "testing" + "time" + + "github.com/disintegration/imaging" + "github.com/gen2brain/webp" + + "galo/internal/albums" + "galo/internal/photos" +) + +// TestEffectsEdit covers the effects round trip: the parameters are stored in +// the photo metadata, the background worker renders the edited version, the +// saved parameters carry the normalized effects, a reset removes everything, +// and re-rendering the same edit (including grain) is deterministic. +func TestEffectsEdit(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, "effects@test.local", "", "password123", "E") + if err != nil { + t.Fatalf("register: %v", err) + } + album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Effects"}) + if err != nil { + t.Fatalf("create album: %v", err) + } + p, err := st.photos.Upload(ctx, photos.UploadInput{ + AlbumID: album.ID, UserID: user.ID, Filename: "e.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) + + params := photos.EditParams{Effects: photos.EffectsParams{ + Texture: 25, + Clarity: 20, + Dehaze: 15, + Vignette: photos.VignetteParams{ + Mode: photos.VignetteColor, Amount: 40, Midpoint: 60, Feather: 40, Color: "#ff8800", + }, + Grain: photos.GrainParams{Amount: 35, Size: 30, Roughness: 60}, + }} + edited, err := st.photos.EditPhoto(ctx, p.ID, user.ID, params) + if err != nil { + t.Fatalf("edit: %v", err) + } + if edited.EditParams == nil { + t.Fatal("edit returned no parameters") + } + if _, ok := edited.EditParams["effects"]; !ok { + t.Fatalf("effects params not returned: %+v", edited.EditParams) + } + + 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 got.EditPreviewKey == "" { + t.Fatalf("no edited preview key: %+v", got) + } + effects, ok := got.EditParams["effects"].(map[string]any) + if !ok { + t.Fatalf("effects params not stored: %+v", got.EditParams) + } + vignette, ok := effects["vignette"].(map[string]any) + if !ok { + t.Fatalf("vignette params not stored: %+v", effects) + } + if vignette["mode"] != photos.VignetteColor { + t.Fatalf("vignette mode = %v, want %q", vignette["mode"], photos.VignetteColor) + } + if vignette["color"] != "#ff8800" { + t.Fatalf("vignette color = %v, want #ff8800", vignette["color"]) + } + if vignette["midpoint"] != float64(60) { + t.Fatalf("vignette midpoint = %v, want 60", vignette["midpoint"]) + } + grain, ok := effects["grain"].(map[string]any) + if !ok { + t.Fatalf("grain params not stored: %+v", effects) + } + if grain["size"] != float64(30) || grain["roughness"] != float64(60) { + t.Fatalf("grain params = %+v, want size 30 roughness 60", grain) + } + first := previewPixels(t, st, got.EditPreviewKey) + + 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 != "" || reset.EditPreviewKey != "" { + t.Fatalf("reset kept the edited version: %+v", reset) + } + + // The same parameters and the same photo id must render the same grain. + if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, params); err != nil { + t.Fatalf("edit again: %v", err) + } + w.ProcessOnce(ctx) + again, err := st.photos.Get(ctx, p.ID, user.ID) + if err != nil { + t.Fatalf("get after second edit: %v", err) + } + if again.EditPreviewURL == "" || again.EditPreviewKey == "" { + t.Fatalf("no edited preview after second edit: %+v", again) + } + second := previewPixels(t, st, again.EditPreviewKey) + if !bytes.Equal(first, second) { + t.Fatal("repeated render with the same grain parameters is not deterministic") + } +} + +// previewPixels decodes an edited preview object into its pixel bytes. +func previewPixels(t *testing.T, st *stack, key string) []uint8 { + t.Helper() + data, err := st.storage.Get(context.Background(), key) + if err != nil { + t.Fatalf("read preview object: %v", err) + } + img, err := webp.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode preview: %v", err) + } + return imaging.Clone(img).Pix +} diff --git a/backend/internal/photos/edit.go b/backend/internal/photos/edit.go index b90ce83..8d7e02e 100644 --- a/backend/internal/photos/edit.go +++ b/backend/internal/photos/edit.go @@ -5,6 +5,7 @@ import ( "errors" "math" "sort" + "strings" ) // Edit parameters are stored in the photo metadata and describe a @@ -15,12 +16,12 @@ import ( // ranges mirror the ones Lightroom exposes: exposure in stops, everything else // in percent. type LightParams struct { - Exposure float64 `json:"exposure"` - Contrast float64 `json:"contrast"` - Highlights float64 `json:"highlights"` - Shadows float64 `json:"shadows"` - Whites float64 `json:"whites"` - Blacks float64 `json:"blacks"` + Exposure float64 `json:"exposure"` + Contrast float64 `json:"contrast"` + Highlights float64 `json:"highlights"` + Shadows float64 `json:"shadows"` + Whites float64 `json:"whites"` + Blacks float64 `json:"blacks"` Curve CurveParams `json:"curve"` } @@ -95,6 +96,140 @@ type ColorCorrection struct { Blue float64 `json:"blue"` } +// EffectsParams holds the parameters of the "Эффекты" (effects) tool: texture, +// clarity, dehaze, vignette and grain. It is applied after the light and color +// adjustments, because it works on the already corrected frame. +type EffectsParams struct { + Texture float64 `json:"texture"` + Clarity float64 `json:"clarity"` + Dehaze float64 `json:"dehaze"` + Vignette VignetteParams `json:"vignette"` + Grain GrainParams `json:"grain"` +} + +// VignetteParams holds the parameters of the "Виньетка" tool. The mode picks +// the way the edges are treated ("light" darkens or brightens them, "color" +// tints them and "colorize" fills them with the chosen color), amount is the +// signed strength in percent, and the rest controls the geometry of the effect. +// +// midpointSet and featherSet record whether the client sent the field: an +// explicit zero is a valid geometry value and must not be replaced by the +// default, while an omitted field gets the default. +type VignetteParams struct { + Mode string `json:"mode"` + Amount float64 `json:"amount"` + Midpoint float64 `json:"midpoint"` + Feather float64 `json:"feather"` + Roundness float64 `json:"roundness"` + Highlights float64 `json:"highlights"` + Color string `json:"color"` + + midpointSet bool + featherSet bool +} + +// UnmarshalJSON remembers which defaultable fields the client actually sent. +func (v *VignetteParams) UnmarshalJSON(data []byte) error { + var raw struct { + Mode *string `json:"mode"` + Amount *float64 `json:"amount"` + Midpoint *float64 `json:"midpoint"` + Feather *float64 `json:"feather"` + Roundness *float64 `json:"roundness"` + Highlights *float64 `json:"highlights"` + Color *string `json:"color"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *v = VignetteParams{} + if raw.Mode != nil { + v.Mode = *raw.Mode + } + if raw.Amount != nil { + v.Amount = *raw.Amount + } + if raw.Midpoint != nil { + v.Midpoint = *raw.Midpoint + v.midpointSet = true + } + if raw.Feather != nil { + v.Feather = *raw.Feather + v.featherSet = true + } + if raw.Roundness != nil { + v.Roundness = *raw.Roundness + } + if raw.Highlights != nil { + v.Highlights = *raw.Highlights + } + if raw.Color != nil { + v.Color = *raw.Color + } + return nil +} + +// GrainParams holds the parameters of the "Зерно" tool. Amount is the strength, +// size the scale of the noise cells, roughness the mix of fine and smooth +// noise. +// +// sizeSet and roughnessSet record whether the client sent the field: an +// explicit zero is a valid value and must not be replaced by the default. +type GrainParams struct { + Amount float64 `json:"amount"` + Size float64 `json:"size"` + Roughness float64 `json:"roughness"` + + sizeSet bool + roughnessSet bool +} + +// UnmarshalJSON remembers which defaultable fields the client actually sent. +func (g *GrainParams) UnmarshalJSON(data []byte) error { + var raw struct { + Amount *float64 `json:"amount"` + Size *float64 `json:"size"` + Roughness *float64 `json:"roughness"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *g = GrainParams{} + if raw.Amount != nil { + g.Amount = *raw.Amount + } + if raw.Size != nil { + g.Size = *raw.Size + g.sizeSet = true + } + if raw.Roughness != nil { + g.Roughness = *raw.Roughness + g.roughnessSet = true + } + return nil +} + +// Modes of the "Виньетка" tool. Any other mode is normalized to VignetteLight. +const ( + VignetteLight = "light" + VignetteColor = "color" + VignetteColorize = "colorize" +) + +// Structural defaults of the effects. They are filled by Normalized, so the +// zero value and an explicit "defaults" request mean the same thing and a photo +// without the "effects" key reads as untouched. +const ( + DefaultVignetteMidpoint = 50.0 + DefaultVignetteFeather = 50.0 + DefaultGrainSize = 25.0 + DefaultGrainRoughness = 50.0 +) + +// DefaultVignetteColor is the color used when the request carries no usable +// hex color. +const DefaultVignetteColor = "#000000" + // mixerBand is a color band with its hue center in degrees. type mixerBand struct { center float64 @@ -124,8 +259,9 @@ func (g ColorGrading) wheels() [3]ColorWheel { // EditParams is the full set of non-destructive editing parameters of a photo. type EditParams struct { - Light LightParams `json:"light"` - Color ColorParams `json:"color"` + Light LightParams `json:"light"` + Color ColorParams `json:"color"` + Effects EffectsParams `json:"effects"` } // Limits of the editing parameters. They are enforced on write so a hand-made @@ -167,6 +303,7 @@ func (p EditParams) Normalized() EditParams { p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green) p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue) p.Color = p.Color.Normalized() + p.Effects = p.Effects.Normalized() return p } @@ -214,6 +351,92 @@ func (c ColorParams) Normalized() ColorParams { return c } +// Normalized returns a copy of the effects clamped to their ranges, with the +// structural defaults filled in. Normalization is idempotent: applying it to an +// already normalized value returns it unchanged, which keeps repeated saves and +// reads stable. +func (e EffectsParams) Normalized() EffectsParams { + e.Texture = clampFloat(e.Texture, -MaxPercent, MaxPercent) + e.Clarity = clampFloat(e.Clarity, -MaxPercent, MaxPercent) + e.Dehaze = clampFloat(e.Dehaze, -MaxPercent, MaxPercent) + e.Vignette = e.Vignette.Normalized() + e.Grain = e.Grain.Normalized() + return e +} + +// Normalized returns a copy of the vignette parameters clamped to their ranges. +// The midpoint and feather defaults are applied only when the client omitted the +// field; an explicit zero stays zero. Clamping happens before defaulting so the +// operation stays idempotent. +func (v VignetteParams) Normalized() VignetteParams { + v.Mode = normalizeVignetteMode(v.Mode) + v.Amount = clampFloat(v.Amount, -MaxPercent, MaxPercent) + v.Midpoint = clampFloat(v.Midpoint, 0, MaxPercent) + if v.Midpoint == 0 && !v.midpointSet { + v.Midpoint = DefaultVignetteMidpoint + } + v.Feather = clampFloat(v.Feather, 0, MaxPercent) + if v.Feather == 0 && !v.featherSet { + v.Feather = DefaultVignetteFeather + } + v.Roundness = clampFloat(v.Roundness, -MaxPercent, MaxPercent) + v.Highlights = clampFloat(v.Highlights, 0, MaxPercent) + v.Color = normalizeHexColor(v.Color) + return v +} + +// Normalized returns a copy of the grain parameters clamped to their ranges. +// The size and roughness defaults are applied only when the client omitted the +// field; an explicit zero stays zero. +func (g GrainParams) Normalized() GrainParams { + g.Amount = clampFloat(g.Amount, 0, MaxPercent) + g.Size = clampFloat(g.Size, 0, MaxPercent) + if g.Size == 0 && !g.sizeSet { + g.Size = DefaultGrainSize + } + g.Roughness = clampFloat(g.Roughness, 0, MaxPercent) + if g.Roughness == 0 && !g.roughnessSet { + g.Roughness = DefaultGrainRoughness + } + return g +} + +// IsEmpty reports whether the effects leave the image untouched. Only the +// strengths count: the structural defaults (mode, midpoint, feather, size, +// roughness, color) do not turn into an edit by themselves. +func (e EffectsParams) IsEmpty() bool { + return e.Texture == 0 && e.Clarity == 0 && e.Dehaze == 0 && + e.Vignette.Amount == 0 && e.Grain.Amount == 0 +} + +// normalizeVignetteMode maps an unknown or empty mode to the light one. +func normalizeVignetteMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case VignetteColor: + return VignetteColor + case VignetteColorize: + return VignetteColorize + default: + return VignetteLight + } +} + +// normalizeHexColor returns the lower-case "#rrggbb" form of a valid color and +// the default black for anything unusable. +func normalizeHexColor(color string) string { + color = strings.TrimSpace(color) + if len(color) != 7 || color[0] != '#' { + return DefaultVignetteColor + } + for i := 1; i < len(color); i++ { + c := color[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return DefaultVignetteColor + } + } + return strings.ToLower(color) +} + // IsEmpty reports whether the parameters describe an untouched photo. func (p EditParams) IsEmpty() bool { l := p.Light @@ -228,7 +451,7 @@ func (p EditParams) IsEmpty() bool { } } } - return p.Color.IsEmpty() + return p.Color.IsEmpty() && p.Effects.IsEmpty() } // IsEmpty reports whether the color parameters leave the image untouched. diff --git a/backend/internal/photos/edit_bench_test.go b/backend/internal/photos/edit_bench_test.go index 064ac29..eb05774 100644 --- a/backend/internal/photos/edit_bench_test.go +++ b/backend/internal/photos/edit_bench_test.go @@ -1,7 +1,9 @@ package photos import ( + "bytes" "image" + "image/png" "testing" "time" ) @@ -29,6 +31,73 @@ func BenchmarkApplyLight(b *testing.B) { b.Logf("кадров: %d", b.N) } +// BenchmarkBoxBlur3 measures the O(n) box-blur passes on a 12 MP luminance +// plane with the radius the clarity pass uses for that frame. +func BenchmarkBoxBlur3(b *testing.B) { + w, h := 4000, 3000 + src := make([]uint8, w*h) + for i := range src { + src[i] = uint8(i % 251) + } + radius := int(0.008 * float64(min(w, h))) + if radius < 2 { + radius = 2 + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = boxBlur3(src, w, h, radius) + } + b.StopTimer() + b.Logf("кадров: %d, радиус: %d", b.N, radius) +} + +// BenchmarkFullRenderWithEffects measures the whole edited render of a 12 MP +// photo with all effects enabled: decode, light, color, dehaze, texture, +// clarity, vignette and grain. This is the work the background worker does for +// one edit job. +func BenchmarkFullRenderWithEffects(b *testing.B) { + w, h := 4000, 3000 + src := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*src.Stride + x*4 + src.Pix[i] = uint8(x*255/w) ^ uint8(y*255/h) + src.Pix[i+1] = uint8(x * 3) + src.Pix[i+2] = uint8(y * 5) + src.Pix[i+3] = 255 + } + } + var buf bytes.Buffer + encoder := png.Encoder{CompressionLevel: png.BestSpeed} + if err := encoder.Encode(&buf, src); err != nil { + b.Fatalf("encode png: %v", err) + } + data := buf.Bytes() + params := EditParams{ + Light: LightParams{Exposure: 0.2, Contrast: 12, Shadows: 15}, + Color: ColorParams{Vibrance: 20, Temperature: 8}, + Effects: EffectsParams{ + Texture: 30, + Clarity: 25, + Dehaze: 20, + Vignette: VignetteParams{ + Mode: VignetteColor, Amount: 35, Color: "#ff8800", Highlights: 25, + }, + Grain: GrainParams{Amount: 30, Size: 30, Roughness: 55}, + }, + } + seed := editSeed("benchmark-photo") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := decodeAndAdjust(data, params, seed); err != nil { + b.Fatalf("decode and adjust: %v", err) + } + } + b.StopTimer() + b.Logf("кадров: %d, эффекты: текстура/чёткость/дымка/виньетка/зерно", b.N) +} + func TestRenderSpeed(t *testing.T) { w, h := 4000, 3000 src := image.NewNRGBA(image.Rect(0, 0, w, h)) diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go index c3363d2..be1e000 100644 --- a/backend/internal/photos/edit_render.go +++ b/backend/internal/photos/edit_render.go @@ -90,19 +90,24 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r } } -// decodeAndAdjust decodes the original and applies the light and color +// decodeAndAdjust decodes the original and applies the light, color and effect // 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) { +// decode. The passes run in a fixed order: light, color, then the effects +// (dehaze, texture, clarity, vignette, grain). The seed makes the grain render +// deterministic; it comes from the photo id. +func decodeAndAdjust(data []byte, p EditParams, seed uint64) (*image.NRGBA, error) { img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) if err != nil { return nil, err } edited := applyLight(img, p.Light) - if p.Color.IsEmpty() { - return edited, nil + if !p.Color.IsEmpty() { + edited = applyColor(edited, p.Color) } - return applyColor(edited, p.Color), nil + if !p.Effects.IsEmpty() { + edited = applyEffects(edited, p.Effects, seed) + } + return edited, nil } // applyColor applies the color adjustments in place to an image that already diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go index 0c7e66e..7bc644c 100644 --- a/backend/internal/photos/edit_test.go +++ b/backend/internal/photos/edit_test.go @@ -208,7 +208,7 @@ func TestBuildEditedVariants(t *testing.T) { src.Pix[i+3] = 255 } } - edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}) + edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}, editSeed("test-photo")) if err != nil { t.Fatalf("decode and adjust: %v", err) } diff --git a/backend/internal/photos/effects_render.go b/backend/internal/photos/effects_render.go new file mode 100644 index 0000000..4732553 --- /dev/null +++ b/backend/internal/photos/effects_render.go @@ -0,0 +1,659 @@ +package photos + +import ( + "image" + "math" + "runtime" + "sync" + + "github.com/disintegration/imaging" +) + +// Spatial effects of the non-destructive editor. They run after the light and +// color adjustments, in a fixed order: dehaze, texture, clarity, vignette, +// grain. Texture and clarity compute a correction from the luminance and add it +// to all three channels equally, so hue and saturation are preserved; dehaze is +// a global tonal correction based on the dark channel prior. Vignette and grain +// are the final, "post-crop" operations. +// +// Everything here is deterministic: the grain seed comes from the photo id, so +// a repeated render of the same parameters produces the same pixels. + +// maxDehazeSide is the longest side of the reduced copy used to estimate the +// atmosphere and the transmission map. +const maxDehazeSide = 512 + +// dehazeOmega is the share of the haze the dark channel prior removes. +const dehazeOmega = 0.95 + +// dehazeMinT bounds the transmission map from below, so the darkest regions do +// not amplify noise without limit. +const dehazeMinT = 0.1 + +// applyEffects applies the non-empty effect passes in the documented order and +// returns the image owned by the caller. +func applyEffects(dst *image.NRGBA, p EffectsParams, seed uint64) *image.NRGBA { + if p.Dehaze != 0 { + dst = applyDehaze(dst, p.Dehaze) + } + if p.Texture != 0 { + applyTexture(dst, p.Texture) + } + if p.Clarity != 0 { + applyClarity(dst, p.Clarity) + } + if p.Vignette.Amount != 0 { + applyVignette(dst, p.Vignette) + } + if p.Grain.Amount != 0 { + applyGrain(dst, p.Grain, seed) + } + return dst +} + +// luminance returns the perceived brightness Y of an RGB pixel (each 0..255). +func luminance(r, g, b float64) float64 { + return 0.299*r + 0.587*g + 0.114*b +} + +// lumaPlane builds a Y plane of the image, one byte per pixel. +func lumaPlane(img *image.NRGBA) []uint8 { + w, h := img.Rect.Dx(), img.Rect.Dy() + out := make([]uint8, w*h) + parallelChunks(h, func(from, to int) { + for y := from; y < to; y++ { + row := img.Pix[y*img.Stride : y*img.Stride+w*4] + dst := out[y*w : (y+1)*w] + for x := 0; x < w; x++ { + i := x * 4 + dst[x] = uint8(luminance(float64(row[i]), float64(row[i+1]), float64(row[i+2])) + 0.5) + } + } + }) + return out +} + +// parallelChunks splits n independent items (rows or columns) into chunks and +// runs fn over each chunk, mirroring the row parallelism of applyLight and +// applyColor. +func parallelChunks(n int, fn func(from, to int)) { + if n <= 0 { + return + } + workers := runtime.GOMAXPROCS(0) + if workers > n { + workers = n + } + if workers <= 1 { + fn(0, n) + return + } + chunk := (n + workers - 1) / workers + var wg sync.WaitGroup + for from := 0; from < n; from += chunk { + to := from + chunk + if to > n { + to = n + } + wg.Add(1) + go func(from, to int) { + defer wg.Done() + fn(from, to) + }(from, to) + } + wg.Wait() +} + +// boxBlur applies one separable box blur with edge-clamped borders. The running +// window makes the cost independent of the radius. Edge pixels repeat the +// nearest border value, so the size of the image never changes. +func boxBlur(src []uint8, w, h, radius int) []uint8 { + if w <= 0 || h <= 0 || len(src) < w*h { + return nil + } + out := make([]uint8, w*h) + if radius <= 0 { + copy(out, src[:w*h]) + return out + } + tmp := make([]uint8, w*h) + blurHorizontal(src, tmp, w, h, radius) + blurVertical(tmp, out, w, h, radius) + return out +} + +// boxBlur3 approximates a Gaussian blur with three box passes. +func boxBlur3(src []uint8, w, h, radius int) []uint8 { + out := boxBlur(src, w, h, radius) + out = boxBlur(out, w, h, radius) + return boxBlur(out, w, h, radius) +} + +// blurHorizontal is one horizontal box-blur pass with a sliding window. +func blurHorizontal(src, dst []uint8, w, h, radius int) { + window := 2*radius + 1 + parallelChunks(h, func(from, to int) { + for y := from; y < to; y++ { + row := src[y*w : (y+1)*w] + out := dst[y*w : (y+1)*w] + var sum int + for k := -radius; k <= radius; k++ { + sum += int(row[clampInt(k, 0, w-1)]) + } + for x := 0; x < w; x++ { + out[x] = uint8((sum + window/2) / window) + sum -= int(row[clampInt(x-radius, 0, w-1)]) + sum += int(row[clampInt(x+radius+1, 0, w-1)]) + } + } + }) +} + +// blurVertical is one vertical box-blur pass with a sliding window. Columns are +// independent, so they are split into chunks just like rows. +func blurVertical(src, dst []uint8, w, h, radius int) { + window := 2*radius + 1 + parallelChunks(w, func(from, to int) { + for x := from; x < to; x++ { + var sum int + for k := -radius; k <= radius; k++ { + sum += int(src[clampInt(k, 0, h-1)*w+x]) + } + for y := 0; y < h; y++ { + dst[y*w+x] = uint8((sum + window/2) / window) + sum -= int(src[clampInt(y-radius, 0, h-1)*w+x]) + sum += int(src[clampInt(y+radius+1, 0, h-1)*w+x]) + } + } + }) +} + +// addLumaDelta adds the same delta to the three color channels of one pixel. +// Transparent pixels are left untouched by the callers. +func addLumaDelta(row []uint8, i int, delta float64) { + row[i] = clampChannel(float64(row[i]) + delta) + row[i+1] = clampChannel(float64(row[i+1]) + delta) + row[i+2] = clampChannel(float64(row[i+2]) + delta) +} + +// clampChannel rounds and clamps a channel value to 0..255. +func clampChannel(v float64) uint8 { + return uint8(clampFloat(v+0.5, 0, 255)) +} + +// applyTexture is an unsharp mask with a small radius: it boosts (or, for a +// negative amount, smooths) the high-frequency detail of the luminance. +func applyTexture(dst *image.NRGBA, amount float64) { + w, h := dst.Rect.Dx(), dst.Rect.Dy() + if w == 0 || h == 0 { + return + } + y := lumaPlane(dst) + radius := int(math.Round(0.0015 * float64(min(w, h)))) + if radius < 1 { + radius = 1 + } + blur := boxBlur3(y, w, h, radius) + k := amount / 100 + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + base := row * w + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + delta := k * (float64(y[base+x]) - float64(blur[base+x])) + addLumaDelta(pix, i, delta) + } + } + }) +} + +// applyClarity is an unsharp mask with a large radius. The correction is +// limited to +-40 levels and weighted by the midtone mask, which keeps the +// edges of contrasty objects from ringing. +func applyClarity(dst *image.NRGBA, amount float64) { + w, h := dst.Rect.Dx(), dst.Rect.Dy() + if w == 0 || h == 0 { + return + } + y := lumaPlane(dst) + radius := int(math.Round(0.008 * float64(min(w, h)))) + if radius < 2 { + radius = 2 + } + blur := boxBlur3(y, w, h, radius) + k := amount / 100 * 1.25 + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + base := row * w + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + yVal := float64(y[base+x]) + d := clampFloat(yVal-float64(blur[base+x]), -40, 40) + t := math.Abs(2*yVal/255 - 1) + weight := 1 - 0.6*t*t + addLumaDelta(pix, i, k*d*weight) + } + } + }) +} + +// applyDehaze removes (positive amount) or adds (negative amount) haze with the +// dark channel prior. The atmosphere and the transmission map are estimated on +// a reduced copy, then the map is interpolated back to the full frame. +func applyDehaze(dst *image.NRGBA, amount float64) *image.NRGBA { + w, h := dst.Rect.Dx(), dst.Rect.Dy() + if w == 0 || h == 0 { + return dst + } + small := dst + sw, sh := w, h + if max(w, h) > maxDehazeSide { + scale := float64(maxDehazeSide) / float64(max(w, h)) + sw = max(1, int(math.Round(float64(w)*scale))) + sh = max(1, int(math.Round(float64(h)*scale))) + small = imaging.Resize(dst, sw, sh, imaging.Linear) + } + sw, sh = small.Rect.Dx(), small.Rect.Dy() + + dark := darkChannel(small) + atmosphere := atmosphericLight(small, dark) + + tMap := make([]uint8, sw*sh) + for y := 0; y < sh; y++ { + row := small.Pix[y*small.Stride : y*small.Stride+sw*4] + for x := 0; x < sw; x++ { + i := x * 4 + transmission := 1 - dehazeOmega*min( + float64(row[i])/atmosphere[0], + min(float64(row[i+1])/atmosphere[1], float64(row[i+2])/atmosphere[2])) + transmission = clampFloat(transmission, dehazeMinT, 1) + tMap[y*sw+x] = uint8(transmission*255 + 0.5) + } + } + tRadius := int(math.Round(0.02 * float64(min(sw, sh)))) + if tRadius < 1 { + tRadius = 1 + } + tMap = boxBlur3(tMap, sw, sh, tRadius) + + fullT := tMap + if sw != w || sh != h { + gray := image.NewGray(image.Rect(0, 0, sw, sh)) + copy(gray.Pix, tMap) + fullT = resizeTransmission(gray, w, h) + } + + k := amount / 100 + if amount > 0 { + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + base := row * w + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + t := float64(fullT[base+x]) / 255 + if t < dehazeMinT { + t = dehazeMinT + } + for c := 0; c < 3; c++ { + in := float64(pix[i+c]) + clear := (in-atmosphere[c])/t + atmosphere[c] + pix[i+c] = clampChannel(in + k*(clear-in)) + } + } + } + }) + return dst + } + + // Negative amount adds haze: the frame moves toward the atmosphere color + // and loses contrast. + haze := -k + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + for c := 0; c < 3; c++ { + in := float64(pix[i+c]) + pix[i+c] = clampChannel(in*(1-0.75*haze) + atmosphere[c]*0.75*haze) + } + } + } + }) + return dst +} + +// darkChannel returns the per-pixel minimum of the RGB channels. +func darkChannel(img *image.NRGBA) []uint8 { + w, h := img.Rect.Dx(), img.Rect.Dy() + out := make([]uint8, w*h) + parallelChunks(h, func(from, to int) { + for y := from; y < to; y++ { + row := img.Pix[y*img.Stride : y*img.Stride+w*4] + dst := out[y*w : (y+1)*w] + for x := 0; x < w; x++ { + i := x * 4 + v := row[i] + if row[i+1] < v { + v = row[i+1] + } + if row[i+2] < v { + v = row[i+2] + } + dst[x] = v + } + } + }) + return out +} + +// atmosphericLight estimates the color of the haze: the average RGB of the +// pixels whose dark channel is above the 99.9th percentile, at least one pixel +// and never below one per channel. +func atmosphericLight(img *image.NRGBA, dark []uint8) [3]float64 { + var atmosphere [3]float64 + if len(dark) == 0 { + return [3]float64{1, 1, 1} + } + var hist [256]int + maxDark := 0 + for _, v := range dark { + hist[v]++ + if int(v) > maxDark { + maxDark = int(v) + } + } + target := int(math.Ceil(0.999 * float64(len(dark)))) + if target < 1 { + target = 1 + } + threshold := maxDark + cumulative := 0 + for v := 0; v < 256; v++ { + cumulative += hist[v] + if cumulative >= target { + threshold = v + break + } + } + + w, h := img.Rect.Dx(), img.Rect.Dy() + var sum [3]float64 + count := 0 + add := func(x, y int) { + i := y*img.Stride + x*4 + sum[0] += float64(img.Pix[i]) + sum[1] += float64(img.Pix[i+1]) + sum[2] += float64(img.Pix[i+2]) + count++ + } + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if int(dark[y*w+x]) > threshold { + add(x, y) + } + } + } + if count == 0 { + // A uniform dark channel has no pixel strictly above the threshold: + // the brightest pixel still is the best atmosphere estimate. + found := false + for y := 0; y < h && !found; y++ { + for x := 0; x < w; x++ { + if int(dark[y*w+x]) == maxDark { + add(x, y) + found = true + break + } + } + } + } + for c := 0; c < 3; c++ { + atmosphere[c] = 1 + if count > 0 { + atmosphere[c] = math.Max(1, sum[c]/float64(count)) + } + } + return atmosphere +} + +// resizeTransmission upsamples the gray transmission map to the full frame. +// imaging.Resize returns an NRGBA image, so the gray value is the first byte of +// every pixel; slicing its Pix by w*h would read only the first quarter of the +// frame, hence the explicit per-pixel copy. +func resizeTransmission(gray *image.Gray, w, h int) []uint8 { + resized := imaging.Resize(gray, w, h, imaging.Linear) + out := make([]uint8, w*h) + for i := range out { + out[i] = resized.Pix[i*4] + } + return out +} + +// applyVignette darkens, brightens, tints or fills the edges of the frame. The +// geometry is normalized to the half-frame: roundness turns the ellipse into a +// rounded rectangle, midpoint sets where the zone starts and feather sets how +// soft the transition is. +func applyVignette(dst *image.NRGBA, v VignetteParams) { + w, h := dst.Rect.Dx(), dst.Rect.Dy() + if w == 0 || h == 0 || v.Amount == 0 { + return + } + exponent := 2 * math.Pow(6, v.Roundness/100) + invExponent := 1 / exponent + start := 0.15 + 0.85*v.Midpoint/100 + featherWidth := 0.02 + 0.48*v.Feather/100 + + xTerms := make([]float64, w) + for x := 0; x < w; x++ { + dx := (float64(x)+0.5)/float64(w)*2 - 1 + xTerms[x] = math.Pow(math.Abs(dx), exponent) + } + yTerms := make([]float64, h) + for y := 0; y < h; y++ { + dy := (float64(y)+0.5)/float64(h)*2 - 1 + yTerms[y] = math.Pow(math.Abs(dy), exponent) + } + + amount := v.Amount / 100 + tintR, tintG, tintB := parseHexRGB(v.Color) + tintY := luminance(tintR, tintG, tintB) + highlights := v.Highlights / 100 + + mode := v.Mode + if mode != VignetteColor && mode != VignetteColorize { + mode = VignetteLight + } + + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + termY := yTerms[row] + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + distance := math.Pow(xTerms[x]+termY, invExponent) + mask := smoothstep(start, start+featherWidth, distance) + if mask == 0 { + continue + } + yVal := luminance(float64(pix[i]), float64(pix[i+1]), float64(pix[i+2])) + highlightFactor := highlights * smoothstep(0.5, 1, yVal/255) + switch mode { + case VignetteColor: + strength := math.Abs(amount) * mask * (1 - highlightFactor) + if strength == 0 { + continue + } + targetR := tintR - tintY + yVal + targetG := tintG - tintY + yVal + targetB := tintB - tintY + yVal + pix[i] = clampChannel(float64(pix[i]) + (targetR-float64(pix[i]))*strength) + pix[i+1] = clampChannel(float64(pix[i+1]) + (targetG-float64(pix[i+1]))*strength) + pix[i+2] = clampChannel(float64(pix[i+2]) + (targetB-float64(pix[i+2]))*strength) + case VignetteColorize: + strength := math.Abs(amount) * mask * (1 - highlightFactor) + if strength == 0 { + continue + } + pix[i] = clampChannel(float64(pix[i]) + (tintR-float64(pix[i]))*strength) + pix[i+1] = clampChannel(float64(pix[i+1]) + (tintG-float64(pix[i+1]))*strength) + pix[i+2] = clampChannel(float64(pix[i+2]) + (tintB-float64(pix[i+2]))*strength) + default: + factor := 1 - amount*0.85*mask + factor += (1 - factor) * highlightFactor * mask + pix[i] = clampChannel(float64(pix[i]) * factor) + pix[i+1] = clampChannel(float64(pix[i+1]) * factor) + pix[i+2] = clampChannel(float64(pix[i+2]) * factor) + } + } + } + }) +} + +// smoothstep is the Hermite ramp between the two edges, clamped to 0..1. +func smoothstep(edge0, edge1, x float64) float64 { + if edge1 <= edge0 { + if x < edge1 { + return 0 + } + return 1 + } + t := clamp01((x - edge0) / (edge1 - edge0)) + return t * t * (3 - 2*t) +} + +// parseHexRGB decodes a "#rrggbb" color. An unusable color reads as black. +func parseHexRGB(color string) (float64, float64, float64) { + if len(color) != 7 || color[0] != '#' { + return 0, 0, 0 + } + var rgb [3]float64 + for c := 0; c < 3; c++ { + hi := hexNibble(color[1+c*2]) + lo := hexNibble(color[2+c*2]) + if hi < 0 || lo < 0 { + return 0, 0, 0 + } + rgb[c] = float64(hi*16 + lo) + } + return rgb[0], rgb[1], rgb[2] +} + +// hexNibble decodes one hexadecimal digit, or -1 for an invalid character. +func hexNibble(c byte) int { + switch { + case c >= '0' && c <= '9': + return int(c - '0') + case c >= 'a' && c <= 'f': + return int(c-'a') + 10 + case c >= 'A' && c <= 'F': + return int(c-'A') + 10 + } + return -1 +} + +// Grain noise is a pure function of the pixel coordinates and the seed. Using +// the splitmix64 finalizer as a hash (instead of a stateful sequence) keeps the +// noise independent of the row order, so the row-parallel render is bit-for-bit +// reproducible. + +const ( + grainSaltValue uint64 = 0x9e3779b97f4a7c15 + grainSaltWhite uint64 = 0xd1b54a32d192ed03 +) + +// mix64 is the splitmix64 finalizer. +func mix64(x uint64) uint64 { + x += 0x9e3779b97f4a7c15 + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 + x = (x ^ (x >> 27)) * 0x94d049bb133111eb + return x ^ (x >> 31) +} + +// hashSigned maps a seed, a salt and integer coordinates to [-1, 1]. +func hashSigned(seed, salt uint64, x, y int) float64 { + z := seed ^ salt + z ^= uint64(x) * 0x9e3779b97f4a7c15 + z ^= uint64(y) * 0xc2b2ae3d27d4eb4f + h := mix64(z) + return float64(h>>11)/float64(uint64(1)<<53)*2 - 1 +} + +// applyGrain adds a deterministic luminance noise. Size sets the cell of the +// smooth value noise and roughness mixes it with the per-pixel white noise; the +// weight fades the noise out of the darkest and brightest tones. +func applyGrain(dst *image.NRGBA, g GrainParams, seed uint64) { + w, h := dst.Rect.Dx(), dst.Rect.Dy() + if w == 0 || h == 0 || g.Amount == 0 { + return + } + cell := int(math.Round((0.0005 + 0.004*g.Size/100) * float64(min(w, h)))) + if cell < 1 { + cell = 1 + } + + gridW := (w-1)/cell + 2 + gridH := (h-1)/cell + 2 + grid := make([]float64, gridW*gridH) + for gy := 0; gy < gridH; gy++ { + for gx := 0; gx < gridW; gx++ { + grid[gy*gridW+gx] = hashSigned(seed, grainSaltValue, gx, gy) + } + } + + roughness := g.Roughness / 100 + smoothWeight := 1 - roughness + strength := g.Amount / 100 * 38 + + parallelChunks(h, func(from, to int) { + for row := from; row < to; row++ { + pix := dst.Pix[row*dst.Stride : row*dst.Stride+w*4] + gy := row / cell + fy := float64(row-gy*cell) / float64(cell) + vy := smoothstep(0, 1, fy) + for x := 0; x < w; x++ { + i := x * 4 + if pix[i+3] == 0 { + continue + } + gx := x / cell + fx := float64(x-gx*cell) / float64(cell) + vx := smoothstep(0, 1, fx) + + a := grid[gy*gridW+gx] + b := grid[gy*gridW+gx+1] + c := grid[(gy+1)*gridW+gx] + d := grid[(gy+1)*gridW+gx+1] + top := a + (b-a)*vx + bottom := c + (d-c)*vx + value := top + (bottom-top)*vy + + white := hashSigned(seed, grainSaltWhite, x, row) + noise := value*smoothWeight + white*roughness + + yVal := luminance(float64(pix[i]), float64(pix[i+1]), float64(pix[i+2])) + t := 2*yVal/255 - 1 + weight := 1 - t*t + addLumaDelta(pix, i, noise*strength*weight) + } + } + }) +} diff --git a/backend/internal/photos/effects_test.go b/backend/internal/photos/effects_test.go new file mode 100644 index 0000000..cad6015 --- /dev/null +++ b/backend/internal/photos/effects_test.go @@ -0,0 +1,708 @@ +package photos + +import ( + "bytes" + "encoding/json" + "image" + "math" + "reflect" + "testing" + + "github.com/disintegration/imaging" +) + +func TestEffectsParams(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + got := (EffectsParams{}).Normalized() + if got.Texture != 0 || got.Clarity != 0 || got.Dehaze != 0 { + t.Fatalf("strengths changed: %+v", got) + } + v := got.Vignette + if v.Mode != VignetteLight || v.Midpoint != DefaultVignetteMidpoint || + v.Feather != DefaultVignetteFeather || v.Roundness != 0 || v.Highlights != 0 || + v.Color != DefaultVignetteColor || v.Amount != 0 { + t.Fatalf("vignette defaults = %+v", v) + } + g := got.Grain + if g.Amount != 0 || g.Size != DefaultGrainSize || g.Roughness != DefaultGrainRoughness { + t.Fatalf("grain defaults = %+v", g) + } + }) + + t.Run("clamps", func(t *testing.T) { + got := EffectsParams{ + Texture: 250, + Clarity: -250, + Dehaze: 150, + Vignette: VignetteParams{ + Mode: "sepia", Amount: -150, Midpoint: 150, Feather: 150, + Roundness: -150, Highlights: 150, Color: "#AABBCC", + }, + Grain: GrainParams{Amount: 150, Size: 150, Roughness: 150}, + }.Normalized() + if got.Texture != MaxPercent || got.Clarity != -MaxPercent || got.Dehaze != MaxPercent { + t.Fatalf("strengths = %v/%v/%v, want clamped", got.Texture, got.Clarity, got.Dehaze) + } + v := got.Vignette + if v.Mode != VignetteLight { + t.Fatalf("unknown mode = %q, want %q", v.Mode, VignetteLight) + } + if v.Amount != -MaxPercent || v.Midpoint != MaxPercent || v.Feather != MaxPercent || + v.Roundness != -MaxPercent || v.Highlights != MaxPercent { + t.Fatalf("vignette not clamped: %+v", v) + } + if v.Color != "#aabbcc" { + t.Fatalf("color = %q, want lower-case", v.Color) + } + if got.Grain.Amount != MaxPercent || got.Grain.Size != MaxPercent || got.Grain.Roughness != MaxPercent { + t.Fatalf("grain not clamped: %+v", got.Grain) + } + }) + + t.Run("modes and colors", func(t *testing.T) { + cases := []struct { + mode, want string + }{ + {"", VignetteLight}, + {"light", VignetteLight}, + {"LIGHT", VignetteLight}, + {"color", VignetteColor}, + {"Colorize", VignetteColorize}, + {"unknown", VignetteLight}, + } + for _, tc := range cases { + if got := (VignetteParams{Mode: tc.mode}).Normalized().Mode; got != tc.want { + t.Fatalf("mode %q = %q, want %q", tc.mode, got, tc.want) + } + } + colors := []struct { + in, want string + }{ + {"#A1B2C3", "#a1b2c3"}, + {"#abcdef", "#abcdef"}, + {"#12345", DefaultVignetteColor}, + {"#gggggg", DefaultVignetteColor}, + {"aabbcc", DefaultVignetteColor}, + {"red", DefaultVignetteColor}, + {"", DefaultVignetteColor}, + } + for _, tc := range colors { + if got := normalizeHexColor(tc.in); got != tc.want { + t.Fatalf("color %q = %q, want %q", tc.in, got, tc.want) + } + } + }) + + t.Run("idempotent", func(t *testing.T) { + p := EffectsParams{ + Texture: 12.5, Clarity: -8, Dehaze: 33, + Vignette: VignetteParams{Mode: "COLOR", Amount: -25, Midpoint: 40, Feather: 70, Roundness: 20, Highlights: 10, Color: "#FF8000"}, + Grain: GrainParams{Amount: 45, Size: 33, Roughness: 66}, + } + once := p.Normalized() + twice := once.Normalized() + if !reflect.DeepEqual(once, twice) { + t.Fatalf("normalization is not idempotent:\n once = %+v\ntwice = %+v", once, twice) + } + }) + + t.Run("is empty", func(t *testing.T) { + cases := []struct { + name string + p EffectsParams + empty bool + }{ + {"zero", EffectsParams{}, true}, + { + "geometry only", + EffectsParams{ + Vignette: VignetteParams{ + Mode: VignetteColor, Midpoint: 20, Feather: 80, + Roundness: 40, Highlights: 50, Color: "#ff0000", + }, + Grain: GrainParams{Size: 90, Roughness: 90}, + }, + true, + }, + {"texture", EffectsParams{Texture: 1}, false}, + {"clarity", EffectsParams{Clarity: -1}, false}, + {"dehaze", EffectsParams{Dehaze: 1}, false}, + {"vignette", EffectsParams{Vignette: VignetteParams{Amount: -1}}, false}, + {"grain", EffectsParams{Grain: GrainParams{Amount: 1}}, false}, + } + for _, tc := range cases { + if got := tc.p.IsEmpty(); got != tc.empty { + t.Fatalf("%s: IsEmpty = %v, want %v", tc.name, got, tc.empty) + } + } + }) + + t.Run("explicit zero geometry", func(t *testing.T) { + // An explicit zero is a valid geometry value and must survive both + // parsing and normalization; omitted fields still get the defaults. + p, err := ParseEditParams([]byte(`{"effects":{"vignette":{"mode":"light","amount":10,"midpoint":0,"feather":0},"grain":{"amount":10,"size":0,"roughness":0}}}`)) + if err != nil { + t.Fatalf("parse explicit zero: %v", err) + } + if p.Effects.Vignette.Midpoint != 0 || p.Effects.Vignette.Feather != 0 { + t.Fatalf("explicit zero vignette geometry was defaulted: %+v", p.Effects.Vignette) + } + if p.Effects.Grain.Size != 0 || p.Effects.Grain.Roughness != 0 { + t.Fatalf("explicit zero grain geometry was defaulted: %+v", p.Effects.Grain) + } + + omitted, err := ParseEditParams([]byte(`{"effects":{"vignette":{"amount":10},"grain":{"amount":10}}}`)) + if err != nil { + t.Fatalf("parse omitted: %v", err) + } + if omitted.Effects.Vignette.Midpoint != DefaultVignetteMidpoint || + omitted.Effects.Vignette.Feather != DefaultVignetteFeather || + omitted.Effects.Grain.Size != DefaultGrainSize || + omitted.Effects.Grain.Roughness != DefaultGrainRoughness { + t.Fatalf("omitted geometry was not defaulted: %+v", omitted.Effects) + } + }) + + t.Run("json", func(t *testing.T) { + p := EditParams{Effects: EffectsParams{Dehaze: 10}} + data := p.JSON() + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := raw["effects"]; !ok { + t.Fatalf("effects key missing from %s", data) + } + var effects EffectsParams + if err := json.Unmarshal(raw["effects"], &effects); err != nil { + t.Fatalf("unmarshal effects: %v", err) + } + if effects.Dehaze != 10 { + t.Fatalf("dehaze = %v, want 10", effects.Dehaze) + } + var body string + if err := json.Unmarshal(data, &body); err == nil { + t.Fatalf("unexpected string body %q", body) + } + }) + + t.Run("edit params count effects", func(t *testing.T) { + if (EditParams{Effects: EffectsParams{Texture: 1}}).IsEmpty() { + t.Fatal("params with texture must not be empty") + } + if (EditParams{Effects: EffectsParams{Vignette: VignetteParams{Mode: VignetteColor}}}).IsEmpty() != true { + t.Fatal("vignette defaults without strength must count as empty") + } + }) +} + +func TestEditParamsLegacy(t *testing.T) { + empty, err := ParseEditParams([]byte(`{}`)) + if err != nil { + t.Fatalf("parse empty: %v", err) + } + if !empty.IsEmpty() || !empty.Effects.IsEmpty() { + t.Fatalf("empty params = %+v", empty) + } + if empty.Effects.Vignette.Midpoint != DefaultVignetteMidpoint || + empty.Effects.Vignette.Color != DefaultVignetteColor || + empty.Effects.Grain.Size != DefaultGrainSize { + t.Fatalf("legacy defaults not filled: %+v", empty.Effects) + } + + legacy, err := ParseEditParams([]byte(`{"light":{"exposure":0.4},"color":{"temperature":15,"saturation":10}}`)) + if err != nil { + t.Fatalf("parse legacy: %v", err) + } + if legacy.IsEmpty() { + t.Fatal("legacy light/color params must not be empty") + } + if !legacy.Effects.IsEmpty() { + t.Fatalf("params without effects key read as effects: %+v", legacy.Effects) + } + + // The old pipeline (light, then color) renders exactly the same pixels when + // the parameters carry no effects. + src := gradientImage(16, 12) + data := mustEncodePNG(t, src) + got, err := decodeAndAdjust(data, legacy, editSeed("legacy")) + if err != nil { + t.Fatalf("decode and adjust: %v", err) + } + decoded, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) + if err != nil { + t.Fatalf("decode: %v", err) + } + want := applyColor(applyLight(decoded, legacy.Light), legacy.Color) + if !bytes.Equal(got.Pix, want.Pix) { + t.Fatal("legacy render changed when effects are absent") + } + + // An untouched photo decodes to the original pixels. + plain, err := decodeAndAdjust(data, EditParams{}, editSeed("legacy")) + if err != nil { + t.Fatalf("decode and adjust untouched: %v", err) + } + if !bytes.Equal(plain.Pix, imaging.Clone(decoded).Pix) { + t.Fatal("empty params changed the original") + } +} + +func TestBoxBlur(t *testing.T) { + if got := boxBlur([]uint8{1, 2, 3, 4}, 2, 2, 0); !bytes.Equal(got, []uint8{1, 2, 3, 4}) { + t.Fatalf("radius 0 = %v, want identity", got) + } + + constant := make([]uint8, 9*7) + for i := range constant { + constant[i] = 77 + } + blurred := boxBlur3(constant, 9, 7, 3) + for i, v := range blurred { + if v != 77 { + t.Fatalf("constant image changed at %d: %d", i, v) + } + } + + w, h := 13, 9 + src := make([]uint8, w*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + src[y*w+x] = uint8((x*37 + y*61) % 256) + } + } + for _, radius := range []int{1, 2, 5} { + if want, got := naiveBoxPass(src, w, h, radius), boxBlur(src, w, h, radius); !bytes.Equal(got, want) { + t.Fatalf("radius %d: boxBlur = %v, want %v", radius, got, want) + } + want := src + for i := 0; i < 3; i++ { + want = naiveBoxPass(want, w, h, radius) + } + if got := boxBlur3(src, w, h, radius); !bytes.Equal(got, want) { + t.Fatalf("radius %d: boxBlur3 = %v, want %v", radius, got, want) + } + } + + // A one-pixel image is a fixed point of any radius. + if got := boxBlur3([]uint8{42}, 1, 1, 4); len(got) != 1 || got[0] != 42 { + t.Fatalf("1x1 blur = %v, want [42]", got) + } +} + +// naiveBoxPass is the reference implementation of one box-blur pass: the +// window is sampled explicitly with edge-clamped coordinates. +func naiveBoxPass(src []uint8, w, h, radius int) []uint8 { + window := 2*radius + 1 + tmp := make([]uint8, w*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sum := 0 + for kx := -radius; kx <= radius; kx++ { + sum += int(src[y*w+clampInt(x+kx, 0, w-1)]) + } + tmp[y*w+x] = uint8((sum + window/2) / window) + } + } + out := make([]uint8, w*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sum := 0 + for ky := -radius; ky <= radius; ky++ { + sum += int(tmp[clampInt(y+ky, 0, h-1)*w+x]) + } + out[y*w+x] = uint8((sum + window/2) / window) + } + } + return out +} + +func TestTextureClarity(t *testing.T) { + src := checkerImage(64, 64, 96, 160) + base := lumaLocalVariance(src) + for _, effect := range []string{"texture", "clarity"} { + up := applyEffects(imaging.Clone(src), strengths(effect, 100), 1) + down := applyEffects(imaging.Clone(src), strengths(effect, -100), 1) + if got := lumaLocalVariance(up); got <= base { + t.Fatalf("%s +100: local variance %v must grow above %v", effect, got, base) + } + if got := lumaLocalVariance(down); got >= base { + t.Fatalf("%s -100: local variance %v must drop below %v", effect, got, base) + } + } +} + +// lumaLocalVariance is the average variance of the luminance inside a 3x3 +// window: the property that texture and clarity change. +func lumaLocalVariance(img *image.NRGBA) float64 { + plane := lumaPlane(img) + w, h := img.Rect.Dx(), img.Rect.Dy() + var total float64 + count := 0 + for y := 1; y < h-1; y++ { + for x := 1; x < w-1; x++ { + var sum, sumSq float64 + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + v := float64(plane[(y+dy)*w+x+dx]) + sum += v + sumSq += v * v + } + } + mean := sum / 9 + total += sumSq/9 - mean*mean + count++ + } + } + return total / float64(count) +} + +// strengths builds a single-effect parameter block. +func strengths(effect string, value float64) EffectsParams { + switch effect { + case "texture": + return EffectsParams{Texture: value} + case "clarity": + return EffectsParams{Clarity: value} + } + return EffectsParams{} +} + +func TestDehaze(t *testing.T) { + clear := hazyScene(128, 96) + hazy := addHaze(clear, 0.45, 235) + + baseContrast := lumaStdDev(hazy) + baseSaturation := meanSaturation(hazy) + removed := applyEffects(imaging.Clone(hazy), EffectsParams{Dehaze: 100}, 1) + t.Logf("contrast %.2f -> %.2f, saturation %.2f -> %.2f", + baseContrast, lumaStdDev(removed), baseSaturation, meanSaturation(removed)) + if got := lumaStdDev(removed); got <= baseContrast { + t.Fatalf("dehaze +100: contrast %v did not grow above %v", got, baseContrast) + } + if got := meanSaturation(removed); got <= baseSaturation { + t.Fatalf("dehaze +100: saturation %v did not grow above %v", got, baseSaturation) + } + + clearContrast := lumaStdDev(clear) + clearSaturation := meanSaturation(clear) + added := applyEffects(imaging.Clone(clear), EffectsParams{Dehaze: -100}, 1) + t.Logf("clear contrast %.2f -> %.2f, saturation %.2f -> %.2f", + clearContrast, lumaStdDev(added), clearSaturation, meanSaturation(added)) + if got := lumaStdDev(added); got >= clearContrast { + t.Fatalf("dehaze -100: contrast %v did not drop below %v", got, clearContrast) + } + if got := meanSaturation(added); got >= clearSaturation { + t.Fatalf("dehaze -100: saturation %v did not drop below %v", got, clearSaturation) + } +} + +func TestResizeTransmission(t *testing.T) { + // A vertical step map: the top half is dark, the bottom half is bright. + sw, sh := 16, 12 + transmission := make([]uint8, sw*sh) + for y := 0; y < sh; y++ { + for x := 0; x < sw; x++ { + if y < sh/2 { + transmission[y*sw+x] = 20 + } else { + transmission[y*sw+x] = 230 + } + } + } + gray := image.NewGray(image.Rect(0, 0, sw, sh)) + copy(gray.Pix, transmission) + out := resizeTransmission(gray, 128, 96) + if len(out) != 128*96 { + t.Fatalf("resized map length = %d, want %d", len(out), 128*96) + } + // The transition band is blurred by the interpolation, so the edges are + // checked away from the step. + for y := 0; y < 96; y++ { + if y >= 40 && y < 56 { + continue + } + want := uint8(20) + if y >= 56 { + want = 230 + } + for x := 0; x < 128; x++ { + if got := out[y*128+x]; got < want-4 || got > want+4 { + t.Fatalf("pixel %d,%d = %d, want around %d", x, y, got, want) + } + } + } +} + +func TestVignette(t *testing.T) { + src := solidImage(64, 64, 128) + const centerX, centerY = 32, 32 + const cornerX, cornerY = 0, 0 + + t.Run("light mode", func(t *testing.T) { + dark := applyEffects(imaging.Clone(src), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteLight, Amount: 80}, + }, 0) + if pixelAt(dark, centerX, centerY) != 128 { + t.Fatalf("center changed: %d", pixelAt(dark, centerX, centerY)) + } + if pixelAt(dark, cornerX, cornerY) >= 128 { + t.Fatalf("positive amount did not darken the corner: %d", pixelAt(dark, cornerX, cornerY)) + } + + bright := applyEffects(imaging.Clone(src), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteLight, Amount: -80}, + }, 0) + if pixelAt(bright, centerX, centerY) != 128 { + t.Fatalf("center changed: %d", pixelAt(bright, centerX, centerY)) + } + if pixelAt(bright, cornerX, cornerY) <= 128 { + t.Fatalf("negative amount did not brighten the corner: %d", pixelAt(bright, cornerX, cornerY)) + } + }) + + t.Run("zero amount is identity", func(t *testing.T) { + out := applyEffects(imaging.Clone(src), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteColor, Amount: 0, Color: "#ff0000"}, + }, 0) + if !bytes.Equal(out.Pix, src.Pix) { + t.Fatal("zero vignette changed the image") + } + }) + + t.Run("color mode tints the edges", func(t *testing.T) { + out := applyEffects(imaging.Clone(src), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteColor, Amount: 100, Color: "#ff0000"}, + }, 0) + r, g, b := rgbAt(out, cornerX, cornerY) + if !(r > g && r > b) { + t.Fatalf("color vignette corner = %d/%d/%d, want a red tint", r, g, b) + } + if r, g, b := rgbAt(out, centerX, centerY); r != 128 || g != 128 || b != 128 { + t.Fatalf("color vignette changed the center: %d/%d/%d", r, g, b) + } + }) + + t.Run("colorize mode fills the edges", func(t *testing.T) { + out := applyEffects(imaging.Clone(src), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteColorize, Amount: 100, Color: "#ff0000"}, + }, 0) + if r, g, b := rgbAt(out, cornerX, cornerY); r != 255 || g != 0 || b != 0 { + t.Fatalf("colorize corner = %d/%d/%d, want 255/0/0", r, g, b) + } + if r, g, b := rgbAt(out, centerX, centerY); r != 128 || g != 128 || b != 128 { + t.Fatalf("colorize changed the center: %d/%d/%d", r, g, b) + } + }) + + t.Run("highlight protection", func(t *testing.T) { + bright := solidImage(64, 64, 240) + plain := applyEffects(imaging.Clone(bright), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteLight, Amount: 100}, + }, 0) + protected := applyEffects(imaging.Clone(bright), EffectsParams{ + Vignette: VignetteParams{Mode: VignetteLight, Amount: 100, Highlights: 100}, + }, 0) + if pixelAt(protected, cornerX, cornerY) <= pixelAt(plain, cornerX, cornerY) { + t.Fatalf("highlight protection did not preserve the corner: %d <= %d", + pixelAt(protected, cornerX, cornerY), pixelAt(plain, cornerX, cornerY)) + } + }) +} + +func TestGrainDeterminism(t *testing.T) { + src := gradientImage(64, 64) + grain := EffectsParams{Grain: GrainParams{Amount: 70, Size: 30, Roughness: 60}} + + first := applyEffects(imaging.Clone(src), grain, 12345) + second := applyEffects(imaging.Clone(src), grain, 12345) + if !bytes.Equal(first.Pix, second.Pix) { + t.Fatal("two renders with the same parameters and seed differ") + } + if bytes.Equal(first.Pix, src.Pix) { + t.Fatal("grain did not change the image") + } + + other := applyEffects(imaging.Clone(src), grain, 999) + if bytes.Equal(first.Pix, other.Pix) { + t.Fatal("a different seed produced the same grain") + } + + zero := applyEffects(imaging.Clone(src), EffectsParams{ + Grain: GrainParams{Amount: 0, Size: 30, Roughness: 60}, + }, 12345) + if !bytes.Equal(zero.Pix, src.Pix) { + t.Fatal("zero grain strength changed the image") + } + + // The full render path seeds the same noise from the photo id. + data := mustEncodePNG(t, src) + a, err := decodeAndAdjust(data, EditParams{Effects: grain}, editSeed("photo-1")) + if err != nil { + t.Fatalf("decode and adjust: %v", err) + } + b, err := decodeAndAdjust(data, EditParams{Effects: grain}, editSeed("photo-1")) + if err != nil { + t.Fatalf("decode and adjust again: %v", err) + } + if !bytes.Equal(a.Pix, b.Pix) { + t.Fatal("repeated decodeAndAdjust renders differ") + } +} + +func TestEffectsKeepTransparentPixels(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 32, 32)) + for i := 0; i < len(src.Pix); i += 4 { + src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 200, 10, 30, 0 + } + before := append([]uint8(nil), src.Pix...) + out := applyEffects(src, EffectsParams{ + Texture: 60, + Clarity: 60, + Dehaze: 60, + Vignette: VignetteParams{ + Mode: VignetteColorize, Amount: 100, Color: "#00ff00", + }, + Grain: GrainParams{Amount: 100, Size: 40, Roughness: 80}, + }, 7) + if !bytes.Equal(out.Pix, before) { + t.Fatal("effects changed transparent pixels") + } +} + +// solidImage returns a uniform opaque image. +func solidImage(w, h int, value uint8) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i] = value + img.Pix[i+1] = value + img.Pix[i+2] = value + img.Pix[i+3] = 255 + } + return img +} + +// gradientImage returns an opaque image with smooth horizontal and vertical +// variation, so every effect has something to work with. +func gradientImage(w, h int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*img.Stride + x*4 + img.Pix[i] = uint8(20 + x*3) + img.Pix[i+1] = uint8(30 + y*3) + img.Pix[i+2] = uint8(40 + (x + y)) + img.Pix[i+3] = 255 + } + } + return img +} + +// checkerImage returns a fine checkerboard, the high-frequency input of the +// local-contrast tests. +func checkerImage(w, h int, dark, light uint8) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*img.Stride + x*4 + v := dark + if (x+y)%2 == 0 { + v = light + } + img.Pix[i] = v + img.Pix[i+1] = v + img.Pix[i+2] = v + img.Pix[i+3] = 255 + } + } + return img +} + +// hazyScene builds a synthetic landscape: a bright sky band and a colored +// checker with a brightness gradient. It represents the "clear" frame. +func hazyScene(w, h int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + sky := h / 6 + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*img.Stride + x*4 + if y < sky { + img.Pix[i], img.Pix[i+1], img.Pix[i+2] = 235, 235, 235 + img.Pix[i+3] = 255 + continue + } + t := float64(x) / float64(w-1) + if (x/16+y/16)%2 == 0 { + img.Pix[i] = uint8(40 + 180*t) + img.Pix[i+1] = uint8(40 + 60*t) + img.Pix[i+2] = uint8(40 + 40*t) + } else { + img.Pix[i] = uint8(40 + 40*t) + img.Pix[i+1] = uint8(40 + 120*t) + img.Pix[i+2] = uint8(40 + 180*t) + } + img.Pix[i+3] = 255 + } + } + return img +} + +// addHaze mixes the frame toward an atmospheric color. +func addHaze(src *image.NRGBA, haze float64, atmosphere uint8) *image.NRGBA { + out := imaging.Clone(src) + w, h := out.Rect.Dx(), out.Rect.Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*out.Stride + x*4 + for c := 0; c < 3; c++ { + out.Pix[i+c] = uint8(float64(out.Pix[i+c])*(1-haze) + float64(atmosphere)*haze + 0.5) + } + } + } + return out +} + +// lumaVariance is the global variance of the luminance. +func lumaVariance(img *image.NRGBA) float64 { + plane := lumaPlane(img) + var sum float64 + for _, v := range plane { + sum += float64(v) + } + mean := sum / float64(len(plane)) + var variance float64 + for _, v := range plane { + d := float64(v) - mean + variance += d * d + } + return variance / float64(len(plane)) +} + +// lumaStdDev is the contrast measure of the dehaze test. +func lumaStdDev(img *image.NRGBA) float64 { + return math.Sqrt(lumaVariance(img)) +} + +// meanSaturation is the average channel spread of the image. +func meanSaturation(img *image.NRGBA) float64 { + w, h := img.Rect.Dx(), img.Rect.Dy() + var sum float64 + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := y*img.Stride + x*4 + mx := max(img.Pix[i], max(img.Pix[i+1], img.Pix[i+2])) + mn := min(img.Pix[i], min(img.Pix[i+1], img.Pix[i+2])) + sum += float64(mx - mn) + } + } + return sum / float64(w*h) +} + +func pixelAt(img *image.NRGBA, x, y int) uint8 { + return img.Pix[y*img.Stride+x*4] +} + +func rgbAt(img *image.NRGBA, x, y int) (uint8, uint8, uint8) { + i := y*img.Stride + x*4 + return img.Pix[i], img.Pix[i+1], img.Pix[i+2] +} diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 80cf050..3b6035c 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -123,14 +123,15 @@ func (h *Handler) Download(c *gin.Context) { // the photo metadata and the edited version is rendered in the background. func (h *Handler) Edit(c *gin.Context) { var req struct { - Light LightParams `json:"light"` - Color ColorParams `json:"color"` + Light LightParams `json:"light"` + Color ColorParams `json:"color"` + Effects EffectsParams `json:"effects"` } if err := c.ShouldBindJSON(&req); err != nil { httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required")) return } - p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color}) + p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color, Effects: req.Effects}) if err != nil { httpx.RespondError(c, err) return diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index b416e90..f4a7553 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "hash/fnv" "io" "log/slog" "os" @@ -202,7 +203,7 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) { w.failEdit(ctx, photoID, "fetch original: "+err.Error()) return } - edited, err := decodeAndAdjust(data, params) + edited, err := decodeAndAdjust(data, params, editSeed(photoID)) if err != nil { w.failEdit(ctx, photoID, "decode original: "+err.Error()) return @@ -211,7 +212,8 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) { if err != nil { w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) return - } // The edit belongs to this photo only, so the variants are not shared + } + // The edit belongs to this photo only, so the variants are not shared // with the copies of the same original. owner := []string{photoID} if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil { @@ -240,6 +242,15 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) { w.notify(ctx, photoID) } +// editSeed is the deterministic seed of the grain render: the FNV-1a hash of the +// photo id. It depends on the photo and not on the parameters, so a repeated +// render of the same edit produces the same noise pattern. +func editSeed(photoID string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(photoID)) + return h.Sum64() +} + func (w *Worker) failEdit(ctx context.Context, photoID, msg string) { slog.Warn("edit job failed", "photo", photoID, "error", msg) _ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts) diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index bc7035f..ca1191e 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -8,30 +8,44 @@ import { edit } from '$lib/stores/edit.svelte'; import { eyedropper } from '$lib/stores/eyedropper.svelte'; import { auth } from '$lib/stores/auth.svelte'; + import { + loadSectionCollapse, + saveSectionCollapse, + type SectionCollapse + } from '$lib/editor-sections'; import { applyColor, applyLight } from '$lib/light-wasm'; import { CHANNEL_COLORS, CHANNEL_LABELS, COLOR_SLIDERS, CURVE_CHANNELS, + EFFECT_SLIDERS, + GRAIN_SLIDERS, GRADING_WHEELS, LIGHT_SLIDERS, MAX_CURVE_POINTS, MIXER_BANDS, + VIGNETTE_MODES, + VIGNETTE_SLIDERS, curveSvgPath, effectiveCurvePoints, emptyEditParams, isEmptyEditParams, normalizeEditParams, + normalizeVignetteColor, readEditParams, whiteBalanceFromRGB, type ColorSliderKey, type CurveChannel, type CurvePoint, type EditParams, + type EffectSliderKey, + type GrainSliderKey, type GradingWheelKey, type MixerBand, - type MixerBandKey + type MixerBandKey, + type VignetteMode, + type VignetteSliderKey } from '$lib/edit'; import { findActiveCurvePreset, @@ -57,15 +71,15 @@ // перетирать более свежие правки (например, после переключения фото). let saveSequence = 0; const latestSaveByPhoto = new Map(); - let collapsed = $state(false); - let curveCollapsed = $state(false); + let collapsed = $state(loadSectionCollapse()); + // Состояние секций хранится в браузере: панель открывается в том же виде + // после перезагрузки и переключения фото. + $effect(() => { + saveSectionCollapse(collapsed); + }); let channel = $state('rgb'); - let colorCollapsed = $state(false); - let mixerCollapsed = $state(false); let mixerTab = $state('hue'); let mixerBand = $state('red'); - let pointColorCollapsed = $state(false); - let correctionCollapsed = $state(false); let correctionView = $state<'all' | 'midtones' | 'split' | 'highlights'>('all'); let wbPreset = $state('as-shot'); @@ -337,6 +351,57 @@ scheduleSave(); } + function onEffectInput(key: EffectSliderKey, value: number) { + params = { ...params, effects: { ...params.effects, [key]: value } }; + edit.set(photo.id, params); + } + + function onVignetteInput(key: VignetteSliderKey, value: number) { + params = { + ...params, + effects: { + ...params.effects, + vignette: { ...params.effects.vignette, [key]: value } + } + }; + edit.set(photo.id, params); + } + + function onVignetteMode(mode: VignetteMode) { + params = { + ...params, + effects: { ...params.effects, vignette: { ...params.effects.vignette, mode } } + }; + edit.set(photo.id, params); + scheduleSave(); + } + + function onVignetteColor(value: string) { + params = { + ...params, + effects: { + ...params.effects, + vignette: { ...params.effects.vignette, color: normalizeVignetteColor(value) } + } + }; + edit.set(photo.id, params); + } + + function onGrainInput(key: GrainSliderKey, value: number) { + params = { + ...params, + effects: { ...params.effects, grain: { ...params.effects.grain, [key]: value } } + }; + edit.set(photo.id, params); + } + + function onEffectsCommit() { + if (!canEdit) return; + params = normalizeEditParams(params); + edit.set(photo.id, params); + scheduleSave(); + } + function onMixerInput(band: MixerBandKey, component: keyof MixerBand, value: number) { const current = params.color.mixer[band]; params = { @@ -654,12 +719,12 @@ @@ -675,7 +740,7 @@ - {#if !collapsed} + {#if !collapsed.light}
{#each LIGHT_SLIDERS as slider (slider.key)} @@ -698,9 +763,9 @@ - {#if !curveCollapsed} + {#if !collapsed.curve}
{#each CURVE_CHANNELS as ch (ch)} @@ -842,13 +907,13 @@ @@ -857,7 +922,7 @@
- {#if !colorCollapsed} + {#if !collapsed.color}
@@ -918,9 +983,9 @@ - {#if !mixerCollapsed} + {#if !collapsed.mixer}
{#each MIXER_BANDS as band (band.key)} @@ -1015,9 +1080,9 @@ - {#if !pointColorCollapsed} + {#if !collapsed.pointColor}
Выберите цвет на фото пипеткой. @@ -1040,9 +1105,9 @@ - {#if !correctionCollapsed} + {#if !collapsed.correction}
{#each CORRECTION_VIEWS as view (view.key)} @@ -1169,6 +1234,116 @@ {/if}
+
+
+ + + + +
+ + {#if !collapsed.effects} +
+
+ {#each EFFECT_SLIDERS as slider (slider.key)} + onEffectInput(slider.key, value)} + onchange={onEffectsCommit} + /> + {/each} +
+ +
+
+ {#each VIGNETTE_MODES as mode (mode.key)} + + {/each} +
+ + {#each VIGNETTE_SLIDERS as slider (slider.key)} + onVignetteInput(slider.key, value)} + onchange={onEffectsCommit} + /> + {/each} + + {#if params.effects.vignette.mode !== 'light'} + + {/if} +
+ +
+
Зерно
+ {#each GRAIN_SLIDERS as slider (slider.key)} + onGrainInput(slider.key, value)} + onchange={onEffectsCommit} + /> + {/each} +
+
+ {/if} +
+ {#if busy}

Сохраняем на сервере…

{:else if dirty && !busy} diff --git a/frontend/src/lib/components/PhotoPanel.svelte b/frontend/src/lib/components/PhotoPanel.svelte index 2f04a00..ae029b6 100644 --- a/frontend/src/lib/components/PhotoPanel.svelte +++ b/frontend/src/lib/components/PhotoPanel.svelte @@ -42,6 +42,10 @@ const canEditPhoto = $derived( canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer') ); + // Редактирование доступно только из просмотрщика: в режиме сетки вкладка + // «Редактирование» скрыта, панель показывает «Сведения». Сохранённая + // вкладка при этом не теряется — она снова станет активной в просмотрщике. + const activeTab = $derived(viewer ? tab : 'info'); let editingName = $state(false); let nameDraft = $state(''); @@ -242,26 +246,28 @@ const tabClass = role="tab" id="photo-tab-info" aria-controls="photo-panel-info" - aria-selected={tab === 'info'} + aria-selected={activeTab === 'info'} class={tabClass} onclick={() => (tab = 'info')} > Сведения - + {#if viewer} + + {/if}
- {#if tab === 'edit'} + {#if activeTab === 'edit'}
{ }); }); +describe('эффекты', () => { + function uniform(width: number, height: number, value = 128): Uint8ClampedArray { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = value; + pixels[i + 1] = value; + pixels[i + 2] = value; + pixels[i + 3] = 255; + } + return pixels; + } + + function channel(pixels: Uint8ClampedArray, width: number, x: number, y: number): [number, number, number] { + const i = (y * width + x) * 4; + return [pixels[i], pixels[i + 1], pixels[i + 2]]; + } + + it('зажимает значения и заполняет дефолты', () => { + const params = normalizeEditParams({ + light: emptyEditParams().light, + color: emptyColor(), + effects: { + texture: 500, + clarity: -500, + dehaze: 50, + vignette: { + mode: 'weird' as never, + amount: 200, + midpoint: -10, + feather: 300, + roundness: 500, + highlights: -5, + color: 'red' + }, + grain: { amount: -10, size: 500, roughness: -5 } + } + }); + expect(params.effects.texture).toBe(100); + expect(params.effects.clarity).toBe(-100); + expect(params.effects.dehaze).toBe(50); + expect(params.effects.vignette.mode).toBe('light'); + expect(params.effects.vignette.amount).toBe(100); + expect(params.effects.vignette.midpoint).toBe(0); + expect(params.effects.vignette.feather).toBe(100); + expect(params.effects.vignette.roundness).toBe(100); + expect(params.effects.vignette.highlights).toBe(0); + expect(params.effects.vignette.color).toBe('#000000'); + expect(params.effects.grain.amount).toBe(0); + expect(params.effects.grain.size).toBe(100); + expect(params.effects.grain.roughness).toBe(0); + }); + + it('нормализует цвет виньетки', () => { + expect(normalizeVignette({ color: '#AABBCC' }).color).toBe('#aabbcc'); + expect(normalizeVignette({ color: '#abc' }).color).toBe('#aabbcc'); + expect(normalizeVignette({ color: 'нет' }).color).toBe('#000000'); + expect(normalizeVignette({}).midpoint).toBe(50); + expect(normalizeVignette({}).feather).toBe(50); + }); + + it('считает пустыми эффекты без силы', () => { + expect(isEmptyEffects(emptyEffects())).toBe(true); + expect(isEmptyEffects({ ...emptyEffects(), texture: 1 })).toBe(false); + const noVignettePower = emptyEffects(); + noVignettePower.vignette.amount = 0; + noVignettePower.vignette.midpoint = 90; + noVignettePower.vignette.color = '#ff0000'; + expect(isEmptyEffects(noVignettePower)).toBe(true); + const grain = emptyEffects(); + grain.grain.amount = 1; + expect(isEmptyEffects(grain)).toBe(false); + }); + + it('читает эффекты из метаданных и не теряет свет и цвет', () => { + const photo = { + edit_params: { + light: { exposure: 0.5 }, + color: { temperature: 20 }, + effects: { texture: 30, grain: { amount: 10 } } + } + } as unknown as Photo; + const params = readEditParams(photo); + expect(params.light.exposure).toBe(0.5); + expect(params.color.temperature).toBe(20); + expect(params.effects.texture).toBe(30); + expect(params.effects.grain.amount).toBe(10); + expect(params.effects.grain.size).toBe(25); + expect(isEmptyEditParams(params)).toBe(false); + }); + + it('затемняет края и не трогает центр', () => { + const pixels = uniform(9, 9); + applyVignetteToPixels( + pixels, + { ...emptyEffects().vignette, amount: 100 }, + 9, + 9 + ); + const center = channel(pixels, 9, 4, 4); + const corner = channel(pixels, 9, 0, 0); + expect(center).toEqual([128, 128, 128]); + expect(corner[0]).toBeLessThan(center[0]); + }); + + it('осветляет края при отрицательной силе', () => { + const pixels = uniform(9, 9); + applyVignetteToPixels( + pixels, + { ...emptyEffects().vignette, amount: -100 }, + 9, + 9 + ); + expect(channel(pixels, 9, 0, 0)[0]).toBeGreaterThan(128); + }); + + it('тонирует и заливает края цветом', () => { + const tinted = uniform(9, 9); + applyVignetteToPixels( + tinted, + { + ...emptyEffects().vignette, + mode: 'color', + amount: 100, + color: '#ff0000' + }, + 9, + 9 + ); + const colorCorner = channel(tinted, 9, 0, 0); + expect(colorCorner[0]).toBeGreaterThan(colorCorner[1]); + expect(colorCorner[0]).toBeGreaterThan(colorCorner[2]); + expect(channel(tinted, 9, 4, 4)).toEqual([128, 128, 128]); + + const painted = uniform(9, 9); + applyVignetteToPixels( + painted, + { + ...emptyEffects().vignette, + mode: 'colorize', + amount: 100, + color: '#ff0000' + }, + 9, + 9 + ); + expect(channel(painted, 9, 0, 0)).toEqual([255, 0, 0]); + }); + + it('не трогает прозрачные пиксели', () => { + const pixels = new Uint8ClampedArray([10, 20, 30, 0]); + applyVignetteToPixels( + pixels, + { ...emptyEffects().vignette, amount: 100 }, + 1, + 1 + ); + expect(Array.from(pixels)).toEqual([10, 20, 30, 0]); + }); + + it('делает зерно детерминированным', () => { + const seed = effectsSeed('photo-1'); + const first = uniform(16, 16); + const second = uniform(16, 16); + const grain = { ...emptyEffects().grain, amount: 80 }; + applyGrainToPixels(first, grain, 16, 16, seed); + applyGrainToPixels(second, grain, 16, 16, seed); + expect(Array.from(first)).toEqual(Array.from(second)); + expect(first[0]).not.toBe(128); + + const other = uniform(16, 16); + applyGrainToPixels(other, grain, 16, 16, effectsSeed('photo-2')); + expect(Array.from(other)).not.toEqual(Array.from(first)); + }); + + it('не меняет кадр при нулевой силе зерна', () => { + const pixels = uniform(8, 8); + applyGrainToPixels(pixels, emptyEffects().grain, 8, 8, effectsSeed('photo-1')); + expect(Array.from(pixels)).toEqual(Array.from(uniform(8, 8))); + }); +}); + describe('кривая', () => { const medium = [ { x: 0, y: 0 }, diff --git a/frontend/src/lib/edit.ts b/frontend/src/lib/edit.ts index b0cf879..61ca984 100644 --- a/frontend/src/lib/edit.ts +++ b/frontend/src/lib/edit.ts @@ -77,9 +77,49 @@ export interface ColorParams { correction: ColorCorrection; } +export type VignetteMode = 'light' | 'color' | 'colorize'; + +/** Параметры виньетки: геометрия общая для всех режимов. */ +export interface VignetteParams { + /** Режим: «Свет», «Цвет» или «Закарасить». */ + mode: VignetteMode; + /** Сила виньетки, -100..100. */ + amount: number; + /** Средняя точка, 0..100. */ + midpoint: number; + /** Растушёвка, 0..100. */ + feather: number; + /** Округлость, -100..100. */ + roundness: number; + /** Защита светов, 0..100. */ + highlights: number; + /** Цвет виньетки для режимов color/colorize, "#rrggbb". */ + color: string; +} + +/** Параметры зерна. */ +export interface GrainParams { + /** Сила зерна, 0..100. */ + amount: number; + /** Размер зерна, 0..100. */ + size: number; + /** Шероховатость, 0..100. */ + roughness: number; +} + +/** Блок «Эффекты»: пространственные эффекты, виньетка и зерно. */ +export interface EffectsParams { + texture: number; + clarity: number; + dehaze: number; + vignette: VignetteParams; + grain: GrainParams; +} + export interface EditParams { light: LightParams; color: ColorParams; + effects: EffectsParams; } /** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */ @@ -106,6 +146,44 @@ export const COLOR_SLIDERS = [ export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key']; +/** Ползунки пространственных эффектов. */ +export const EFFECT_SLIDERS = [ + { key: 'texture', label: 'Текстура', min: -100, max: 100, step: 1 }, + { key: 'clarity', label: 'Чёткость', min: -100, max: 100, step: 1 }, + { key: 'dehaze', label: 'Убрать дымку', min: -100, max: 100, step: 1 } +] as const; + +export type EffectSliderKey = (typeof EFFECT_SLIDERS)[number]['key']; + +/** Ползунки виньетки: общие для всех режимов. */ +export const VIGNETTE_SLIDERS = [ + { key: 'amount', label: 'Виньетка', min: -100, max: 100, step: 1 }, + { key: 'midpoint', label: 'Средняя точка', min: 0, max: 100, step: 1 }, + { key: 'feather', label: 'Растушёвка', min: 0, max: 100, step: 1 }, + { key: 'roundness', label: 'Округлость', min: -100, max: 100, step: 1 }, + { key: 'highlights', label: 'Света', min: 0, max: 100, step: 1 } +] as const; + +export type VignetteSliderKey = (typeof VIGNETTE_SLIDERS)[number]['key']; + +/** Ползунки зерна. */ +export const GRAIN_SLIDERS = [ + { key: 'amount', label: 'Зерно', min: 0, max: 100, step: 1 }, + { key: 'size', label: 'Размер', min: 0, max: 100, step: 1 }, + { key: 'roughness', label: 'Шероховатость', min: 0, max: 100, step: 1 } +] as const; + +export type GrainSliderKey = (typeof GRAIN_SLIDERS)[number]['key']; + +/** Вкладки виньетки. */ +export const VIGNETTE_MODES = [ + { key: 'light', label: 'Свет' }, + { key: 'color', label: 'Цвет' }, + { key: 'colorize', label: 'Закарасить' } +] as const; + +export const DEFAULT_VIGNETTE_COLOR = '#000000'; + /** Колёса точечного цвета. */ export const GRADING_WHEELS = [ { key: 'shadows', label: 'Тени' }, @@ -173,7 +251,7 @@ export function emptyLight(): LightParams { } export function emptyEditParams(): EditParams { - return { light: emptyLight(), color: emptyColor() }; + return { light: emptyLight(), color: emptyColor(), effects: emptyEffects() }; } function emptyMixerBand(): MixerBand { @@ -207,6 +285,25 @@ export function emptyColor(): ColorParams { }; } +/** Пустые эффекты: все силы нулевые, геометрия — по умолчанию. */ +export function emptyEffects(): EffectsParams { + return { + texture: 0, + clarity: 0, + dehaze: 0, + vignette: { + mode: 'light', + amount: 0, + midpoint: 50, + feather: 50, + roundness: 0, + highlights: 0, + color: DEFAULT_VIGNETTE_COLOR + }, + grain: { amount: 0, size: 25, roughness: 50 } + }; +} + 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)); @@ -234,6 +331,7 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined export function normalizeEditParams(params: { light?: Partial; color?: Partial; + effects?: Partial; }): EditParams { const light = params?.light ?? emptyLight(); return { @@ -251,7 +349,8 @@ export function normalizeEditParams(params: { blue: normalizeCurve(light.curve?.blue) } }, - color: normalizeColor(params?.color) + color: normalizeColor(params?.color), + effects: normalizeEffects(params?.effects) }; } @@ -306,6 +405,53 @@ export function normalizeColor(color?: Partial): ColorParams { }; } +const VIGNETTE_MODE_KEYS: VignetteMode[] = ['light', 'color', 'colorize']; + +/** Приводит hex-цвет виньетки к виду "#rrggbb"; невалидное значение — чёрный. */ +export function normalizeVignetteColor(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_VIGNETTE_COLOR; + const raw = value.trim().replace(/^#/, ''); + const hex = raw.length === 3 ? raw.replace(/./g, (c) => c + c) : raw; + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return DEFAULT_VIGNETTE_COLOR; + return `#${hex.toLowerCase()}`; +} + +/** Приводит параметры виньетки к допустимым диапазонам и дефолтам. */ +export function normalizeVignette(v?: Partial): VignetteParams { + const mode = VIGNETTE_MODE_KEYS.includes(v?.mode as VignetteMode) + ? (v?.mode as VignetteMode) + : 'light'; + return { + mode, + amount: clamp(v?.amount ?? 0, -MAX_PERCENT, MAX_PERCENT), + midpoint: clamp(v?.midpoint ?? 50, 0, MAX_PERCENT), + feather: clamp(v?.feather ?? 50, 0, MAX_PERCENT), + roundness: clamp(v?.roundness ?? 0, -MAX_PERCENT, MAX_PERCENT), + highlights: clamp(v?.highlights ?? 0, 0, MAX_PERCENT), + color: normalizeVignetteColor(v?.color) + }; +} + +/** Приводит параметры зерна к допустимым диапазонам и дефолтам. */ +export function normalizeGrain(g?: Partial): GrainParams { + return { + amount: clamp(g?.amount ?? 0, 0, MAX_PERCENT), + size: clamp(g?.size ?? 25, 0, MAX_PERCENT), + roughness: clamp(g?.roughness ?? 50, 0, MAX_PERCENT) + }; +} + +/** Приводит эффекты к допустимым диапазонам и дефолтам. */ +export function normalizeEffects(effects?: Partial): EffectsParams { + return { + texture: clamp(effects?.texture ?? 0, -MAX_PERCENT, MAX_PERCENT), + clarity: clamp(effects?.clarity ?? 0, -MAX_PERCENT, MAX_PERCENT), + dehaze: clamp(effects?.dehaze ?? 0, -MAX_PERCENT, MAX_PERCENT), + vignette: normalizeVignette(effects?.vignette), + grain: normalizeGrain(effects?.grain) + }; +} + /** Пустой ли блок «Свет». */ export function isEmptyLight(light: LightParams): boolean { if ( @@ -341,7 +487,21 @@ export function isColorFast(color: ColorParams): boolean { /** Пустые ли параметры: фотография не тронута. */ export function isEmptyEditParams(params: EditParams): boolean { - return isEmptyLight(params.light) && isEmptyColor(params.color); + return ( + isEmptyLight(params.light) && isEmptyColor(params.color) && isEmptyEffects(params.effects) + ); +} + +/** Пусты ли эффекты: все силы нулевые (дефолты геометрии правкой не считаются). */ +export function isEmptyEffects(effects?: EffectsParams): boolean { + if (!effects) return true; + return ( + !effects.texture && + !effects.clarity && + !effects.dehaze && + !effects.vignette?.amount && + !effects.grain?.amount + ); } /** Пусты ли цветовые параметры: цвет не тронут. */ @@ -386,16 +546,17 @@ export function whiteBalanceFromRGB( /** Читает параметры правок из метаданных фото. */ export function readEditParams(photo: Photo | null | undefined): EditParams { const raw = photo?.edit_params as - | { light?: Partial; color?: Partial } + | { light?: Partial; color?: Partial; effects?: Partial } | undefined; - if (!raw?.light && !raw?.color) return emptyEditParams(); + if (!raw?.light && !raw?.color && !raw?.effects) return emptyEditParams(); return normalizeEditParams({ light: { ...emptyLight(), ...raw?.light, curve: raw?.light?.curve ?? {} }, - color: raw?.color ?? emptyColor() + color: raw?.color ?? emptyColor(), + effects: raw?.effects ?? emptyEffects() }); } @@ -758,3 +919,176 @@ export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams): data[i + 2] = Math.round(clamp(bb, 0, 255)); } } + +/** Разбирает "#rrggbb" в RGB (0..255); на входе уже нормализованный цвет. */ +function parseHexColor(hex: string): [number, number, number] { + const m = /^#([0-9a-f]{6})$/.exec(hex); + if (!m) return [0, 0, 0]; + const value = parseInt(m[1], 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + if (edge1 <= edge0) return x < edge0 ? 0 : 1; + const t = clamp01((x - edge0) / (edge1 - edge0)); + return t * t * (3 - 2 * t); +} + +/** Яркость пикселя (та же формула, что в серверном рендере). */ +function luma(r: number, g: number, b: number): number { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +/** + * Применяет виньетку к пикселям (в том же массиве). Математика повторяет + * серверную: нормализованные координаты, суперэллипс по округлости, + * smoothstep по средней точке и растушёвке, три режима. + */ +export function applyVignetteToPixels( + data: Uint8ClampedArray, + vignette: VignetteParams, + width: number, + height: number +): void { + if (!width || !height || !vignette?.amount) return; + const p = 2 * Math.pow(6, vignette.roundness / 100); + const start = 0.15 + 0.85 * (vignette.midpoint / 100); + const feather = 0.02 + 0.48 * (vignette.feather / 100); + const amount = vignette.amount / 100; + const highlights = vignette.highlights / 100; + const tint = parseHexColor(vignette.color); + const tintLuma = luma(tint[0], tint[1], tint[2]); + const colorize = vignette.mode === 'colorize'; + const colored = colorize || vignette.mode === 'color'; + + for (let y = 0; y < height; y++) { + const dy = ((y + 0.5) / height) * 2 - 1; + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + if (data[i + 3] === 0) continue; + const dx = ((x + 0.5) / width) * 2 - 1; + const radius = Math.pow( + Math.pow(Math.abs(dx), p) + Math.pow(Math.abs(dy), p), + 1 / p + ); + const mask = smoothstep(start, start + feather, radius); + if (mask <= 0) continue; + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const y0 = luma(r, g, b); + const light = 1 - highlights * smoothstep(0.5, 1, y0 / 255); + + if (!colored) { + let factor = 1 - amount * 0.85 * mask; + const protect = highlights * mask * smoothstep(0.5, 1, y0 / 255); + factor += (1 - factor) * protect; + data[i] = Math.round(clamp(r * factor, 0, 255)); + data[i + 1] = Math.round(clamp(g * factor, 0, 255)); + data[i + 2] = Math.round(clamp(b * factor, 0, 255)); + continue; + } + + const strength = Math.abs(amount) * mask * light; + if (strength <= 0) continue; + if (colorize) { + data[i] = Math.round(clamp(r + (tint[0] - r) * strength, 0, 255)); + data[i + 1] = Math.round(clamp(g + (tint[1] - g) * strength, 0, 255)); + data[i + 2] = Math.round(clamp(b + (tint[2] - b) * strength, 0, 255)); + } else { + const target = [tint[0] - tintLuma + y0, tint[1] - tintLuma + y0, tint[2] - tintLuma + y0]; + data[i] = Math.round(clamp(r + (target[0] - r) * strength, 0, 255)); + data[i + 1] = Math.round(clamp(g + (target[1] - g) * strength, 0, 255)); + data[i + 2] = Math.round(clamp(b + (target[2] - b) * strength, 0, 255)); + } + } + } +} + +/** FNV-1a 64-bit: seed зерна, одинаковый с серверным рендером (photo.id). */ +export function effectsSeed(photoId: string): bigint { + let hash = 0xcbf29ce484222325n; + const bytes = new TextEncoder().encode(photoId); + for (const byte of bytes) { + hash ^= BigInt(byte); + hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn; + } + return hash; +} + +function mix32(x: number): number { + x = Math.imul(x ^ (x >>> 16), 0x7feb352d); + x = Math.imul(x ^ (x >>> 15), 0x846ca68b); + return (x ^ (x >>> 16)) >>> 0; +} + +/** Детерминированный шум в -1..1 по координатам пикселя/ячейки. */ +function hashNoise(seedLo: number, seedHi: number, x: number, y: number): number { + let h = seedLo ^ mix32(seedHi ^ mix32(Math.imul(x, 0x9e3779b1) ^ Math.imul(y, 0x85ebca6b))); + h = mix32(h); + return (h / 0xffffffff) * 2 - 1; +} + +/** + * Применяет зерно к пикселям (в том же массиве). Детерминировано от seed: + * повторный вызов с теми же аргументами даёт тот же рисунок. + */ +export function applyGrainToPixels( + data: Uint8ClampedArray, + grain: GrainParams, + width: number, + height: number, + seed: bigint +): void { + if (!width || !height || !grain?.amount) return; + const minDim = Math.min(width, height); + const cell = Math.max(1, Math.round((0.0005 + 0.004 * (grain.size / 100)) * minDim)); + const roughness = grain.roughness / 100; + const strength = (grain.amount / 100) * 38; + const seedLo = Number(seed & 0xffffffffn); + const seedHi = Number((seed >> 32n) & 0xffffffffn); + + for (let y = 0; y < height; y++) { + const fy = y / cell; + const iy = Math.floor(fy); + const ty0 = fy - iy; + const ty = ty0 * ty0 * (3 - 2 * ty0); + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + if (data[i + 3] === 0) continue; + const fx = x / cell; + const ix = Math.floor(fx); + const tx0 = fx - ix; + const tx = tx0 * tx0 * (3 - 2 * tx0); + const v00 = hashNoise(seedLo, seedHi, ix, iy); + const v10 = hashNoise(seedLo, seedHi, ix + 1, iy); + const v01 = hashNoise(seedLo, seedHi, ix, iy + 1); + const v11 = hashNoise(seedLo, seedHi, ix + 1, iy + 1); + const value = v00 + (v10 - v00) * tx + (v01 - v00) * ty + (v00 - v10 - v01 + v11) * tx * ty; + const white = hashNoise(seedLo, seedHi, x, y); + const noise = value * (1 - roughness) + white * roughness; + const y0 = luma(data[i], data[i + 1], data[i + 2]); + const weight = 1 - Math.pow((2 * y0) / 255 - 1, 2); + const delta = noise * strength * weight; + data[i] = Math.round(clamp(data[i] + delta, 0, 255)); + data[i + 1] = Math.round(clamp(data[i + 1] + delta, 0, 255)); + data[i + 2] = Math.round(clamp(data[i + 2] + delta, 0, 255)); + } + } +} + +/** + * Применяет эффекты к пикселям кадра (в том же массиве). CPU-фолбэк считает + * только попиксельные эффекты — виньетку и зерно; пространственные эффекты + * появляются после серверного рендера. + */ +export function applyEffectsToPixels( + data: Uint8ClampedArray, + effects: EffectsParams, + width: number, + height: number, + seed: bigint +): void { + applyVignetteToPixels(data, effects.vignette, width, height); + applyGrainToPixels(data, effects.grain, width, height, seed); +} diff --git a/frontend/src/lib/editor-sections.test.ts b/frontend/src/lib/editor-sections.test.ts new file mode 100644 index 0000000..34dffd0 --- /dev/null +++ b/frontend/src/lib/editor-sections.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + defaultSectionCollapse, + EDITOR_SECTIONS_KEY, + loadSectionCollapse, + normalizeSectionCollapse, + saveSectionCollapse +} from './editor-sections'; + +function memoryStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem(key: string) { + return data.has(key) ? data.get(key)! : null; + }, + setItem(key: string, value: string) { + data.set(key, value); + } + }; +} + +describe('секции редактора', () => { + it('по умолчанию все секции свёрнуты', () => { + const state = defaultSectionCollapse(); + for (const section of [ + 'light', + 'curve', + 'color', + 'mixer', + 'pointColor', + 'correction', + 'effects' + ] as const) { + expect(state[section]).toBe(true); + } + expect(loadSectionCollapse(null)).toEqual(defaultSectionCollapse()); + }); + + it('читает сохранённое состояние и восстанавливает недостающие ключи', () => { + const storage = memoryStorage({ + [EDITOR_SECTIONS_KEY]: JSON.stringify({ effects: false, light: false, unknown: false }) + }); + const state = loadSectionCollapse(storage); + expect(state.effects).toBe(false); + expect(state.light).toBe(false); + expect(state.color).toBe(true); + expect('unknown' in state).toBe(false); + }); + + it('игнорирует невалидный JSON и небулевы значения', () => { + expect( + loadSectionCollapse(memoryStorage({ [EDITOR_SECTIONS_KEY]: '{oops' })) + ).toEqual(defaultSectionCollapse()); + expect(normalizeSectionCollapse({ effects: 'no', color: 1 })).toEqual( + defaultSectionCollapse() + ); + expect(normalizeSectionCollapse(null)).toEqual(defaultSectionCollapse()); + }); + + it('сохраняет состояние в хранилище', () => { + const storage = memoryStorage(); + const state = { ...defaultSectionCollapse(), effects: false, curve: false }; + saveSectionCollapse(state, storage); + expect(storage.data.get(EDITOR_SECTIONS_KEY)).toBe(JSON.stringify(state)); + expect(loadSectionCollapse(storage)).toEqual(state); + }); + + it('переживает недоступное хранилище', () => { + const broken = { + getItem() { + throw new Error('denied'); + }, + setItem() { + throw new Error('denied'); + } + }; + expect(loadSectionCollapse(broken)).toEqual(defaultSectionCollapse()); + expect(() => saveSectionCollapse(defaultSectionCollapse(), broken)).not.toThrow(); + }); +}); diff --git a/frontend/src/lib/editor-sections.ts b/frontend/src/lib/editor-sections.ts new file mode 100644 index 0000000..ffd552b --- /dev/null +++ b/frontend/src/lib/editor-sections.ts @@ -0,0 +1,92 @@ +/** + * Состояние сворачиваемых секций редактора фото. + * + * Все секции по умолчанию свёрнуты, а выбор пользователя хранится в браузере + * (localStorage), поэтому панель открывается в том же виде после перезагрузки + * и переключения фото. Серверные настройки для этого не нужны. + */ + +export const EDITOR_SECTIONS = [ + 'light', + 'curve', + 'color', + 'mixer', + 'pointColor', + 'correction', + 'effects' +] as const; + +export type EditorSection = (typeof EDITOR_SECTIONS)[number]; + +/** true — секция свёрнута. */ +export type SectionCollapse = Record; + +export const EDITOR_SECTIONS_KEY = 'galo.edit.sections'; + +/** По умолчанию все секции свёрнуты. */ +export function defaultSectionCollapse(): SectionCollapse { + return { + light: true, + curve: true, + color: true, + mixer: true, + pointColor: true, + correction: true, + effects: true + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Приводит сохранённое значение к полному набору секций: неизвестные ключи + * отбрасываются, небулевы значения и отсутствующие ключи получают дефолт. + */ +export function normalizeSectionCollapse(raw: unknown): SectionCollapse { + const defaults = defaultSectionCollapse(); + if (!isRecord(raw)) return defaults; + const out = defaults; + for (const section of EDITOR_SECTIONS) { + const value = raw[section]; + if (typeof value === 'boolean') out[section] = value; + } + return out; +} + +type ReadableStorage = Pick; +type WritableStorage = Pick; + +/** Читает состояние секций из браузера; без хранилища — дефолт. */ +export function loadSectionCollapse( + storage?: ReadableStorage | null +): SectionCollapse { + const target = storage === undefined ? browserStorage() : storage; + if (!target) return defaultSectionCollapse(); + try { + const raw = target.getItem(EDITOR_SECTIONS_KEY); + if (!raw) return defaultSectionCollapse(); + return normalizeSectionCollapse(JSON.parse(raw)); + } catch { + return defaultSectionCollapse(); + } +} + +/** Сохраняет состояние секций в браузере; ошибки хранилища игнорируются. */ +export function saveSectionCollapse( + state: SectionCollapse, + storage?: WritableStorage | null +): void { + const target = storage === undefined ? browserStorage() : storage; + if (!target) return; + try { + target.setItem(EDITOR_SECTIONS_KEY, JSON.stringify(state)); + } catch { + // Приватный режим или переполненное хранилище не должны ломать панель. + } +} + +function browserStorage(): Storage | null { + return typeof localStorage === 'undefined' ? null : localStorage; +} diff --git a/frontend/src/lib/webgl-preview.ts b/frontend/src/lib/webgl-preview.ts index ab952e3..4c8c70d 100644 --- a/frontend/src/lib/webgl-preview.ts +++ b/frontend/src/lib/webgl-preview.ts @@ -4,31 +4,37 @@ import { hslToRGB, MIXER_BANDS, type ColorParams, + type EffectsParams, type LightParams } from './edit'; /** * GPU-превью правок на WebGL2. * - * Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст, - * света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL, - * микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра, - * кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс. + * Проход 1 повторяет серверную математику света и цвета и рисует кадр в + * FBO-текстуру. Затем считаются размытия яркости (текстура — мелкие детали, + * чёткость — средние частоты), карта дымки готовится на CPU. Финальный проход + * применяет пространственные эффекты, виньетку и зерно и выводит кадр на экран. + * + * Превью приблизительное: источник истины — серверный рендер. */ const VERT = `#version 300 es -in vec2 a_pos; +layout(location = 0) in vec2 a_pos; uniform vec2 u_uvOffset; uniform vec2 u_uvScale; out vec2 v_uv; +out vec2 v_tex; void main() { vec2 uv = a_pos * 0.5 + 0.5; + v_tex = uv; v_uv = u_uvOffset + uv * u_uvScale; gl_Position = vec4(a_pos, 0.0, 1.0); }`; -const FRAG = `#version 300 es +const SCENE_FRAG = `#version 300 es precision highp float; +precision highp int; in vec2 v_uv; out vec4 outColor; @@ -196,6 +202,181 @@ void main() { outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a); }`; +const BLUR_FRAG = `#version 300 es +precision highp float; +precision highp int; + +in vec2 v_tex; +out vec4 outColor; + +uniform sampler2D u_src; +uniform vec2 u_step; +uniform int u_radius; + +const int MAX_RADIUS = 32; + +float luma(vec3 c) { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +void main() { + float sum = 0.0; + float count = 0.0; + int radius = u_radius; + if (radius > MAX_RADIUS) radius = MAX_RADIUS; + for (int i = 0; i <= MAX_RADIUS * 2; i++) { + int d = i - MAX_RADIUS; + if (d < -radius || d > radius) continue; + sum += luma(texture(u_src, v_tex + u_step * float(d)).rgb); + count += 1.0; + } + outColor = vec4(vec3(sum / max(count, 1.0)), 1.0); +}`; + +const FINAL_FRAG = `#version 300 es +precision highp float; +precision highp int; + +in vec2 v_uv; +in vec2 v_tex; +out vec4 outColor; + +uniform sampler2D u_scene; +uniform sampler2D u_blurTex; +uniform sampler2D u_blurClar; +uniform sampler2D u_dehaze; + +uniform float u_hasBlurTex; +uniform float u_hasBlurClar; +uniform float u_texture; +uniform float u_clarity; +uniform float u_dehazeAmount; +uniform vec3 u_dehazeA; + +uniform vec2 u_imageSize; +uniform float u_grainCell; +uniform uint u_seedLo; +uniform uint u_seedHi; +uniform float u_grainAmount; +uniform float u_grainSize; +uniform float u_grainRoughness; + +uniform float u_vigAmount; +uniform float u_vigMidpoint; +uniform float u_vigFeather; +uniform float u_vigRoundness; +uniform float u_vigHighlights; +uniform int u_vigMode; +uniform vec3 u_vigColor; + +float luma(vec3 c) { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +uint mix32(uint x) { + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; +} + +float hashNoise(uint lo, uint hi, float x, float y) { + uint ux = uint(int(x)); + uint uy = uint(int(y)); + uint h = mix32(lo ^ mix32(hi ^ mix32(ux * 0x9e3779b1u ^ uy * 0x85ebca6bu))); + return float(h) / 4294967295.0 * 2.0 - 1.0; +} + +void main() { + vec4 scene = texture(u_scene, v_tex); + if (scene.a == 0.0) { + outColor = vec4(0.0); + return; + } + vec3 c = scene.rgb * 255.0; + float y = luma(c); + + // Текстура и чёткость: локальный контраст по яркости. + if (u_texture != 0.0 && u_hasBlurTex > 0.5) { + float b = texture(u_blurTex, v_tex).r * 255.0; + c += (y - b) * u_texture; + } + if (u_clarity != 0.0 && u_hasBlurClar > 0.5) { + float b = texture(u_blurClar, v_tex).r * 255.0; + float d = clamp(y - b, -40.0, 40.0); + float t = abs(2.0 * y / 255.0 - 1.0); + float w = 1.0 - 0.6 * t * t; + c += u_clarity * 1.25 * d * w; + } + + // Дымка: приближённо, по карте пропускания на уменьшенной копии. + if (u_dehazeAmount != 0.0) { + float transmission = max(texture(u_dehaze, v_uv).r, 0.1); + if (u_dehazeAmount > 0.0) { + vec3 cleaned = (c - u_dehazeA) / transmission + u_dehazeA; + c = mix(c, cleaned, u_dehazeAmount); + } else { + c = c * (1.0 - 0.75 * -u_dehazeAmount) + u_dehazeA * (0.75 * -u_dehazeAmount); + } + } + + c = clamp(c, 0.0, 255.0); + float vy = luma(c); + + // Виньетка. + if (u_vigAmount != 0.0) { + vec2 d = v_uv * 2.0 - 1.0; + float p = 2.0 * pow(6.0, u_vigRoundness / 100.0); + float radius = pow(pow(abs(d.x), p) + pow(abs(d.y), p), 1.0 / p); + float start = 0.15 + 0.85 * u_vigMidpoint / 100.0; + float feather = 0.02 + 0.48 * u_vigFeather / 100.0; + float mask = clamp((radius - start) / feather, 0.0, 1.0); + mask = mask * mask * (3.0 - 2.0 * mask); + float hl = (u_vigHighlights / 100.0) * smoothstep(0.5, 1.0, vy / 255.0); + float amount = u_vigAmount / 100.0; + if (u_vigMode == 0) { + float factor = 1.0 - amount * 0.85 * mask; + float protect = hl * mask; + factor += (1.0 - factor) * protect; + c *= factor; + } else { + float strength = abs(amount) * mask * (1.0 - hl); + vec3 tint = u_vigColor * 255.0; + if (u_vigMode == 2) { + c += (tint - c) * strength; + } else { + float tintY = luma(tint); + vec3 target = tint - tintY + vy; + c += (target - c) * strength; + } + } + } + + // Зерно: детерминированный шум в координатах изображения. + if (u_grainAmount != 0.0) { + vec2 pos = v_uv * u_imageSize; + vec2 cell = vec2(max(1.0, u_grainCell)); + vec2 f = pos / cell; + vec2 i = floor(f); + vec2 fr = f - i; + fr = fr * fr * (3.0 - 2.0 * fr); + float v00 = hashNoise(u_seedLo, u_seedHi, i.x, i.y); + float v10 = hashNoise(u_seedLo, u_seedHi, i.x + 1.0, i.y); + float v01 = hashNoise(u_seedLo, u_seedHi, i.x, i.y + 1.0); + float v11 = hashNoise(u_seedLo, u_seedHi, i.x + 1.0, i.y + 1.0); + float value = mix(mix(v00, v10, fr.x), mix(v01, v11, fr.x), fr.y); + float white = hashNoise(u_seedLo, u_seedHi, pos.x, pos.y); + float roughness = u_grainRoughness / 100.0; + float noise = value * (1.0 - roughness) + white * roughness; + float weight = 1.0 - pow(2.0 * vy / 255.0 - 1.0, 2.0); + c += noise * (u_grainAmount / 100.0) * 38.0 * weight; + } + + outColor = vec4(clamp(c / 255.0, 0.0, 1.0), scene.a); +}`; + export interface PreviewRenderOptions { image: TexImageSource; imageVersion: string; @@ -206,6 +387,24 @@ export interface PreviewRenderOptions { dest: { x: number; y: number; width: number; height: number }; light: LightParams; color: ColorParams; + effects: EffectsParams; + seed: bigint; + lowQuality?: boolean; +} + +interface Target { + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; + width: number; + height: number; +} + +interface DehazeMap { + width: number; + height: number; + /** Карта пропускания, строки снизу вверх (как у FBO-текстур). */ + transmission: Uint8Array; + atmosphere: [number, number, number]; } function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null { @@ -221,6 +420,36 @@ function compile(gl: WebGL2RenderingContext, type: number, source: string): WebG return shader; } +interface Program { + program: WebGLProgram; + uniforms: Record; +} + +function buildProgram( + gl: WebGL2RenderingContext, + fragment: string, + uniformNames: string[] +): Program { + const vertex = compile(gl, gl.VERTEX_SHADER, VERT); + const frag = compile(gl, gl.FRAGMENT_SHADER, fragment); + if (!vertex || !frag) throw new Error('shader compile failed'); + const program = gl.createProgram(); + if (!program) throw new Error('program create failed'); + gl.attachShader(program, vertex); + gl.attachShader(program, frag); + gl.linkProgram(program); + gl.deleteShader(vertex); + gl.deleteShader(frag); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(`program link failed: ${log ?? ''}`); + } + const uniforms: Record = {}; + for (const name of uniformNames) uniforms[name] = gl.getUniformLocation(program, name); + return { program, uniforms }; +} + function buildLuts(light: LightParams): Uint8Array { const master = buildCurveLUT(light.curve?.rgb); const red = buildCurveLUT(light.curve?.red); @@ -236,37 +465,191 @@ function buildLuts(light: LightParams): Uint8Array { return out; } +function parseHex(color: string): [number, number, number] { + const m = /^#([0-9a-f]{6})$/.exec(color); + if (!m) return [0, 0, 0]; + const value = parseInt(m[1], 16); + return [((value >> 16) & 0xff) / 255, ((value >> 8) & 0xff) / 255, (value & 0xff) / 255]; +} + +/** box-размытие карты пропускания (маленькая картинка, поэтому простое). */ +function blurTransmission(map: Float32Array, width: number, height: number, radius: number): void { + if (radius <= 0) return; + const temp = new Float32Array(map.length); + const window = radius * 2 + 1; + for (let y = 0; y < height; y++) { + let sum = 0; + const row = y * width; + for (let x = -radius; x <= radius; x++) sum += map[row + Math.min(width - 1, Math.max(0, x))]; + for (let x = 0; x < width; x++) { + temp[row + x] = sum / window; + sum -= map[row + Math.min(width - 1, Math.max(0, x - radius))]; + sum += map[row + Math.min(width - 1, Math.max(0, x + radius + 1))]; + } + } + for (let x = 0; x < width; x++) { + let sum = 0; + for (let y = -radius; y <= radius; y++) sum += temp[Math.min(height - 1, Math.max(0, y)) * width + x]; + for (let y = 0; y < height; y++) { + map[y * width + x] = sum / window; + sum -= temp[Math.min(height - 1, Math.max(0, y - radius)) * width + x]; + sum += temp[Math.min(height - 1, Math.max(0, y + radius + 1)) * width + x]; + } + } +} + +/** + * Готовит карту дымки по уменьшенной копии изображения: атмосферный свет и + * пропускание (dark channel prior). Карта хранится в ориентации изображения + * снизу вверх, чтобы совпадать с текстурными координатами сцены. + */ +function buildDehazeMap( + image: CanvasImageSource, + imageWidth: number, + imageHeight: number +): DehazeMap | null { + const scale = Math.min(1, 256 / Math.max(1, imageWidth, imageHeight)); + const width = Math.max(1, Math.round(imageWidth * scale)); + const height = Math.max(1, Math.round(imageHeight * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + ctx.drawImage(image, 0, 0, width, height); + const src = ctx.getImageData(0, 0, width, height).data; + + const dark = new Uint8Array(width * height); + const histogram = new Uint32Array(256); + for (let i = 0, p = 0; i < dark.length; i++, p += 4) { + const d = Math.min(src[p], src[p + 1], src[p + 2]); + dark[i] = d; + histogram[d]++; + } + let needed = Math.max(1, Math.round(dark.length * 0.001)); + let threshold = 255; + for (let value = 255; value >= 0; value--) { + needed -= histogram[value]; + if (needed <= 0) { + threshold = value; + break; + } + } + let sumR = 0; + let sumG = 0; + let sumB = 0; + let count = 0; + for (let i = 0, p = 0; i < dark.length; i++, p += 4) { + if (dark[i] < threshold) continue; + sumR += src[p]; + sumG += src[p + 1]; + sumB += src[p + 2]; + count++; + } + const atmosphere: [number, number, number] = count + ? [sumR / count, sumG / count, sumB / count] + : [255, 255, 255]; + const a = atmosphere.map((v) => Math.max(1, v)) as [number, number, number]; + + const transmission = new Float32Array(width * height); + for (let i = 0, p = 0; i < dark.length; i++, p += 4) { + const t = + 1 - + 0.95 * + Math.min(src[p] / a[0], src[p + 1] / a[1], src[p + 2] / a[2]); + transmission[i] = Math.min(1, Math.max(0.1, t)); + } + blurTransmission(transmission, width, height, Math.max(1, Math.round(0.02 * Math.min(width, height)))); + + const bytes = new Uint8Array(width * height); + for (let y = 0; y < height; y++) { + const dstRow = (height - 1 - y) * width; + const srcRow = y * width; + for (let x = 0; x < width; x++) { + bytes[dstRow + x] = Math.round(transmission[srcRow + x] * 255); + } + } + return { width, height, transmission: bytes, atmosphere: a }; +} + export class WebGLPreview { private readonly gl: WebGL2RenderingContext; private readonly canvas: HTMLCanvasElement; - private readonly program: WebGLProgram; + private readonly scene: Program; + private readonly blur: Program; + private readonly final: Program; private readonly vao: WebGLVertexArrayObject; private readonly buffer: WebGLBuffer; private readonly imageTex: WebGLTexture; private readonly lutTex: WebGLTexture; - private readonly u: Record; + private readonly dehazeTex: WebGLTexture; private imageVersion = ''; + private dehazeVersion = ''; + private dehazeMap: DehazeMap | null = null; + private sceneTarget: Target | null = null; + private textureH: Target | null = null; + private textureV: Target | null = null; + private clarityH: Target | null = null; + private clarityV: Target | null = null; 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; + this.scene = buildProgram(gl, SCENE_FRAG, [ + 'u_image', + 'u_luts', + 'u_uvOffset', + 'u_uvScale', + 'u_exposureMul', + 'u_contrastK', + 'u_highlights', + 'u_shadows', + 'u_whites', + 'u_blacks', + 'u_temperature', + 'u_tint', + 'u_vibrance', + 'u_saturation', + 'u_blending', + 'u_balance', + 'u_correction', + 'u_mixerHue[0]', + 'u_mixerSat[0]', + 'u_mixerLum[0]', + 'u_gradeSat[0]', + 'u_gradeLum[0]', + 'u_gradeTint[0]' + ]); + this.blur = buildProgram(gl, BLUR_FRAG, ['u_src', 'u_step', 'u_radius']); + this.final = buildProgram(gl, FINAL_FRAG, [ + 'u_scene', + 'u_blurTex', + 'u_blurClar', + 'u_dehaze', + 'u_hasBlurTex', + 'u_hasBlurClar', + 'u_texture', + 'u_clarity', + 'u_dehazeAmount', + 'u_dehazeA', + 'u_imageSize', + 'u_grainCell', + 'u_seedLo', + 'u_seedHi', + 'u_grainAmount', + 'u_grainSize', + 'u_grainRoughness', + 'u_vigAmount', + 'u_vigMidpoint', + 'u_vigFeather', + 'u_vigRoundness', + 'u_vigHighlights', + 'u_vigMode', + 'u_vigColor', + 'u_uvOffset', + 'u_uvScale' + ]); const vao = gl.createVertexArray(); const buffer = gl.createBuffer(); @@ -278,55 +661,34 @@ export class WebGLPreview { 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.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 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); + const dehazeTex = gl.createTexture(); + if (!imageTex || !lutTex || !dehazeTex) throw new Error('texture create failed'); + for (const texture of [imageTex, lutTex, dehazeTex]) { + gl.bindTexture(gl.TEXTURE_2D, texture); + 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, + texture === lutTex ? gl.NEAREST : gl.LINEAR + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MAG_FILTER, + texture === lutTex ? gl.NEAREST : gl.LINEAR + ); + } 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]') - }; + this.dehazeTex = dehazeTex; } static create(canvas: HTMLCanvasElement): WebGLPreview | null { @@ -349,11 +711,120 @@ export class WebGLPreview { dispose() { const gl = this.gl; + for (const target of [ + this.sceneTarget, + this.textureH, + this.textureV, + this.clarityH, + this.clarityV + ]) { + if (!target) continue; + gl.deleteTexture(target.texture); + gl.deleteFramebuffer(target.framebuffer); + } gl.deleteTexture(this.imageTex); gl.deleteTexture(this.lutTex); + gl.deleteTexture(this.dehazeTex); gl.deleteBuffer(this.buffer); gl.deleteVertexArray(this.vao); - gl.deleteProgram(this.program); + gl.deleteProgram(this.scene.program); + gl.deleteProgram(this.blur.program); + gl.deleteProgram(this.final.program); + } + + private ensureTarget(target: Target | null, width: number, height: number): Target { + const gl = this.gl; + if (target && target.width === width && target.height === height) return target; + if (target) { + gl.deleteTexture(target.texture); + gl.deleteFramebuffer(target.framebuffer); + } + const texture = gl.createTexture(); + const framebuffer = gl.createFramebuffer(); + if (!texture || !framebuffer) throw new Error('target create failed'); + gl.bindTexture(gl.TEXTURE_2D, texture); + 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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + texture, + 0 + ); + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { + console.error('webgl preview: incomplete framebuffer'); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return { texture, framebuffer, width, height }; + } + + private drawQuad() { + const gl = this.gl; + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.bindVertexArray(null); + } + + private uploadImage(opts: PreviewRenderOptions) { + const gl = this.gl; + 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); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + this.imageVersion = opts.imageVersion; + } + + private ensureDehazeMap(opts: PreviewRenderOptions) { + const gl = this.gl; + if (this.dehazeVersion !== opts.imageVersion) { + this.dehazeMap = buildDehazeMap( + opts.image as CanvasImageSource, + opts.imageWidth, + opts.imageHeight + ); + this.dehazeVersion = opts.imageVersion; + if (this.dehazeMap) { + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.dehazeTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R8, + this.dehazeMap.width, + this.dehazeMap.height, + 0, + gl.RED, + gl.UNSIGNED_BYTE, + this.dehazeMap.transmission + ); + } + } + return this.dehazeMap; + } + + private blurPass( + source: WebGLTexture, + target: Target, + stepX: number, + stepY: number, + radius: number + ) { + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer); + gl.viewport(0, 0, target.width, target.height); + gl.useProgram(this.blur.program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, source); + gl.uniform1i(this.blur.uniforms.u_src, 0); + gl.uniform2f(this.blur.uniforms.u_step, stepX, stepY); + gl.uniform1i(this.blur.uniforms.u_radius, Math.min(32, Math.max(1, radius))); + this.drawQuad(); } render(opts: PreviewRenderOptions): boolean { @@ -370,19 +841,22 @@ export class WebGLPreview { 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; - } + if (this.imageVersion !== opts.imageVersion) this.uploadImage(opts); + + const effects = opts.effects; + const sceneWidth = Math.max(1, Math.round(opts.dest.width)); + const sceneHeight = Math.max(1, Math.round(opts.dest.height)); + this.sceneTarget = this.ensureTarget(this.sceneTarget, sceneWidth, sceneHeight); + const sceneTarget = this.sceneTarget; + + // Проход 1: свет и цвет в FBO-текстуру. + gl.bindFramebuffer(gl.FRAMEBUFFER, sceneTarget.framebuffer); + gl.viewport(0, 0, sceneWidth, sceneHeight); + gl.useProgram(this.scene.program); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.imageTex); - gl.uniform1i(this.u.u_image, 0); - + gl.uniform1i(this.scene.uniforms.u_image, 0); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.lutTex); gl.texImage2D( @@ -396,25 +870,25 @@ export class WebGLPreview { gl.UNSIGNED_BYTE, buildLuts(opts.light) ); - gl.uniform1i(this.u.u_luts, 1); + gl.uniform1i(this.scene.uniforms.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); + gl.uniform1f(this.scene.uniforms.u_exposureMul, Math.pow(2, light.exposure)); + gl.uniform1f(this.scene.uniforms.u_contrastK, 1 + light.contrast / 100); + gl.uniform1f(this.scene.uniforms.u_highlights, (light.highlights / 100) * 80); + gl.uniform1f(this.scene.uniforms.u_shadows, (light.shadows / 100) * 80); + gl.uniform1f(this.scene.uniforms.u_whites, (light.whites / 100) * 80); + gl.uniform1f(this.scene.uniforms.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.uniform1f(this.scene.uniforms.u_temperature, color.temperature / 100); + gl.uniform1f(this.scene.uniforms.u_tint, color.tint / 100); + gl.uniform1f(this.scene.uniforms.u_vibrance, color.vibrance / 100); + gl.uniform1f(this.scene.uniforms.u_saturation, color.saturation / 100); + gl.uniform1f(this.scene.uniforms.u_blending, color.grading.blending / 100); + gl.uniform1f(this.scene.uniforms.u_balance, color.grading.balance / 100); gl.uniform3f( - this.u.u_correction, + this.scene.uniforms.u_correction, color.correction.red / 100, color.correction.green / 100, color.correction.blue / 100 @@ -429,9 +903,9 @@ export class WebGLPreview { 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); + gl.uniform1fv(this.scene.uniforms['u_mixerHue[0]'], hue); + gl.uniform1fv(this.scene.uniforms['u_mixerSat[0]'], sat); + gl.uniform1fv(this.scene.uniforms['u_mixerLum[0]'], lum); const gradeSat = new Float32Array(3); const gradeLum = new Float32Array(3); @@ -445,26 +919,124 @@ export class WebGLPreview { 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); + gl.uniform1fv(this.scene.uniforms['u_gradeSat[0]'], gradeSat); + gl.uniform1fv(this.scene.uniforms['u_gradeLum[0]'], gradeLum); + gl.uniform3fv(this.scene.uniforms['u_gradeTint[0]'], 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); + const uvOffsetX = u0; + const uvOffsetY = 1 - vBottom; + const uvScaleX = u1 - u0; + const uvScaleY = vBottom - vTop; + gl.uniform2f(this.scene.uniforms.u_uvOffset, uvOffsetX, uvOffsetY); + gl.uniform2f(this.scene.uniforms.u_uvScale, uvScaleX, uvScaleY); + this.drawQuad(); + // Проходы размытия: текстура — полное (или половинное) разрешение, + // чёткость — половинное (или четвертное). + const scale = opts.dest.width / Math.max(1, opts.crop.width); + const minCrop = Math.min(opts.crop.width, opts.crop.height); + const textureRadius = Math.max(1, Math.round(0.0015 * minCrop * scale)); + const clarityRadius = Math.max(2, Math.round(0.008 * minCrop * scale)); + const texScale = opts.lowQuality ? 0.5 : 1; + const clarScale = opts.lowQuality ? 0.25 : 0.5; + let hasTexture = false; + let hasClarity = false; + + if (effects.texture !== 0) { + const targetW = Math.max(1, Math.round(sceneWidth * texScale)); + const targetH = Math.max(1, Math.round(sceneHeight * texScale)); + this.textureH = this.ensureTarget(this.textureH, targetW, targetH); + this.textureV = this.ensureTarget(this.textureV, targetW, targetH); + const radius = Math.max(1, Math.round(textureRadius * texScale)); + this.blurPass(sceneTarget.texture, this.textureH, 1 / targetW, 0, radius); + this.blurPass(this.textureH.texture, this.textureV, 0, 1 / targetH, radius); + hasTexture = true; + } + if (effects.clarity !== 0) { + const targetW = Math.max(1, Math.round(sceneWidth * clarScale)); + const targetH = Math.max(1, Math.round(sceneHeight * clarScale)); + this.clarityH = this.ensureTarget(this.clarityH, targetW, targetH); + this.clarityV = this.ensureTarget(this.clarityV, targetW, targetH); + const radius = Math.max(1, Math.round(clarityRadius * clarScale)); + this.blurPass(sceneTarget.texture, this.clarityH, 1 / targetW, 0, radius); + this.blurPass(this.clarityH.texture, this.clarityV, 0, 1 / targetH, radius); + hasClarity = true; + } + + const dehazeMap = effects.dehaze !== 0 ? this.ensureDehazeMap(opts) : null; + + // Финальный проход: эффекты, виньетка, зерно. + gl.bindFramebuffer(gl.FRAMEBUFFER, null); 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); + gl.useProgram(this.final.program); + const u = this.final.uniforms; + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, sceneTarget.texture); + gl.uniform1i(u.u_scene, 0); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, hasTexture ? this.textureV!.texture : sceneTarget.texture); + gl.uniform1i(u.u_blurTex, 1); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, hasClarity ? this.clarityV!.texture : sceneTarget.texture); + gl.uniform1i(u.u_blurClar, 2); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.dehazeTex); + gl.uniform1i(u.u_dehaze, 3); + + gl.uniform1f(u.u_hasBlurTex, hasTexture ? 1 : 0); + gl.uniform1f(u.u_hasBlurClar, hasClarity ? 1 : 0); + gl.uniform1f(u.u_texture, effects.texture / 100); + gl.uniform1f(u.u_clarity, effects.clarity / 100); + gl.uniform1f(u.u_dehazeAmount, dehazeMap ? effects.dehaze / 100 : 0); + if (dehazeMap) { + gl.uniform3f( + u.u_dehazeA, + dehazeMap.atmosphere[0], + dehazeMap.atmosphere[1], + dehazeMap.atmosphere[2] + ); + } else { + gl.uniform3f(u.u_dehazeA, 255, 255, 255); + } + + gl.uniform2f(u.u_imageSize, opts.imageWidth, opts.imageHeight); + const cell = Math.max( + 1, + (0.0005 + 0.004 * (effects.grain.size / 100)) * + Math.min(opts.imageWidth, opts.imageHeight) + ); + gl.uniform1f(u.u_grainCell, cell); + const seed = opts.seed; + gl.uniform1ui(u.u_seedLo, Number(seed & 0xffffffffn)); + gl.uniform1ui(u.u_seedHi, Number((seed >> 32n) & 0xffffffffn)); + gl.uniform1f(u.u_grainAmount, effects.grain.amount); + gl.uniform1f(u.u_grainSize, effects.grain.size); + gl.uniform1f(u.u_grainRoughness, effects.grain.roughness); + + const vignette = effects.vignette; + gl.uniform1f(u.u_vigAmount, vignette.amount); + gl.uniform1f(u.u_vigMidpoint, vignette.midpoint); + gl.uniform1f(u.u_vigFeather, vignette.feather); + gl.uniform1f(u.u_vigRoundness, vignette.roundness); + gl.uniform1f(u.u_vigHighlights, vignette.highlights); + gl.uniform1i( + u.u_vigMode, + vignette.mode === 'light' ? 0 : vignette.mode === 'color' ? 1 : 2 + ); + gl.uniform3f(u.u_vigColor, ...parseHex(vignette.color)); + gl.uniform2f(u.u_uvOffset, uvOffsetX, uvOffsetY); + gl.uniform2f(u.u_uvScale, uvScaleX, uvScaleY); + this.drawQuad(); return true; } }