feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user