feat(photos): добавить эффекты и настройки панели редактора

- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
iu
2026-10-24 22:57:30 +03:00
parent 4cc5c0f340
commit c953008a86
18 changed files with 3548 additions and 173 deletions
@@ -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)
}
}
+142
View File
@@ -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
}
+232 -9
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"math" "math"
"sort" "sort"
"strings"
) )
// Edit parameters are stored in the photo metadata and describe a // 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 // ranges mirror the ones Lightroom exposes: exposure in stops, everything else
// in percent. // in percent.
type LightParams struct { type LightParams struct {
Exposure float64 `json:"exposure"` Exposure float64 `json:"exposure"`
Contrast float64 `json:"contrast"` Contrast float64 `json:"contrast"`
Highlights float64 `json:"highlights"` Highlights float64 `json:"highlights"`
Shadows float64 `json:"shadows"` Shadows float64 `json:"shadows"`
Whites float64 `json:"whites"` Whites float64 `json:"whites"`
Blacks float64 `json:"blacks"` Blacks float64 `json:"blacks"`
Curve CurveParams `json:"curve"` Curve CurveParams `json:"curve"`
} }
@@ -95,6 +96,140 @@ type ColorCorrection struct {
Blue float64 `json:"blue"` 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. // mixerBand is a color band with its hue center in degrees.
type mixerBand struct { type mixerBand struct {
center float64 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. // EditParams is the full set of non-destructive editing parameters of a photo.
type EditParams struct { type EditParams struct {
Light LightParams `json:"light"` Light LightParams `json:"light"`
Color ColorParams `json:"color"` Color ColorParams `json:"color"`
Effects EffectsParams `json:"effects"`
} }
// Limits of the editing parameters. They are enforced on write so a hand-made // Limits of the editing parameters. They are enforced on write so a hand-made
@@ -167,6 +303,7 @@ func (p EditParams) Normalized() EditParams {
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green) p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue) p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
p.Color = p.Color.Normalized() p.Color = p.Color.Normalized()
p.Effects = p.Effects.Normalized()
return p return p
} }
@@ -214,6 +351,92 @@ func (c ColorParams) Normalized() ColorParams {
return c 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. // IsEmpty reports whether the parameters describe an untouched photo.
func (p EditParams) IsEmpty() bool { func (p EditParams) IsEmpty() bool {
l := p.Light l := p.Light
@@ -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. // IsEmpty reports whether the color parameters leave the image untouched.
@@ -1,7 +1,9 @@
package photos package photos
import ( import (
"bytes"
"image" "image"
"image/png"
"testing" "testing"
"time" "time"
) )
@@ -29,6 +31,73 @@ func BenchmarkApplyLight(b *testing.B) {
b.Logf("кадров: %d", b.N) 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) { func TestRenderSpeed(t *testing.T) {
w, h := 4000, 3000 w, h := 4000, 3000
src := image.NewNRGBA(image.Rect(0, 0, w, h)) src := image.NewNRGBA(image.Rect(0, 0, w, h))
+11 -6
View File
@@ -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 // adjustments to it, so the variants and the full-size version share one
// decode. Color is applied after light. // decode. The passes run in a fixed order: light, color, then the effects
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) { // (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)) img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil { if err != nil {
return nil, err return nil, err
} }
edited := applyLight(img, p.Light) edited := applyLight(img, p.Light)
if p.Color.IsEmpty() { if !p.Color.IsEmpty() {
return edited, nil 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 // applyColor applies the color adjustments in place to an image that already
+1 -1
View File
@@ -208,7 +208,7 @@ func TestBuildEditedVariants(t *testing.T) {
src.Pix[i+3] = 255 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 { if err != nil {
t.Fatalf("decode and adjust: %v", err) t.Fatalf("decode and adjust: %v", err)
} }
+659
View File
@@ -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)
}
}
})
}
+708
View File
@@ -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]
}
+4 -3
View File
@@ -123,14 +123,15 @@ func (h *Handler) Download(c *gin.Context) {
// the photo metadata and the edited version is rendered in the background. // the photo metadata and the edited version is rendered in the background.
func (h *Handler) Edit(c *gin.Context) { func (h *Handler) Edit(c *gin.Context) {
var req struct { var req struct {
Light LightParams `json:"light"` Light LightParams `json:"light"`
Color ColorParams `json:"color"` Color ColorParams `json:"color"`
Effects EffectsParams `json:"effects"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required")) httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
return return
} }
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, 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 { if err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
return return
+13 -2
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"hash/fnv"
"io" "io"
"log/slog" "log/slog"
"os" "os"
@@ -202,7 +203,7 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
w.failEdit(ctx, photoID, "fetch original: "+err.Error()) w.failEdit(ctx, photoID, "fetch original: "+err.Error())
return return
} }
edited, err := decodeAndAdjust(data, params) edited, err := decodeAndAdjust(data, params, editSeed(photoID))
if err != nil { if err != nil {
w.failEdit(ctx, photoID, "decode original: "+err.Error()) w.failEdit(ctx, photoID, "decode original: "+err.Error())
return return
@@ -211,7 +212,8 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
if err != nil { if err != nil {
w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) w.failEdit(ctx, photoID, "build edited variants: "+err.Error())
return 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. // with the copies of the same original.
owner := []string{photoID} owner := []string{photoID}
if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil { 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) 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) { func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {
slog.Warn("edit job failed", "photo", photoID, "error", msg) slog.Warn("edit job failed", "photo", photoID, "error", msg)
_ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts) _ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts)
+206 -31
View File
@@ -8,30 +8,44 @@
import { edit } from '$lib/stores/edit.svelte'; import { edit } from '$lib/stores/edit.svelte';
import { eyedropper } from '$lib/stores/eyedropper.svelte'; import { eyedropper } from '$lib/stores/eyedropper.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import {
loadSectionCollapse,
saveSectionCollapse,
type SectionCollapse
} from '$lib/editor-sections';
import { applyColor, applyLight } from '$lib/light-wasm'; import { applyColor, applyLight } from '$lib/light-wasm';
import { import {
CHANNEL_COLORS, CHANNEL_COLORS,
CHANNEL_LABELS, CHANNEL_LABELS,
COLOR_SLIDERS, COLOR_SLIDERS,
CURVE_CHANNELS, CURVE_CHANNELS,
EFFECT_SLIDERS,
GRAIN_SLIDERS,
GRADING_WHEELS, GRADING_WHEELS,
LIGHT_SLIDERS, LIGHT_SLIDERS,
MAX_CURVE_POINTS, MAX_CURVE_POINTS,
MIXER_BANDS, MIXER_BANDS,
VIGNETTE_MODES,
VIGNETTE_SLIDERS,
curveSvgPath, curveSvgPath,
effectiveCurvePoints, effectiveCurvePoints,
emptyEditParams, emptyEditParams,
isEmptyEditParams, isEmptyEditParams,
normalizeEditParams, normalizeEditParams,
normalizeVignetteColor,
readEditParams, readEditParams,
whiteBalanceFromRGB, whiteBalanceFromRGB,
type ColorSliderKey, type ColorSliderKey,
type CurveChannel, type CurveChannel,
type CurvePoint, type CurvePoint,
type EditParams, type EditParams,
type EffectSliderKey,
type GrainSliderKey,
type GradingWheelKey, type GradingWheelKey,
type MixerBand, type MixerBand,
type MixerBandKey type MixerBandKey,
type VignetteMode,
type VignetteSliderKey
} from '$lib/edit'; } from '$lib/edit';
import { import {
findActiveCurvePreset, findActiveCurvePreset,
@@ -57,15 +71,15 @@
// перетирать более свежие правки (например, после переключения фото). // перетирать более свежие правки (например, после переключения фото).
let saveSequence = 0; let saveSequence = 0;
const latestSaveByPhoto = new Map<string, number>(); const latestSaveByPhoto = new Map<string, number>();
let collapsed = $state(false); let collapsed = $state<SectionCollapse>(loadSectionCollapse());
let curveCollapsed = $state(false); // Состояние секций хранится в браузере: панель открывается в том же виде
// после перезагрузки и переключения фото.
$effect(() => {
saveSectionCollapse(collapsed);
});
let channel = $state<CurveChannel>('rgb'); let channel = $state<CurveChannel>('rgb');
let colorCollapsed = $state(false);
let mixerCollapsed = $state(false);
let mixerTab = $state<keyof MixerBand>('hue'); let mixerTab = $state<keyof MixerBand>('hue');
let mixerBand = $state<MixerBandKey>('red'); let mixerBand = $state<MixerBandKey>('red');
let pointColorCollapsed = $state(false);
let correctionCollapsed = $state(false);
let correctionView = $state<'all' | 'midtones' | 'split' | 'highlights'>('all'); let correctionView = $state<'all' | 'midtones' | 'split' | 'highlights'>('all');
let wbPreset = $state('as-shot'); let wbPreset = $state('as-shot');
@@ -337,6 +351,57 @@
scheduleSave(); 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) { function onMixerInput(band: MixerBandKey, component: keyof MixerBand, value: number) {
const current = params.color.mixer[band]; const current = params.color.mixer[band];
params = { params = {
@@ -654,12 +719,12 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold" class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!collapsed} aria-expanded={!collapsed.light}
onclick={() => (collapsed = !collapsed)} onclick={() => (collapsed.light = !collapsed.light)}
> >
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {collapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.light ? '-rotate-90' : ''}"
/> />
Свет Свет
</button> </button>
@@ -675,7 +740,7 @@
</button> </button>
</div> </div>
{#if !collapsed} {#if !collapsed.light}
<div class="mt-3 space-y-4"> <div class="mt-3 space-y-4">
<div class="space-y-2.5"> <div class="space-y-2.5">
{#each LIGHT_SLIDERS as slider (slider.key)} {#each LIGHT_SLIDERS as slider (slider.key)}
@@ -698,9 +763,9 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold" class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!curveCollapsed} aria-expanded={!collapsed.curve}
aria-controls="light-curve" aria-controls="light-curve"
onclick={() => (curveCollapsed = !curveCollapsed)} onclick={() => (collapsed.curve = !collapsed.curve)}
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<Icon name="show_chart" class="size-4" /> <Icon name="show_chart" class="size-4" />
@@ -708,11 +773,11 @@
</span> </span>
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.curve ? '-rotate-90' : ''}"
/> />
</button> </button>
{#if !curveCollapsed} {#if !collapsed.curve}
<div id="light-curve" class="mt-2"> <div id="light-curve" class="mt-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#each CURVE_CHANNELS as ch (ch)} {#each CURVE_CHANNELS as ch (ch)}
@@ -842,13 +907,13 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold" class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!colorCollapsed} aria-expanded={!collapsed.color}
aria-controls="color-panel" aria-controls="color-panel"
onclick={() => (colorCollapsed = !colorCollapsed)} onclick={() => (collapsed.color = !collapsed.color)}
> >
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {colorCollapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.color ? '-rotate-90' : ''}"
/> />
Цвет Цвет
</button> </button>
@@ -857,7 +922,7 @@
</span> </span>
</div> </div>
{#if !colorCollapsed} {#if !collapsed.color}
<div id="color-panel" class="mt-3 space-y-4"> <div id="color-panel" class="mt-3 space-y-4">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -918,9 +983,9 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold" class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!mixerCollapsed} aria-expanded={!collapsed.mixer}
aria-controls="color-mixer" aria-controls="color-mixer"
onclick={() => (mixerCollapsed = !mixerCollapsed)} onclick={() => (collapsed.mixer = !collapsed.mixer)}
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<Icon name="tune" class="size-4" /> <Icon name="tune" class="size-4" />
@@ -928,10 +993,10 @@
</span> </span>
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {mixerCollapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.mixer ? '-rotate-90' : ''}"
/> />
</button> </button>
{#if !mixerCollapsed} {#if !collapsed.mixer}
<div id="color-mixer" class="mt-2"> <div id="color-mixer" class="mt-2">
<div class="flex items-center justify-between gap-1"> <div class="flex items-center justify-between gap-1">
{#each MIXER_BANDS as band (band.key)} {#each MIXER_BANDS as band (band.key)}
@@ -1015,9 +1080,9 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold" class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!pointColorCollapsed} aria-expanded={!collapsed.pointColor}
aria-controls="color-point" aria-controls="color-point"
onclick={() => (pointColorCollapsed = !pointColorCollapsed)} onclick={() => (collapsed.pointColor = !collapsed.pointColor)}
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<Icon name="colorize" class="size-4" /> <Icon name="colorize" class="size-4" />
@@ -1025,10 +1090,10 @@
</span> </span>
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {pointColorCollapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.pointColor ? '-rotate-90' : ''}"
/> />
</button> </button>
{#if !pointColorCollapsed} {#if !collapsed.pointColor}
<div id="color-point" class="text-text-muted mt-2 flex items-start gap-2 text-xs"> <div id="color-point" class="text-text-muted mt-2 flex items-start gap-2 text-xs">
<Icon name="colorize" class="mt-0.5 size-4 shrink-0" /> <Icon name="colorize" class="mt-0.5 size-4 shrink-0" />
<span>Выберите цвет на фото пипеткой.</span> <span>Выберите цвет на фото пипеткой.</span>
@@ -1040,9 +1105,9 @@
<button <button
type="button" type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold" class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!correctionCollapsed} aria-expanded={!collapsed.correction}
aria-controls="color-correction" aria-controls="color-correction"
onclick={() => (correctionCollapsed = !correctionCollapsed)} onclick={() => (collapsed.correction = !collapsed.correction)}
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<Icon name="blur_on" class="size-4" /> <Icon name="blur_on" class="size-4" />
@@ -1050,10 +1115,10 @@
</span> </span>
<Icon <Icon
name="expand_more" name="expand_more"
class="size-4 transition-transform {correctionCollapsed ? '-rotate-90' : ''}" class="size-4 transition-transform {collapsed.correction ? '-rotate-90' : ''}"
/> />
</button> </button>
{#if !correctionCollapsed} {#if !collapsed.correction}
<div id="color-correction" class="mt-2 space-y-4"> <div id="color-correction" class="mt-2 space-y-4">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#each CORRECTION_VIEWS as view (view.key)} {#each CORRECTION_VIEWS as view (view.key)}
@@ -1169,6 +1234,116 @@
{/if} {/if}
</div> </div>
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!collapsed.effects}
aria-controls="effects-panel"
onclick={() => (collapsed.effects = !collapsed.effects)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {collapsed.effects ? '-rotate-90' : ''}"
/>
Эффекты
</button>
<span class="text-text-muted" title="Показать эффекты">
<Icon name="visibility" class="size-4" />
</span>
</div>
{#if !collapsed.effects}
<div id="effects-panel" class="mt-3 space-y-4">
<div class="space-y-2.5">
{#each EFFECT_SLIDERS as slider (slider.key)}
<EditSlider
id="effect-{slider.key}"
label={slider.label}
value={params.effects[slider.key]}
min={slider.min}
max={slider.max}
step={slider.step}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onEffectInput(slider.key, value)}
onchange={onEffectsCommit}
/>
{/each}
</div>
<div class="border-border space-y-2.5 border-t pt-3">
<div class="flex items-center gap-1" role="group" aria-label="Режим виньетки">
{#each VIGNETTE_MODES as mode (mode.key)}
<button
type="button"
class="rounded-md px-2 py-1 text-xs disabled:opacity-40 {params.effects.vignette
.mode === mode.key
? 'bg-bg-muted text-text'
: 'text-text-muted hover:text-text'}"
aria-pressed={params.effects.vignette.mode === mode.key}
disabled={!canEdit}
onclick={() => onVignetteMode(mode.key)}
>
{mode.label}
</button>
{/each}
</div>
{#each VIGNETTE_SLIDERS as slider (slider.key)}
<EditSlider
id="vignette-{slider.key}"
label={slider.label}
value={params.effects.vignette[slider.key]}
min={slider.min}
max={slider.max}
step={slider.step}
resetValue={slider.key === 'midpoint' || slider.key === 'feather' ? 50 : 0}
disabled={!canEdit}
format={slider.min < 0 ? formatValue : undefined}
oninput={(value) => onVignetteInput(slider.key, value)}
onchange={onEffectsCommit}
/>
{/each}
{#if params.effects.vignette.mode !== 'light'}
<label class="flex items-center justify-between gap-2 text-xs">
<span class="text-text">Цвет виньетки</span>
<input
type="color"
class="border-border bg-bg h-6 w-10 cursor-pointer rounded border disabled:opacity-40"
value={params.effects.vignette.color}
title="Цвет виньетки"
disabled={!canEdit}
oninput={(e) => onVignetteColor(e.currentTarget.value)}
onchange={onEffectsCommit}
/>
</label>
{/if}
</div>
<div class="border-border space-y-2.5 border-t pt-3">
<div class="text-text text-xs font-semibold">Зерно</div>
{#each GRAIN_SLIDERS as slider (slider.key)}
<EditSlider
id="grain-{slider.key}"
label={slider.label}
value={params.effects.grain[slider.key]}
min={slider.min}
max={slider.max}
step={slider.step}
resetValue={slider.key === 'size' ? 25 : slider.key === 'roughness' ? 50 : 0}
disabled={!canEdit}
oninput={(value) => onGrainInput(slider.key, value)}
onchange={onEffectsCommit}
/>
{/each}
</div>
</div>
{/if}
</div>
{#if busy} {#if busy}
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p> <p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
{:else if dirty && !busy} {:else if dirty && !busy}
+19 -13
View File
@@ -42,6 +42,10 @@
const canEditPhoto = $derived( const canEditPhoto = $derived(
canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer') canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer')
); );
// Редактирование доступно только из просмотрщика: в режиме сетки вкладка
// «Редактирование» скрыта, панель показывает «Сведения». Сохранённая
// вкладка при этом не теряется — она снова станет активной в просмотрщике.
const activeTab = $derived(viewer ? tab : 'info');
let editingName = $state(false); let editingName = $state(false);
let nameDraft = $state(''); let nameDraft = $state('');
@@ -242,26 +246,28 @@ const tabClass =
role="tab" role="tab"
id="photo-tab-info" id="photo-tab-info"
aria-controls="photo-panel-info" aria-controls="photo-panel-info"
aria-selected={tab === 'info'} aria-selected={activeTab === 'info'}
class={tabClass} class={tabClass}
onclick={() => (tab = 'info')} onclick={() => (tab = 'info')}
> >
Сведения Сведения
</button> </button>
<button {#if viewer}
type="button" <button
role="tab" type="button"
id="photo-tab-edit" role="tab"
aria-controls="photo-panel-edit" id="photo-tab-edit"
aria-selected={tab === 'edit'} aria-controls="photo-panel-edit"
class={tabClass} aria-selected={activeTab === 'edit'}
onclick={() => (tab = 'edit')} class={tabClass}
> onclick={() => (tab = 'edit')}
Редактирование >
</button> Редактирование
</button>
{/if}
</div> </div>
{#if tab === 'edit'} {#if activeTab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit"> <div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel <PhotoEditPanel
{photo} {photo}
+15 -1
View File
@@ -14,6 +14,8 @@
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm'; import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
import { WebGLPreview } from '$lib/webgl-preview'; import { WebGLPreview } from '$lib/webgl-preview';
import { import {
applyEffectsToPixels,
effectsSeed,
isEmptyColor, isEmptyColor,
isEmptyEditParams, isEmptyEditParams,
isEmptyLight, isEmptyLight,
@@ -296,7 +298,10 @@
viewport: { width, height }, viewport: { width, height },
dest, dest,
light: liveParams.light, light: liveParams.light,
color: liveParams.color color: liveParams.color,
effects: liveParams.effects,
seed: effectsSeed(current?.id ?? ''),
lowQuality: previewLow
}); });
if (ok) return; if (ok) return;
useGL = false; useGL = false;
@@ -325,6 +330,14 @@
const frame = ctx.getImageData(0, 0, bw, bh); const frame = ctx.getImageData(0, 0, bw, bh);
if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light); if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light);
if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color); if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color);
// CPU-фолбэк считает только попиксельные эффекты: виньетку и зерно.
applyEffectsToPixels(
frame.data,
liveParams.effects,
bw,
bh,
effectsSeed(current?.id ?? '')
);
ctx.putImageData(frame, 0, 0); ctx.putImageData(frame, 0, 0);
} }
@@ -343,6 +356,7 @@
scale; scale;
liveParams.light; liveParams.light;
liveParams.color; liveParams.color;
liveParams.effects;
displaySrc; displaySrc;
previewLow; previewLow;
useGL; useGL;
+187
View File
@@ -1,14 +1,20 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
applyColorToPixels, applyColorToPixels,
applyGrainToPixels,
applyVignetteToPixels,
buildCurveLUT, buildCurveLUT,
curveSvgPath, curveSvgPath,
effectiveCurvePoints, effectiveCurvePoints,
effectsSeed,
emptyColor, emptyColor,
emptyEditParams, emptyEditParams,
emptyEffects,
isEmptyColor, isEmptyColor,
isEmptyEditParams, isEmptyEditParams,
isEmptyEffects,
normalizeEditParams, normalizeEditParams,
normalizeVignette,
readEditParams, readEditParams,
whiteBalanceFromRGB whiteBalanceFromRGB
} from './edit'; } from './edit';
@@ -186,6 +192,187 @@ describe('цветовые параметры', () => {
}); });
}); });
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('кривая', () => { describe('кривая', () => {
const medium = [ const medium = [
{ x: 0, y: 0 }, { x: 0, y: 0 },
+340 -6
View File
@@ -77,9 +77,49 @@ export interface ColorParams {
correction: ColorCorrection; 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 { export interface EditParams {
light: LightParams; light: LightParams;
color: ColorParams; color: ColorParams;
effects: EffectsParams;
} }
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */ /** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
@@ -106,6 +146,44 @@ export const COLOR_SLIDERS = [
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key']; 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 = [ export const GRADING_WHEELS = [
{ key: 'shadows', label: 'Тени' }, { key: 'shadows', label: 'Тени' },
@@ -173,7 +251,7 @@ export function emptyLight(): LightParams {
} }
export function emptyEditParams(): EditParams { export function emptyEditParams(): EditParams {
return { light: emptyLight(), color: emptyColor() }; return { light: emptyLight(), color: emptyColor(), effects: emptyEffects() };
} }
function emptyMixerBand(): MixerBand { 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 { function clamp(v: number | undefined, min: number, max: number): number {
if (typeof v !== 'number' || !Number.isFinite(v)) return 0; if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
@@ -234,6 +331,7 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
export function normalizeEditParams(params: { export function normalizeEditParams(params: {
light?: Partial<LightParams>; light?: Partial<LightParams>;
color?: Partial<ColorParams>; color?: Partial<ColorParams>;
effects?: Partial<EffectsParams>;
}): EditParams { }): EditParams {
const light = params?.light ?? emptyLight(); const light = params?.light ?? emptyLight();
return { return {
@@ -251,7 +349,8 @@ export function normalizeEditParams(params: {
blue: normalizeCurve(light.curve?.blue) 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>): 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>): 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>): 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>): 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 { export function isEmptyLight(light: LightParams): boolean {
if ( if (
@@ -341,7 +487,21 @@ export function isColorFast(color: ColorParams): boolean {
/** Пустые ли параметры: фотография не тронута. */ /** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): 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 { export function readEditParams(photo: Photo | null | undefined): EditParams {
const raw = photo?.edit_params as const raw = photo?.edit_params as
| { light?: Partial<LightParams>; color?: Partial<ColorParams> } | { light?: Partial<LightParams>; color?: Partial<ColorParams>; effects?: Partial<EffectsParams> }
| undefined; | undefined;
if (!raw?.light && !raw?.color) return emptyEditParams(); if (!raw?.light && !raw?.color && !raw?.effects) return emptyEditParams();
return normalizeEditParams({ return normalizeEditParams({
light: { light: {
...emptyLight(), ...emptyLight(),
...raw?.light, ...raw?.light,
curve: raw?.light?.curve ?? {} curve: raw?.light?.curve ?? {}
}, },
color: raw?.color ?? emptyColor() 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)); 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);
}
+81
View File
@@ -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<string, string> = {}) {
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();
});
});
+92
View File
@@ -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<EditorSection, boolean>;
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<string, unknown> {
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<Storage, 'getItem'>;
type WritableStorage = Pick<Storage, 'setItem'>;
/** Читает состояние секций из браузера; без хранилища — дефолт. */
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;
}
+673 -101
View File
@@ -4,31 +4,37 @@ import {
hslToRGB, hslToRGB,
MIXER_BANDS, MIXER_BANDS,
type ColorParams, type ColorParams,
type EffectsParams,
type LightParams type LightParams
} from './edit'; } from './edit';
/** /**
* GPU-превью правок на WebGL2. * GPU-превью правок на WebGL2.
* *
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст, * Проход 1 повторяет серверную математику света и цвета и рисует кадр в
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL, * FBO-текстуру. Затем считаются размытия яркости (текстура — мелкие детали,
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра, * чёткость — средние частоты), карта дымки готовится на CPU. Финальный проход
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс. * применяет пространственные эффекты, виньетку и зерно и выводит кадр на экран.
*
* Превью приблизительное: источник истины — серверный рендер.
*/ */
const VERT = `#version 300 es const VERT = `#version 300 es
in vec2 a_pos; layout(location = 0) in vec2 a_pos;
uniform vec2 u_uvOffset; uniform vec2 u_uvOffset;
uniform vec2 u_uvScale; uniform vec2 u_uvScale;
out vec2 v_uv; out vec2 v_uv;
out vec2 v_tex;
void main() { void main() {
vec2 uv = a_pos * 0.5 + 0.5; vec2 uv = a_pos * 0.5 + 0.5;
v_tex = uv;
v_uv = u_uvOffset + uv * u_uvScale; v_uv = u_uvOffset + uv * u_uvScale;
gl_Position = vec4(a_pos, 0.0, 1.0); 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 float;
precision highp int;
in vec2 v_uv; in vec2 v_uv;
out vec4 outColor; out vec4 outColor;
@@ -196,6 +202,181 @@ void main() {
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a); 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 { export interface PreviewRenderOptions {
image: TexImageSource; image: TexImageSource;
imageVersion: string; imageVersion: string;
@@ -206,6 +387,24 @@ export interface PreviewRenderOptions {
dest: { x: number; y: number; width: number; height: number }; dest: { x: number; y: number; width: number; height: number };
light: LightParams; light: LightParams;
color: ColorParams; 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 { 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; return shader;
} }
interface Program {
program: WebGLProgram;
uniforms: Record<string, WebGLUniformLocation | null>;
}
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<string, WebGLUniformLocation | null> = {};
for (const name of uniformNames) uniforms[name] = gl.getUniformLocation(program, name);
return { program, uniforms };
}
function buildLuts(light: LightParams): Uint8Array { function buildLuts(light: LightParams): Uint8Array {
const master = buildCurveLUT(light.curve?.rgb); const master = buildCurveLUT(light.curve?.rgb);
const red = buildCurveLUT(light.curve?.red); const red = buildCurveLUT(light.curve?.red);
@@ -236,37 +465,191 @@ function buildLuts(light: LightParams): Uint8Array {
return out; 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 { export class WebGLPreview {
private readonly gl: WebGL2RenderingContext; private readonly gl: WebGL2RenderingContext;
private readonly canvas: HTMLCanvasElement; 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 vao: WebGLVertexArrayObject;
private readonly buffer: WebGLBuffer; private readonly buffer: WebGLBuffer;
private readonly imageTex: WebGLTexture; private readonly imageTex: WebGLTexture;
private readonly lutTex: WebGLTexture; private readonly lutTex: WebGLTexture;
private readonly u: Record<string, WebGLUniformLocation | null>; private readonly dehazeTex: WebGLTexture;
private imageVersion = ''; 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) { private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
this.canvas = canvas; this.canvas = canvas;
this.gl = gl; this.gl = gl;
const vs = compile(gl, gl.VERTEX_SHADER, VERT); this.scene = buildProgram(gl, SCENE_FRAG, [
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG); 'u_image',
if (!vs || !fs) throw new Error('shader compile failed'); 'u_luts',
const program = gl.createProgram(); 'u_uvOffset',
if (!program) throw new Error('program create failed'); 'u_uvScale',
gl.attachShader(program, vs); 'u_exposureMul',
gl.attachShader(program, fs); 'u_contrastK',
gl.linkProgram(program); 'u_highlights',
gl.deleteShader(vs); 'u_shadows',
gl.deleteShader(fs); 'u_whites',
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 'u_blacks',
const log = gl.getProgramInfoLog(program); 'u_temperature',
gl.deleteProgram(program); 'u_tint',
throw new Error(`program link failed: ${log ?? ''}`); 'u_vibrance',
} 'u_saturation',
this.program = program; '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 vao = gl.createVertexArray();
const buffer = gl.createBuffer(); const buffer = gl.createBuffer();
@@ -278,55 +661,34 @@ export class WebGLPreview {
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW gl.STATIC_DRAW
); );
const aPos = gl.getAttribLocation(program, 'a_pos'); gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null); gl.bindVertexArray(null);
this.vao = vao; this.vao = vao;
this.buffer = buffer; this.buffer = buffer;
const imageTex = gl.createTexture(); const imageTex = gl.createTexture();
const lutTex = gl.createTexture(); const lutTex = gl.createTexture();
if (!imageTex || !lutTex) throw new Error('texture create failed'); const dehazeTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, imageTex); if (!imageTex || !lutTex || !dehazeTex) throw new Error('texture create failed');
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); for (const texture of [imageTex, lutTex, dehazeTex]) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, lutTex); gl.texParameteri(
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.TEXTURE_2D,
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.TEXTURE_MIN_FILTER,
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); texture === lutTex ? gl.NEAREST : gl.LINEAR
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); );
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MAG_FILTER,
texture === lutTex ? gl.NEAREST : gl.LINEAR
);
}
this.imageTex = imageTex; this.imageTex = imageTex;
this.lutTex = lutTex; this.lutTex = lutTex;
this.dehazeTex = dehazeTex;
const loc = (name: string) => gl.getUniformLocation(program, name);
this.u = {
u_image: loc('u_image'),
u_luts: loc('u_luts'),
u_uvOffset: loc('u_uvOffset'),
u_uvScale: loc('u_uvScale'),
u_exposureMul: loc('u_exposureMul'),
u_contrastK: loc('u_contrastK'),
u_highlights: loc('u_highlights'),
u_shadows: loc('u_shadows'),
u_whites: loc('u_whites'),
u_blacks: loc('u_blacks'),
u_temperature: loc('u_temperature'),
u_tint: loc('u_tint'),
u_vibrance: loc('u_vibrance'),
u_saturation: loc('u_saturation'),
u_blending: loc('u_blending'),
u_balance: loc('u_balance'),
u_correction: loc('u_correction'),
u_mixerHue: loc('u_mixerHue[0]'),
u_mixerSat: loc('u_mixerSat[0]'),
u_mixerLum: loc('u_mixerLum[0]'),
u_gradeSat: loc('u_gradeSat[0]'),
u_gradeLum: loc('u_gradeLum[0]'),
u_gradeTint: loc('u_gradeTint[0]')
};
} }
static create(canvas: HTMLCanvasElement): WebGLPreview | null { static create(canvas: HTMLCanvasElement): WebGLPreview | null {
@@ -349,11 +711,120 @@ export class WebGLPreview {
dispose() { dispose() {
const gl = this.gl; 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.imageTex);
gl.deleteTexture(this.lutTex); gl.deleteTexture(this.lutTex);
gl.deleteTexture(this.dehazeTex);
gl.deleteBuffer(this.buffer); gl.deleteBuffer(this.buffer);
gl.deleteVertexArray(this.vao); 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 { render(opts: PreviewRenderOptions): boolean {
@@ -370,19 +841,22 @@ export class WebGLPreview {
gl.viewport(0, 0, width, height); gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.program);
if (this.imageVersion !== opts.imageVersion) { if (this.imageVersion !== opts.imageVersion) this.uploadImage(opts);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex); const effects = opts.effects;
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); const sceneWidth = Math.max(1, Math.round(opts.dest.width));
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, opts.image); const sceneHeight = Math.max(1, Math.round(opts.dest.height));
this.imageVersion = opts.imageVersion; 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.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex); 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.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.lutTex); gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
gl.texImage2D( gl.texImage2D(
@@ -396,25 +870,25 @@ export class WebGLPreview {
gl.UNSIGNED_BYTE, gl.UNSIGNED_BYTE,
buildLuts(opts.light) buildLuts(opts.light)
); );
gl.uniform1i(this.u.u_luts, 1); gl.uniform1i(this.scene.uniforms.u_luts, 1);
const light = opts.light; const light = opts.light;
gl.uniform1f(this.u.u_exposureMul, Math.pow(2, light.exposure)); gl.uniform1f(this.scene.uniforms.u_exposureMul, Math.pow(2, light.exposure));
gl.uniform1f(this.u.u_contrastK, 1 + light.contrast / 100); gl.uniform1f(this.scene.uniforms.u_contrastK, 1 + light.contrast / 100);
gl.uniform1f(this.u.u_highlights, (light.highlights / 100) * 80); gl.uniform1f(this.scene.uniforms.u_highlights, (light.highlights / 100) * 80);
gl.uniform1f(this.u.u_shadows, (light.shadows / 100) * 80); gl.uniform1f(this.scene.uniforms.u_shadows, (light.shadows / 100) * 80);
gl.uniform1f(this.u.u_whites, (light.whites / 100) * 80); gl.uniform1f(this.scene.uniforms.u_whites, (light.whites / 100) * 80);
gl.uniform1f(this.u.u_blacks, (light.blacks / 100) * 80); gl.uniform1f(this.scene.uniforms.u_blacks, (light.blacks / 100) * 80);
const color = opts.color; const color = opts.color;
gl.uniform1f(this.u.u_temperature, color.temperature / 100); gl.uniform1f(this.scene.uniforms.u_temperature, color.temperature / 100);
gl.uniform1f(this.u.u_tint, color.tint / 100); gl.uniform1f(this.scene.uniforms.u_tint, color.tint / 100);
gl.uniform1f(this.u.u_vibrance, color.vibrance / 100); gl.uniform1f(this.scene.uniforms.u_vibrance, color.vibrance / 100);
gl.uniform1f(this.u.u_saturation, color.saturation / 100); gl.uniform1f(this.scene.uniforms.u_saturation, color.saturation / 100);
gl.uniform1f(this.u.u_blending, color.grading.blending / 100); gl.uniform1f(this.scene.uniforms.u_blending, color.grading.blending / 100);
gl.uniform1f(this.u.u_balance, color.grading.balance / 100); gl.uniform1f(this.scene.uniforms.u_balance, color.grading.balance / 100);
gl.uniform3f( gl.uniform3f(
this.u.u_correction, this.scene.uniforms.u_correction,
color.correction.red / 100, color.correction.red / 100,
color.correction.green / 100, color.correction.green / 100,
color.correction.blue / 100 color.correction.blue / 100
@@ -429,9 +903,9 @@ export class WebGLPreview {
sat[i] = band.saturation / 100; sat[i] = band.saturation / 100;
lum[i] = band.luminance / 100; lum[i] = band.luminance / 100;
} }
gl.uniform1fv(this.u.u_mixerHue, hue); gl.uniform1fv(this.scene.uniforms['u_mixerHue[0]'], hue);
gl.uniform1fv(this.u.u_mixerSat, sat); gl.uniform1fv(this.scene.uniforms['u_mixerSat[0]'], sat);
gl.uniform1fv(this.u.u_mixerLum, lum); gl.uniform1fv(this.scene.uniforms['u_mixerLum[0]'], lum);
const gradeSat = new Float32Array(3); const gradeSat = new Float32Array(3);
const gradeLum = new Float32Array(3); const gradeLum = new Float32Array(3);
@@ -445,26 +919,124 @@ export class WebGLPreview {
gradeTint[i * 3 + 1] = g; gradeTint[i * 3 + 1] = g;
gradeTint[i * 3 + 2] = b; gradeTint[i * 3 + 2] = b;
} }
gl.uniform1fv(this.u.u_gradeSat, gradeSat); gl.uniform1fv(this.scene.uniforms['u_gradeSat[0]'], gradeSat);
gl.uniform1fv(this.u.u_gradeLum, gradeLum); gl.uniform1fv(this.scene.uniforms['u_gradeLum[0]'], gradeLum);
gl.uniform3fv(this.u.u_gradeTint, gradeTint); gl.uniform3fv(this.scene.uniforms['u_gradeTint[0]'], gradeTint);
const u0 = opts.crop.x / opts.imageWidth; const u0 = opts.crop.x / opts.imageWidth;
const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth; const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth;
const vTop = opts.crop.y / opts.imageHeight; const vTop = opts.crop.y / opts.imageHeight;
const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight; const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight;
gl.uniform2f(this.u.u_uvOffset, u0, 1 - vBottom); const uvOffsetX = u0;
gl.uniform2f(this.u.u_uvScale, u1 - u0, vBottom - vTop); 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( gl.viewport(
Math.round(opts.dest.x), Math.round(opts.dest.x),
Math.round(height - opts.dest.y - opts.dest.height), Math.round(height - opts.dest.y - opts.dest.height),
Math.max(1, Math.round(opts.dest.width)), Math.max(1, Math.round(opts.dest.width)),
Math.max(1, Math.round(opts.dest.height)) Math.max(1, Math.round(opts.dest.height))
); );
gl.bindVertexArray(this.vao); gl.useProgram(this.final.program);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); const u = this.final.uniforms;
gl.bindVertexArray(null);
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; return true;
} }
} }