feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// A color edit must survive the round trip: the parameters are stored in the
|
||||
// photo metadata, the edited version is rendered, and a reset returns the
|
||||
// photo to its original look.
|
||||
func TestColorEditRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "color@test.local", "", "password123", "C")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Color"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||
AlbumID: album.ID, UserID: user.ID, Filename: "c.png", Data: makePNG(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||
w.ProcessOnce(ctx)
|
||||
|
||||
color := photos.ColorParams{Temperature: 25, Vibrance: 20}
|
||||
color.Mixer.Red.Saturation = 30
|
||||
color.Grading.Shadows = photos.ColorWheel{Hue: 220, Saturation: 40}
|
||||
edited, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 0.3},
|
||||
Color: color,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
if edited.EditParams == nil {
|
||||
t.Fatal("edit returned no parameters")
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.EditPreviewURL == "" {
|
||||
t.Fatalf("no edited preview url: %+v", got)
|
||||
}
|
||||
if _, ok := got.EditParams["color"]; !ok {
|
||||
t.Fatalf("color params not stored: %+v", got.EditParams)
|
||||
}
|
||||
|
||||
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
if len(reset.EditParams) != 0 {
|
||||
t.Fatalf("reset kept params: %+v", reset.EditParams)
|
||||
}
|
||||
if reset.EditURL != "" || reset.EditPreviewURL != "" {
|
||||
t.Fatalf("reset kept the edited version: %+v", reset)
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,93 @@ type CurvePoint struct {
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
// ColorParams holds the parameters of the "Цвет" (color) tool: white balance,
|
||||
// vibrance/saturation, the per-hue color mixer, color grading and a simple
|
||||
// per-channel color correction. Percent values use -100..100, wheel hues use
|
||||
// 0..360.
|
||||
type ColorParams struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
Tint float64 `json:"tint"`
|
||||
Vibrance float64 `json:"vibrance"`
|
||||
Saturation float64 `json:"saturation"`
|
||||
Mixer ColorMixer `json:"mixer"`
|
||||
Grading ColorGrading `json:"grading"`
|
||||
Correction ColorCorrection `json:"correction"`
|
||||
}
|
||||
|
||||
// ColorMixer holds one HSL band per hue, mirroring the Lightroom color mixer.
|
||||
type ColorMixer struct {
|
||||
Red MixerBand `json:"red"`
|
||||
Orange MixerBand `json:"orange"`
|
||||
Yellow MixerBand `json:"yellow"`
|
||||
Green MixerBand `json:"green"`
|
||||
Aqua MixerBand `json:"aqua"`
|
||||
Blue MixerBand `json:"blue"`
|
||||
Purple MixerBand `json:"purple"`
|
||||
Magenta MixerBand `json:"magenta"`
|
||||
}
|
||||
|
||||
// MixerBand is a hue/saturation/luminance adjustment of one color band.
|
||||
type MixerBand struct {
|
||||
Hue float64 `json:"hue"`
|
||||
Saturation float64 `json:"saturation"`
|
||||
Luminance float64 `json:"luminance"`
|
||||
}
|
||||
|
||||
// ColorGrading holds the three color-grading wheels and their blending.
|
||||
type ColorGrading struct {
|
||||
Shadows ColorWheel `json:"shadows"`
|
||||
Midtones ColorWheel `json:"midtones"`
|
||||
Highlights ColorWheel `json:"highlights"`
|
||||
Blending float64 `json:"blending"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// ColorWheel is a hue, strength and luminance used by color grading.
|
||||
type ColorWheel struct {
|
||||
Hue float64 `json:"hue"`
|
||||
Saturation float64 `json:"saturation"`
|
||||
Luminance float64 `json:"luminance"`
|
||||
}
|
||||
|
||||
// ColorCorrection is a simple per-channel gain, in percent.
|
||||
type ColorCorrection struct {
|
||||
Red float64 `json:"red"`
|
||||
Green float64 `json:"green"`
|
||||
Blue float64 `json:"blue"`
|
||||
}
|
||||
|
||||
// mixerBand is a color band with its hue center in degrees.
|
||||
type mixerBand struct {
|
||||
center float64
|
||||
hue float64
|
||||
saturation float64
|
||||
luminance float64
|
||||
}
|
||||
|
||||
// bands returns the mixer bands in a stable order.
|
||||
func (m ColorMixer) bands() [8]mixerBand {
|
||||
return [8]mixerBand{
|
||||
{0, m.Red.Hue, m.Red.Saturation, m.Red.Luminance},
|
||||
{30, m.Orange.Hue, m.Orange.Saturation, m.Orange.Luminance},
|
||||
{60, m.Yellow.Hue, m.Yellow.Saturation, m.Yellow.Luminance},
|
||||
{120, m.Green.Hue, m.Green.Saturation, m.Green.Luminance},
|
||||
{180, m.Aqua.Hue, m.Aqua.Saturation, m.Aqua.Luminance},
|
||||
{240, m.Blue.Hue, m.Blue.Saturation, m.Blue.Luminance},
|
||||
{280, m.Purple.Hue, m.Purple.Saturation, m.Purple.Luminance},
|
||||
{320, m.Magenta.Hue, m.Magenta.Saturation, m.Magenta.Luminance},
|
||||
}
|
||||
}
|
||||
|
||||
// wheels returns the grading wheels in a stable order.
|
||||
func (g ColorGrading) wheels() [3]ColorWheel {
|
||||
return [3]ColorWheel{g.Shadows, g.Midtones, g.Highlights}
|
||||
}
|
||||
|
||||
// EditParams is the full set of non-destructive editing parameters of a photo.
|
||||
type EditParams struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
}
|
||||
|
||||
// Limits of the editing parameters. They are enforced on write so a hand-made
|
||||
@@ -82,9 +166,54 @@ func (p EditParams) Normalized() EditParams {
|
||||
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
|
||||
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
|
||||
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
|
||||
p.Color = p.Color.Normalized()
|
||||
return p
|
||||
}
|
||||
|
||||
// Normalized returns a copy of the color parameters clamped to their ranges.
|
||||
func (c ColorParams) Normalized() ColorParams {
|
||||
c.Temperature = clampFloat(c.Temperature, -MaxPercent, MaxPercent)
|
||||
c.Tint = clampFloat(c.Tint, -MaxPercent, MaxPercent)
|
||||
c.Vibrance = clampFloat(c.Vibrance, -MaxPercent, MaxPercent)
|
||||
c.Saturation = clampFloat(c.Saturation, -MaxPercent, MaxPercent)
|
||||
|
||||
bands := c.Mixer.bands()
|
||||
for i := range bands {
|
||||
bands[i].hue = clampFloat(bands[i].hue, -MaxPercent, MaxPercent)
|
||||
bands[i].saturation = clampFloat(bands[i].saturation, -MaxPercent, MaxPercent)
|
||||
bands[i].luminance = clampFloat(bands[i].luminance, -MaxPercent, MaxPercent)
|
||||
}
|
||||
c.Mixer = ColorMixer{
|
||||
Red: MixerBand{bands[0].hue, bands[0].saturation, bands[0].luminance},
|
||||
Orange: MixerBand{bands[1].hue, bands[1].saturation, bands[1].luminance},
|
||||
Yellow: MixerBand{bands[2].hue, bands[2].saturation, bands[2].luminance},
|
||||
Green: MixerBand{bands[3].hue, bands[3].saturation, bands[3].luminance},
|
||||
Aqua: MixerBand{bands[4].hue, bands[4].saturation, bands[4].luminance},
|
||||
Blue: MixerBand{bands[5].hue, bands[5].saturation, bands[5].luminance},
|
||||
Purple: MixerBand{bands[6].hue, bands[6].saturation, bands[6].luminance},
|
||||
Magenta: MixerBand{bands[7].hue, bands[7].saturation, bands[7].luminance},
|
||||
}
|
||||
|
||||
wheels := c.Grading.wheels()
|
||||
for i := range wheels {
|
||||
wheels[i].Hue = clampFloat(wheels[i].Hue, 0, 360)
|
||||
wheels[i].Saturation = clampFloat(wheels[i].Saturation, -MaxPercent, MaxPercent)
|
||||
wheels[i].Luminance = clampFloat(wheels[i].Luminance, -MaxPercent, MaxPercent)
|
||||
}
|
||||
c.Grading = ColorGrading{
|
||||
Shadows: wheels[0],
|
||||
Midtones: wheels[1],
|
||||
Highlights: wheels[2],
|
||||
Blending: clampFloat(c.Grading.Blending, 0, MaxPercent),
|
||||
Balance: clampFloat(c.Grading.Balance, -MaxPercent, MaxPercent),
|
||||
}
|
||||
|
||||
c.Correction.Red = clampFloat(c.Correction.Red, -MaxPercent, MaxPercent)
|
||||
c.Correction.Green = clampFloat(c.Correction.Green, -MaxPercent, MaxPercent)
|
||||
c.Correction.Blue = clampFloat(c.Correction.Blue, -MaxPercent, MaxPercent)
|
||||
return c
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the parameters describe an untouched photo.
|
||||
func (p EditParams) IsEmpty() bool {
|
||||
l := p.Light
|
||||
@@ -99,7 +228,26 @@ func (p EditParams) IsEmpty() bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return p.Color.IsEmpty()
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the color parameters leave the image untouched.
|
||||
func (c ColorParams) IsEmpty() bool {
|
||||
if c.Temperature != 0 || c.Tint != 0 || c.Vibrance != 0 || c.Saturation != 0 {
|
||||
return false
|
||||
}
|
||||
for _, b := range c.Mixer.bands() {
|
||||
if b.hue != 0 || b.saturation != 0 || b.luminance != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, w := range c.Grading.wheels() {
|
||||
// A hue without strength or luminance has no effect.
|
||||
if w.Saturation != 0 || w.Luminance != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return c.Correction.Red == 0 && c.Correction.Green == 0 && c.Correction.Blue == 0
|
||||
}
|
||||
|
||||
// JSON encodes the parameters for storage in the photo metadata.
|
||||
@@ -267,3 +415,229 @@ func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
|
||||
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
|
||||
return newLightAdjuster(p).apply(v, master, channel)
|
||||
}
|
||||
|
||||
// rgbToHSL converts RGB (each 0..255) to HSL: hue in degrees, saturation and
|
||||
// lightness in 0..1. It uses only arithmetic, so it is easy to mirror in the
|
||||
// client and in the WASM module.
|
||||
func rgbToHSL(r, g, b float64) (h, s, l float64) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
max := math.Max(r, math.Max(g, b))
|
||||
min := math.Min(r, math.Min(g, b))
|
||||
l = (max + min) / 2
|
||||
if max == min {
|
||||
return 0, 0, l
|
||||
}
|
||||
d := max - min
|
||||
if l > 0.5 {
|
||||
s = d / (2 - max - min)
|
||||
} else {
|
||||
s = d / (max + min)
|
||||
}
|
||||
switch max {
|
||||
case r:
|
||||
h = (g - b) / d
|
||||
if g < b {
|
||||
h += 6
|
||||
}
|
||||
case g:
|
||||
h = (b-r)/d + 2
|
||||
case b:
|
||||
h = (r-g)/d + 4
|
||||
}
|
||||
h *= 60
|
||||
return h, s, l
|
||||
}
|
||||
|
||||
// hslToRGB converts HSL (hue in degrees, saturation and lightness in 0..1)
|
||||
// back to RGB in 0..255.
|
||||
func hslToRGB(h, s, l float64) (r, g, b float64) {
|
||||
if s == 0 {
|
||||
v := l * 255
|
||||
return v, v, v
|
||||
}
|
||||
var q float64
|
||||
if l < 0.5 {
|
||||
q = l * (1 + s)
|
||||
} else {
|
||||
q = l + s - l*s
|
||||
}
|
||||
p := 2*l - q
|
||||
hk := h / 360
|
||||
r = hueToRGB(p, q, hk+1.0/3)
|
||||
g = hueToRGB(p, q, hk)
|
||||
b = hueToRGB(p, q, hk-1.0/3)
|
||||
return r * 255, g * 255, b * 255
|
||||
}
|
||||
|
||||
// hueToRGB is the per-channel helper of hslToRGB.
|
||||
func hueToRGB(p, q, t float64) float64 {
|
||||
if t < 0 {
|
||||
t++
|
||||
}
|
||||
if t > 1 {
|
||||
t--
|
||||
}
|
||||
if t < 1.0/6 {
|
||||
return p + (q-p)*6*t
|
||||
}
|
||||
if t < 1.0/2 {
|
||||
return q
|
||||
}
|
||||
if t < 2.0/3 {
|
||||
return p + (q-p)*(2.0/3-t)*6
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// bandWeight returns a triangular weight of a hue around a band center.
|
||||
func bandWeight(h, center float64) float64 {
|
||||
d := math.Abs(h - center)
|
||||
if d > 180 {
|
||||
d = 360 - d
|
||||
}
|
||||
return clamp01(1 - d/60)
|
||||
}
|
||||
|
||||
// colorAdjuster holds the precomputed factors of the color adjustments. The
|
||||
// order of the operations is fixed and mirrored by the client-side preview and
|
||||
// the WASM module, so the live preview and the rendered result match.
|
||||
type colorAdjuster struct {
|
||||
temperature float64
|
||||
tint float64
|
||||
vibrance float64
|
||||
saturation float64
|
||||
mixer [8]mixerBand
|
||||
hasMixer bool
|
||||
wheels [3]ColorWheel
|
||||
luminances [3]float64
|
||||
blending float64
|
||||
balance float64
|
||||
correction [3]float64
|
||||
tints [3][3]float64
|
||||
}
|
||||
|
||||
func newColorAdjuster(p ColorParams) colorAdjuster {
|
||||
a := colorAdjuster{
|
||||
temperature: p.Temperature / 100,
|
||||
tint: p.Tint / 100,
|
||||
vibrance: p.Vibrance / 100,
|
||||
saturation: p.Saturation / 100,
|
||||
mixer: p.Mixer.bands(),
|
||||
wheels: p.Grading.wheels(),
|
||||
blending: p.Grading.Blending / 100,
|
||||
balance: p.Grading.Balance / 100,
|
||||
correction: [3]float64{p.Correction.Red / 100, p.Correction.Green / 100, p.Correction.Blue / 100},
|
||||
}
|
||||
for _, band := range a.mixer {
|
||||
if band.hue != 0 || band.saturation != 0 || band.luminance != 0 {
|
||||
a.hasMixer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, wheel := range a.wheels {
|
||||
a.luminances[i] = wheel.Luminance / 100
|
||||
r, g, b := hslToRGB(wheel.Hue, 1, 0.5)
|
||||
a.tints[i] = [3]float64{r, g, b}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// apply color-corrects one RGB pixel (each 0..255) and returns the result.
|
||||
func (a colorAdjuster) apply(r, g, b float64) (float64, float64, float64) {
|
||||
// Баланс белого: тёплый/холодный и зелёный/пурпурный сдвиг.
|
||||
r *= 1 + 0.20*a.temperature + 0.10*a.tint
|
||||
g *= 1 - 0.20*a.tint
|
||||
b *= 1 - 0.20*a.temperature + 0.10*a.tint
|
||||
r = clampFloat(r, 0, 255)
|
||||
g = clampFloat(g, 0, 255)
|
||||
b = clampFloat(b, 0, 255)
|
||||
|
||||
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
|
||||
// HSL-переход не нужен, lightness считается напрямую.
|
||||
fast := !a.hasMixer && a.saturation == 0 && a.vibrance == 0
|
||||
var weights [3]float64
|
||||
var rr, gg, bb float64
|
||||
|
||||
if fast {
|
||||
max := math.Max(r, math.Max(g, b))
|
||||
min := math.Min(r, math.Min(g, b))
|
||||
l := (max + min) / 510
|
||||
lb := clamp01(l + a.balance*0.5)
|
||||
shadowW := (1 - lb) * (1 - lb)
|
||||
shadowW *= shadowW
|
||||
highlightW := lb * lb
|
||||
highlightW *= highlightW
|
||||
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
|
||||
targetL := l
|
||||
for i := range a.wheels {
|
||||
targetL += a.luminances[i] * 0.2 * weights[i]
|
||||
}
|
||||
targetL = clamp01(targetL)
|
||||
delta := (targetL - l) * 255
|
||||
rr = r + delta
|
||||
gg = g + delta
|
||||
bb = b + delta
|
||||
} else {
|
||||
// RGB -> HSL.
|
||||
h, s, l := rgbToHSL(r, g, b)
|
||||
|
||||
// Насыщенность и вибрация: вибрация сильнее влияет на приглушённые цвета.
|
||||
if a.saturation != 0 || a.vibrance != 0 {
|
||||
s = clamp01(s * (1 + a.saturation + a.vibrance*(1-s)))
|
||||
}
|
||||
|
||||
// Цветовой микшер: сдвиг оттенка, насыщенности и яркости по зонам.
|
||||
if a.hasMixer {
|
||||
for _, band := range a.mixer {
|
||||
w := bandWeight(h, band.center)
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
h += (band.hue / 100) * 30 * w
|
||||
s = clamp01(s * (1 + (band.saturation/100)*w))
|
||||
l = clamp01(l + (band.luminance/100)*0.2*w)
|
||||
}
|
||||
}
|
||||
h = math.Mod(h, 360)
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
|
||||
// Зоны яркости: веса в сумме дают 1, баланс сдвигает границы.
|
||||
lb := clamp01(l + a.balance*0.5)
|
||||
// Более узкие зоны: колесо теней почти не задевает средние тона.
|
||||
shadowW := (1 - lb) * (1 - lb)
|
||||
shadowW *= shadowW
|
||||
highlightW := lb * lb
|
||||
highlightW *= highlightW
|
||||
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
|
||||
for i := range a.wheels {
|
||||
l = clamp01(l + a.luminances[i]*0.2*weights[i])
|
||||
}
|
||||
|
||||
// HSL -> RGB.
|
||||
rr, gg, bb = hslToRGB(h, s, l)
|
||||
}
|
||||
|
||||
// Точечный цвет: три колеса по зонам яркости.
|
||||
blend := a.blending
|
||||
for i, wheel := range a.wheels {
|
||||
strength := (wheel.Saturation / 100) * 0.5 * blend * weights[i]
|
||||
if strength == 0 {
|
||||
continue
|
||||
}
|
||||
tint := a.tints[i]
|
||||
rr = rr*(1-strength) + tint[0]*strength
|
||||
gg = gg*(1-strength) + tint[1]*strength
|
||||
bb = bb*(1-strength) + tint[2]*strength
|
||||
}
|
||||
|
||||
// Цветокоррекция: поканальный сдвиг.
|
||||
rr *= 1 + a.correction[0]*0.5
|
||||
gg *= 1 + a.correction[1]*0.5
|
||||
bb *= 1 + a.correction[2]*0.5
|
||||
|
||||
return clampFloat(rr, 0, 255), clampFloat(gg, 0, 255), clampFloat(bb, 0, 255)
|
||||
}
|
||||
|
||||
@@ -90,14 +90,68 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAndAdjust decodes the original and applies the light adjustments to
|
||||
// it, so the variants and the full-size version share one decode.
|
||||
// decodeAndAdjust decodes the original and applies the light and color
|
||||
// adjustments to it, so the variants and the full-size version share one
|
||||
// decode. Color is applied after light.
|
||||
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applyLight(img, p.Light), nil
|
||||
edited := applyLight(img, p.Light)
|
||||
if p.Color.IsEmpty() {
|
||||
return edited, nil
|
||||
}
|
||||
return applyColor(edited, p.Color), nil
|
||||
}
|
||||
|
||||
// applyColor applies the color adjustments in place to an image that already
|
||||
// has the light adjustments applied. The image is owned by the caller.
|
||||
func applyColor(dst *image.NRGBA, p ColorParams) *image.NRGBA {
|
||||
adj := newColorAdjuster(p)
|
||||
|
||||
rows := dst.Rect.Dy()
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > rows {
|
||||
workers = rows
|
||||
}
|
||||
if workers <= 1 {
|
||||
applyColorRows(dst, 0, rows, adj)
|
||||
return dst
|
||||
}
|
||||
// Рендер больших фото идёт по строкам параллельно.
|
||||
chunk := (rows + workers - 1) / workers
|
||||
var wg sync.WaitGroup
|
||||
for from := 0; from < rows; from += chunk {
|
||||
to := from + chunk
|
||||
if to > rows {
|
||||
to = rows
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(from, to int) {
|
||||
defer wg.Done()
|
||||
applyColorRows(dst, from, to, adj)
|
||||
}(from, to)
|
||||
}
|
||||
wg.Wait()
|
||||
return dst
|
||||
}
|
||||
|
||||
// applyColorRows applies the color adjustments to the given range of rows.
|
||||
func applyColorRows(dst *image.NRGBA, from, to int, adj colorAdjuster) {
|
||||
width := dst.Rect.Dx()
|
||||
for y := from; y < to; y++ {
|
||||
row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4]
|
||||
for i := 0; i+3 < len(row); i += 4 {
|
||||
if row[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
r, g, b := adj.apply(float64(row[i]), float64(row[i+1]), float64(row[i+2]))
|
||||
row[i] = uint8(clampFloat(r+0.5, 0, 255))
|
||||
row[i+1] = uint8(clampFloat(g+0.5, 0, 255))
|
||||
row[i+2] = uint8(clampFloat(b+0.5, 0, 255))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encodeEditedVariants renders the grid variants of an edited photo. They
|
||||
|
||||
@@ -182,3 +182,195 @@ func mustEncodePNG(t *testing.T, img image.Image) []byte {
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseEditParamsClampsColor(t *testing.T) {
|
||||
raw := []byte(`{"color":{"temperature":200,"tint":-300,"vibrance":50,"saturation":-500,"mixer":{"red":{"hue":150,"saturation":-200,"luminance":10}},"grading":{"shadows":{"hue":400,"saturation":150},"balance":-200},"correction":{"red":120}}}`)
|
||||
params, err := ParseEditParams(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
c := params.Color
|
||||
if c.Temperature != MaxPercent || c.Tint != -MaxPercent {
|
||||
t.Fatalf("wb = %v/%v, want clamped", c.Temperature, c.Tint)
|
||||
}
|
||||
if c.Saturation != -MaxPercent {
|
||||
t.Fatalf("saturation = %v, want %v", c.Saturation, -MaxPercent)
|
||||
}
|
||||
if c.Mixer.Red.Hue != MaxPercent || c.Mixer.Red.Saturation != -MaxPercent {
|
||||
t.Fatalf("mixer red = %+v, want clamped", c.Mixer.Red)
|
||||
}
|
||||
if c.Grading.Shadows.Hue != 360 || c.Grading.Shadows.Saturation != MaxPercent {
|
||||
t.Fatalf("grading shadows = %+v, want clamped", c.Grading.Shadows)
|
||||
}
|
||||
if c.Grading.Balance != -MaxPercent || c.Correction.Red != MaxPercent {
|
||||
t.Fatalf("grading/correction not clamped: %+v %+v", c.Grading, c.Correction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorParamsIsEmpty(t *testing.T) {
|
||||
var zero ColorParams
|
||||
if !zero.IsEmpty() {
|
||||
t.Fatal("zero color params must be empty")
|
||||
}
|
||||
vibrance := ColorParams{Vibrance: 1}
|
||||
if vibrance.IsEmpty() {
|
||||
t.Fatal("vibrance must not be empty")
|
||||
}
|
||||
mixer := ColorParams{}
|
||||
mixer.Mixer.Blue.Luminance = -10
|
||||
if mixer.IsEmpty() {
|
||||
t.Fatal("mixer band must not be empty")
|
||||
}
|
||||
// Оттенок колеса без силы не влияет на картинку.
|
||||
hueOnly := ColorParams{}
|
||||
hueOnly.Grading.Shadows.Hue = 120
|
||||
if !hueOnly.IsEmpty() {
|
||||
t.Fatal("grading hue without strength must count as empty")
|
||||
}
|
||||
grade := ColorParams{}
|
||||
grade.Grading.Highlights.Saturation = 10
|
||||
if grade.IsEmpty() {
|
||||
t.Fatal("grading strength must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorWhiteBalance(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 100, 100, 100, 255
|
||||
out := applyColor(src, ColorParams{Temperature: 100})
|
||||
if !(out.Pix[0] > out.Pix[1] && out.Pix[1] > out.Pix[2]) {
|
||||
t.Fatalf("warm white balance = %d/%d/%d, want r > g > b", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorSaturationMinus100IsGray(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 200, 50, 50, 255
|
||||
out := applyColor(src, ColorParams{Saturation: -100})
|
||||
if out.Pix[0] != out.Pix[1] || out.Pix[1] != out.Pix[2] {
|
||||
t.Fatalf("desaturated pixel = %d/%d/%d, want gray", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorSaturationPlus100(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 150, 120, 120, 255
|
||||
before := src.Pix[0] - src.Pix[1]
|
||||
out := applyColor(src, ColorParams{Saturation: 100})
|
||||
if out.Pix[0]-out.Pix[1] <= before {
|
||||
t.Fatalf("saturation did not spread channels: %d/%d/%d", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorGradingTintsShadows(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
out := applyColor(src, p)
|
||||
if out.Pix[2] <= out.Pix[0] {
|
||||
t.Fatalf("blue shadow grading = %d/%d/%d, want b > r", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorGradingLuminance(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||
p := ColorParams{}
|
||||
p.Grading.Shadows = ColorWheel{Luminance: 100}
|
||||
out := applyColor(src, p)
|
||||
if out.Pix[0] <= 40 {
|
||||
t.Fatalf("shadow luminance did not brighten: %d", out.Pix[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorUniformRows(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(src.Pix); i += 4 {
|
||||
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 40, 40, 40, 255
|
||||
}
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
out := applyColor(src, p)
|
||||
wantR, wantG, wantB := out.Pix[0], out.Pix[1], out.Pix[2]
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 64; x++ {
|
||||
i := y*out.Stride + x*4
|
||||
if out.Pix[i] != wantR || out.Pix[i+1] != wantG || out.Pix[i+2] != wantB {
|
||||
t.Fatalf("pixel (%d,%d) = %d/%d/%d, want %d/%d/%d", x, y, out.Pix[i], out.Pix[i+1], out.Pix[i+2], wantR, wantG, wantB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorParallelMatchesSingle(t *testing.T) {
|
||||
makeImage := func() *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 32, 64))
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 32; x++ {
|
||||
i := y*img.Stride + x*4
|
||||
img.Pix[i] = uint8(20 + y*3)
|
||||
img.Pix[i+1] = uint8(20 + y*2)
|
||||
img.Pix[i+2] = uint8(30 + y)
|
||||
img.Pix[i+3] = 255
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
|
||||
parallel := applyColor(makeImage(), p)
|
||||
single := makeImage()
|
||||
applyColorRows(single, 0, single.Rect.Dy(), newColorAdjuster(p))
|
||||
|
||||
for i := range parallel.Pix {
|
||||
if parallel.Pix[i] != single.Pix[i] {
|
||||
t.Fatalf("parallel differs at byte %d: %d != %d", i, parallel.Pix[i], single.Pix[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorKeepsTransparentPixels(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 10, 20, 30, 0
|
||||
out := applyColor(src, ColorParams{Saturation: 100, Temperature: 100})
|
||||
if out.Pix[0] != 10 || out.Pix[1] != 20 || out.Pix[2] != 30 || out.Pix[3] != 0 {
|
||||
t.Fatalf("transparent pixel changed: %v", out.Pix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEditParamsWithoutColorIsBackwardCompatible(t *testing.T) {
|
||||
params, err := ParseEditParams([]byte(`{"light":{"exposure":0.5}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !params.Color.IsEmpty() {
|
||||
t.Fatalf("missing color must be empty: %+v", params.Color)
|
||||
}
|
||||
if params.IsEmpty() {
|
||||
t.Fatal("light edits must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorEmptyIsIdentity(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 3, 1))
|
||||
colors := [][3]uint8{{0, 0, 0}, {200, 50, 25}, {33, 210, 128}}
|
||||
for i, c := range colors {
|
||||
src.Pix[i*4] = c[0]
|
||||
src.Pix[i*4+1] = c[1]
|
||||
src.Pix[i*4+2] = c[2]
|
||||
src.Pix[i*4+3] = 255
|
||||
}
|
||||
before := make([]byte, len(src.Pix))
|
||||
copy(before, src.Pix)
|
||||
applyColor(src, ColorParams{})
|
||||
for i := range before {
|
||||
if before[i] != src.Pix[i] {
|
||||
t.Fatalf("empty color changed pixel %d: %d != %d", i, src.Pix[i], before[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +124,13 @@ func (h *Handler) Download(c *gin.Context) {
|
||||
func (h *Handler) Edit(c *gin.Context) {
|
||||
var req struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "light params are required"))
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
|
||||
return
|
||||
}
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light})
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (hue: number, saturation: number) => void;
|
||||
oncommit?: () => void;
|
||||
}
|
||||
|
||||
let { hue, saturation, label, disabled = false, onchange, oncommit }: Props = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let el: HTMLDivElement | undefined = $state();
|
||||
|
||||
function pick(ev: PointerEvent) {
|
||||
if (!el || disabled) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = (ev.clientX - rect.left) / rect.width - 0.5;
|
||||
const y = (ev.clientY - rect.top) / rect.height - 0.5;
|
||||
const radius = Math.sqrt(x * x + y * y);
|
||||
const angle = Math.atan2(x, -y);
|
||||
const nextHue = (((angle * 180) / Math.PI) + 360) % 360;
|
||||
const nextSaturation = Math.min(1, radius * 2) * 100;
|
||||
onchange?.(nextHue, nextSaturation);
|
||||
}
|
||||
|
||||
function onDown(ev: PointerEvent) {
|
||||
if (disabled) return;
|
||||
ev.preventDefault();
|
||||
dragging = true;
|
||||
pick(ev);
|
||||
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
|
||||
}
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
if (dragging) pick(ev);
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
oncommit?.();
|
||||
}
|
||||
|
||||
/** Двойной клик сбрасывает колесо в нейтраль. */
|
||||
function onDoubleClick(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
if (disabled) return;
|
||||
onchange?.(0, 0);
|
||||
oncommit?.();
|
||||
}
|
||||
|
||||
const knob = $derived({
|
||||
left:
|
||||
50 +
|
||||
Math.sin((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50,
|
||||
top:
|
||||
50 -
|
||||
Math.cos((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div
|
||||
bind:this={el}
|
||||
class="relative aspect-square w-full max-w-[88px] touch-none rounded-full"
|
||||
style="background: radial-gradient(circle, #d9d9d9 0%, rgba(217,217,217,0) 55%), conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red);"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label={label ?? 'Цвет'}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="360"
|
||||
aria-valuenow={Math.round(hue)}
|
||||
onpointerdown={onDown}
|
||||
onpointermove={onMove}
|
||||
onpointerup={onUp}
|
||||
onpointerleave={onUp}
|
||||
ondblclick={onDoubleClick}
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow"
|
||||
style="left: {knob.left}%; top: {knob.top}%;"
|
||||
></span>
|
||||
</div>
|
||||
{#if label}<span class="text-text-muted text-[10px]">{label}</span>{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id?: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
disabled?: boolean;
|
||||
/** Полный градиент дорожки (например, для цветовых ползунков). */
|
||||
gradient?: string;
|
||||
/** Значение при двойном клике. */
|
||||
resetValue?: number;
|
||||
format?: (value: number) => string;
|
||||
oninput?: (value: number) => void;
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled = false,
|
||||
gradient,
|
||||
resetValue = 0,
|
||||
format,
|
||||
oninput,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
const percent = $derived(((value - min) / (max - min)) * 100);
|
||||
const trackStyle = $derived(
|
||||
gradient
|
||||
? `background: ${gradient};`
|
||||
: `background: linear-gradient(to right, #cfcfcf 0%, #cfcfcf ${percent}%, #4a4a4a ${percent}%, #4a4a4a 100%);`
|
||||
);
|
||||
const display = $derived(format ? format(value) : String(value));
|
||||
|
||||
function handleInput(event: Event) {
|
||||
oninput?.(Number((event.currentTarget as HTMLInputElement).value));
|
||||
}
|
||||
|
||||
function handleDoubleClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
oninput?.(resetValue);
|
||||
onchange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<label class="text-text text-xs" for={id}>{label}</label>
|
||||
<span class="text-text-muted text-xs tabular-nums">{display}</span>
|
||||
</div>
|
||||
<input
|
||||
{id}
|
||||
type="range"
|
||||
class="edit-slider"
|
||||
style={trackStyle}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{disabled}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
onchange={onchange}
|
||||
ondblclick={handleDoubleClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.edit-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edit-slider:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.edit-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: #f3f3f3;
|
||||
border: 1px solid #9a9a9a;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
.edit-slider::-moz-range-thumb {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: #f3f3f3;
|
||||
border: 1px solid #9a9a9a;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { photosApi } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import EditSlider from './EditSlider.svelte';
|
||||
import ColorWheel from './ColorWheel.svelte';
|
||||
import { edit } from '$lib/stores/edit.svelte';
|
||||
import { applyLight } from '$lib/light-wasm';
|
||||
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||
import { applyColor, applyLight } from '$lib/light-wasm';
|
||||
import {
|
||||
CHANNEL_COLORS,
|
||||
CHANNEL_LABELS,
|
||||
COLOR_SLIDERS,
|
||||
CURVE_CHANNELS,
|
||||
GRADING_WHEELS,
|
||||
LIGHT_SLIDERS,
|
||||
MIXER_BANDS,
|
||||
emptyEditParams,
|
||||
isEmptyEditParams,
|
||||
normalizeEditParams,
|
||||
readEditParams,
|
||||
whiteBalanceFromRGB,
|
||||
type ColorSliderKey,
|
||||
type CurveChannel,
|
||||
type CurvePoint,
|
||||
type EditParams
|
||||
type EditParams,
|
||||
type GradingWheelKey,
|
||||
type MixerBand,
|
||||
type MixerBandKey
|
||||
} from '$lib/edit';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
photo: Photo;
|
||||
canEdit: boolean;
|
||||
viewer?: boolean;
|
||||
onUpdated: (photo: Photo) => void;
|
||||
}
|
||||
|
||||
let { photo, canEdit, onUpdated }: Props = $props();
|
||||
let { photo, canEdit, viewer = false, onUpdated }: Props = $props();
|
||||
|
||||
let params = $state<EditParams>(emptyEditParams());
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let collapsed = $state(false);
|
||||
let curveCollapsed = $state(true);
|
||||
let curveCollapsed = $state(false);
|
||||
let channel = $state<CurveChannel>('rgb');
|
||||
let colorCollapsed = $state(false);
|
||||
let mixerCollapsed = $state(false);
|
||||
let mixerTab = $state<keyof MixerBand>('hue');
|
||||
let mixerBand = $state<MixerBandKey>('red');
|
||||
let pointColorCollapsed = $state(false);
|
||||
let correctionCollapsed = $state(false);
|
||||
let correctionView = $state<'all' | 'midtones' | 'split' | 'highlights'>('all');
|
||||
let wbPreset = $state('as-shot');
|
||||
|
||||
const saved = $derived(readEditParams(photo));
|
||||
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
|
||||
@@ -46,6 +67,7 @@
|
||||
const id = photo.id;
|
||||
if (id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
eyedropper.stop();
|
||||
params = readEditParams(photo);
|
||||
edit.set(id, params);
|
||||
error = '';
|
||||
@@ -55,6 +77,26 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Пипетка баланса белого: просмотрщик присылает цвет нейтральной точки.
|
||||
let lastSampleSeq = 0;
|
||||
$effect(() => {
|
||||
const sample = eyedropper.sample;
|
||||
if (!sample || sample.photoId !== photo.id || sample.seq === lastSampleSeq) return;
|
||||
lastSampleSeq = sample.seq;
|
||||
const wb = whiteBalanceFromRGB(sample.r, sample.g, sample.b);
|
||||
const base = untrack(() => params);
|
||||
const next = normalizeEditParams({
|
||||
light: base.light,
|
||||
color: { ...base.color, temperature: wb.temperature, tint: wb.tint }
|
||||
});
|
||||
untrack(() => {
|
||||
params = next;
|
||||
edit.set(photo.id, next);
|
||||
wbPreset = 'custom';
|
||||
scheduleSave();
|
||||
});
|
||||
});
|
||||
|
||||
// Гистограмма каналов считается по базовому превью фото и пересчитывается
|
||||
// с учётом текущих правок, поэтому реагирует на движение ползунков и кривой.
|
||||
type Histogram = { red: number[]; green: number[]; blue: number[] };
|
||||
@@ -97,6 +139,7 @@
|
||||
if (!pixels) return null;
|
||||
const data = new Uint8ClampedArray(pixels);
|
||||
applyLight(data, params.light);
|
||||
applyColor(data, params.color);
|
||||
const r = new Array<number>(256).fill(0);
|
||||
const g = new Array<number>(256).fill(0);
|
||||
const b = new Array<number>(256).fill(0);
|
||||
@@ -202,6 +245,174 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
const MIXER_COMPONENTS: { key: keyof MixerBand; label: string }[] = [
|
||||
{ key: 'hue', label: 'Тон' },
|
||||
{ key: 'saturation', label: 'Насыщенность' },
|
||||
{ key: 'luminance', label: 'Яркость' }
|
||||
];
|
||||
|
||||
const MIXER_GENITIVE: Record<MixerBandKey, string> = {
|
||||
red: 'красного',
|
||||
orange: 'оранжевого',
|
||||
yellow: 'жёлтого',
|
||||
green: 'зелёного',
|
||||
aqua: 'бирюзового',
|
||||
blue: 'синего',
|
||||
purple: 'фиолетового',
|
||||
magenta: 'пурпурного'
|
||||
};
|
||||
|
||||
const CORRECTION_VIEWS = [
|
||||
{ key: 'all' as const, icon: 'blur_on' as const, label: 'Все зоны' },
|
||||
{ key: 'midtones' as const, icon: 'circle' as const, label: 'Средние тона' },
|
||||
{ key: 'split' as const, icon: 'contrast' as const, label: 'Тени и света' },
|
||||
{ key: 'highlights' as const, icon: 'light_mode' as const, label: 'Света' }
|
||||
];
|
||||
|
||||
const WB_PRESETS = [
|
||||
{ value: 'as-shot', label: 'Как снято', temperature: 0, tint: 0 },
|
||||
{ value: 'auto', label: 'Авто', temperature: 0, tint: 0 },
|
||||
{ value: 'daylight', label: 'Дневной свет', temperature: 5, tint: -5 },
|
||||
{ value: 'cloudy', label: 'Облачно', temperature: 20, tint: 0 },
|
||||
{ value: 'shade', label: 'Тень', temperature: 40, tint: 5 },
|
||||
{ value: 'tungsten', label: 'Лампа накаливания', temperature: -40, tint: 10 },
|
||||
{ value: 'fluorescent', label: 'Флуоресцентная', temperature: -20, tint: 15 }
|
||||
];
|
||||
|
||||
const correctionWheels = $derived<GradingWheelKey[]>(
|
||||
correctionView === 'all'
|
||||
? ['midtones', 'shadows', 'highlights']
|
||||
: correctionView === 'midtones'
|
||||
? ['midtones']
|
||||
: correctionView === 'split'
|
||||
? ['shadows', 'highlights']
|
||||
: ['highlights']
|
||||
);
|
||||
|
||||
const selectedBand = $derived(
|
||||
MIXER_BANDS.find((band) => band.key === mixerBand) ?? MIXER_BANDS[0]
|
||||
);
|
||||
|
||||
function onColorSliderInput(key: ColorSliderKey, value: number) {
|
||||
if (key === 'temperature' || key === 'tint') wbPreset = 'custom';
|
||||
params = { ...params, color: { ...params.color, [key]: value } };
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onColorSliderCommit() {
|
||||
if (!canEdit) return;
|
||||
params = normalizeEditParams(params);
|
||||
edit.set(photo.id, params);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function onMixerInput(band: MixerBandKey, component: keyof MixerBand, value: number) {
|
||||
const current = params.color.mixer[band];
|
||||
params = {
|
||||
...params,
|
||||
color: {
|
||||
...params.color,
|
||||
mixer: {
|
||||
...params.color.mixer,
|
||||
[band]: { ...current, [component]: value }
|
||||
}
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onWheelInput(wheel: GradingWheelKey, hue: number, saturation: number) {
|
||||
const nextHue = Number.isFinite(hue) ? Math.min(360, Math.max(0, hue)) : 0;
|
||||
const nextSaturation = Number.isFinite(saturation)
|
||||
? Math.min(100, Math.max(0, saturation))
|
||||
: 0;
|
||||
params = {
|
||||
...params,
|
||||
color: {
|
||||
...params.color,
|
||||
grading: {
|
||||
...params.color.grading,
|
||||
[wheel]: {
|
||||
...params.color.grading[wheel],
|
||||
hue: nextHue,
|
||||
saturation: nextSaturation
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onGradingInput(key: 'blending' | 'balance', value: number) {
|
||||
params = {
|
||||
...params,
|
||||
color: {
|
||||
...params.color,
|
||||
grading: { ...params.color.grading, [key]: value }
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onWheelLuminanceInput(wheel: GradingWheelKey, value: number) {
|
||||
params = {
|
||||
...params,
|
||||
color: {
|
||||
...params.color,
|
||||
grading: {
|
||||
...params.color.grading,
|
||||
[wheel]: { ...params.color.grading[wheel], luminance: value }
|
||||
}
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function applyWbPreset(value: string) {
|
||||
wbPreset = value;
|
||||
if (value === 'custom') return;
|
||||
const preset = WB_PRESETS.find((item) => item.value === value) ?? WB_PRESETS[0];
|
||||
params = {
|
||||
...params,
|
||||
color: { ...params.color, temperature: preset.temperature, tint: preset.tint }
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function bandColor(center: number): string {
|
||||
return `hsl(${center}, 85%, 50%)`;
|
||||
}
|
||||
|
||||
function hueGradient(center: number): string {
|
||||
return `linear-gradient(90deg, hsl(${center - 60}, 85%, 50%), hsl(${center}, 85%, 50%), hsl(${center + 60}, 85%, 50%))`;
|
||||
}
|
||||
|
||||
function saturationGradient(center: number): string {
|
||||
return `linear-gradient(90deg, hsl(${center}, 0%, 50%), hsl(${center}, 100%, 50%))`;
|
||||
}
|
||||
|
||||
function luminanceGradient(center: number): string {
|
||||
return `linear-gradient(90deg, hsl(${center}, 85%, 8%), hsl(${center}, 85%, 65%))`;
|
||||
}
|
||||
|
||||
function colorGradient(key: ColorSliderKey): string {
|
||||
switch (key) {
|
||||
case 'temperature':
|
||||
return 'linear-gradient(90deg, #3b6fd4, #a3b83a)';
|
||||
case 'tint':
|
||||
return 'linear-gradient(90deg, #3fa34d, #b23bb0)';
|
||||
case 'vibrance':
|
||||
return 'linear-gradient(90deg, #6b7280, #e0526b)';
|
||||
case 'saturation':
|
||||
return 'linear-gradient(90deg, #6b7280, #e03131)';
|
||||
}
|
||||
}
|
||||
|
||||
function formatExposure(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function setChannel(next: CurveChannel) {
|
||||
channel = next;
|
||||
}
|
||||
@@ -234,7 +445,7 @@
|
||||
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
|
||||
}
|
||||
|
||||
/** Клик по полю кривой добавляет точку. */
|
||||
/** Клик по полю кривой добавляет точку, двойной клик — сбрасывает кривую. */
|
||||
function onCurveDown(ev: PointerEvent) {
|
||||
if (!canEdit) return;
|
||||
const target = ev.target as Element | null;
|
||||
@@ -339,9 +550,6 @@
|
||||
await resetEdit(photo.id);
|
||||
}
|
||||
|
||||
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
|
||||
const valueClass = 'text-text-muted text-xs tabular-nums';
|
||||
|
||||
function formatValue(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
if (rounded === 0) return '0';
|
||||
@@ -377,66 +585,78 @@
|
||||
|
||||
{#if !collapsed}
|
||||
<div class="mt-3 space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2.5">
|
||||
{#each LIGHT_SLIDERS as slider (slider.key)}
|
||||
<div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<label class="text-text text-xs" for="light-{slider.key}">{slider.label}</label>
|
||||
<span class={valueClass}>{formatValue(params.light[slider.key])}</span>
|
||||
</div>
|
||||
<input
|
||||
id="light-{slider.key}"
|
||||
type="range"
|
||||
class={sliderClass}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
value={params.light[slider.key]}
|
||||
disabled={!canEdit}
|
||||
oninput={(e) => onSliderInput(slider.key, Number(e.currentTarget.value))}
|
||||
onchange={onSliderCommit}
|
||||
/>
|
||||
</div>
|
||||
<EditSlider
|
||||
id="light-{slider.key}"
|
||||
label={slider.key === 'contrast' ? 'Контрастность' : slider.label}
|
||||
value={params.light[slider.key]}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
disabled={!canEdit}
|
||||
format={slider.key === 'exposure' ? formatExposure : formatValue}
|
||||
oninput={(value) => onSliderInput(slider.key, value)}
|
||||
onchange={onSliderCommit}
|
||||
/>
|
||||
{/each}
|
||||
</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-xs font-semibold"
|
||||
aria-expanded={!curveCollapsed}
|
||||
aria-controls="light-curve"
|
||||
onclick={() => (curveCollapsed = !curveCollapsed)}
|
||||
>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
|
||||
aria-expanded={!curveCollapsed}
|
||||
aria-controls="light-curve"
|
||||
onclick={() => (curveCollapsed = !curveCollapsed)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="show_chart" class="size-4" />
|
||||
Кривая
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each CURVE_CHANNELS as ch (ch)}
|
||||
<button
|
||||
type="button"
|
||||
class="size-4 rounded-full border-2 disabled:opacity-40"
|
||||
style="border-color: {CHANNEL_COLORS[ch]}; background: {ch === channel
|
||||
? CHANNEL_COLORS[ch]
|
||||
: 'transparent'}"
|
||||
aria-label="Канал {CHANNEL_LABELS[ch]}"
|
||||
title="Канал {CHANNEL_LABELS[ch]}"
|
||||
aria-pressed={ch === channel}
|
||||
disabled={!canEdit}
|
||||
onclick={() => setChannel(ch)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if !curveCollapsed}
|
||||
<div id="light-curve">
|
||||
<div id="light-curve" class="mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#each CURVE_CHANNELS as ch (ch)}
|
||||
{#if ch === 'rgb'}
|
||||
<button
|
||||
type="button"
|
||||
class="size-4 border-2 disabled:opacity-40"
|
||||
style="border-color: #d4d4d4; background: {channel === ch
|
||||
? '#d4d4d4'
|
||||
: 'transparent'}"
|
||||
aria-label="Канал RGB"
|
||||
title="Канал RGB"
|
||||
aria-pressed={channel === ch}
|
||||
disabled={!canEdit}
|
||||
onclick={() => setChannel(ch)}
|
||||
></button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="size-4 rounded-full border-2 disabled:opacity-40"
|
||||
style="border-color: {CHANNEL_COLORS[ch]}; background: {channel === ch
|
||||
? CHANNEL_COLORS[ch]
|
||||
: 'transparent'}"
|
||||
aria-label="Канал {CHANNEL_LABELS[ch]}"
|
||||
title="Канал {CHANNEL_LABELS[ch]}"
|
||||
aria-pressed={channel === ch}
|
||||
disabled={!canEdit}
|
||||
onclick={() => setChannel(ch)}
|
||||
></button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Icon name="colorize" class="text-text-muted ml-auto size-4" />
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
|
||||
class="border-border bg-bg mt-2 block aspect-square w-full touch-none rounded border"
|
||||
viewBox="0 0 100 100"
|
||||
role="application"
|
||||
aria-label="Тоновая кривая"
|
||||
@@ -444,6 +664,10 @@
|
||||
onpointermove={onPointMove}
|
||||
onpointerup={onPointUp}
|
||||
onpointerleave={onPointUp}
|
||||
ondblclick={(e) => {
|
||||
e.preventDefault();
|
||||
resetCurve();
|
||||
}}
|
||||
>
|
||||
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
|
||||
{#if histogram}
|
||||
@@ -452,7 +676,10 @@
|
||||
d={histogramPath(histogramValues(ch))}
|
||||
fill="none"
|
||||
stroke-width="0.4"
|
||||
style="stroke: {CHANNEL_COLORS[ch]}; opacity: {ch === 'rgb' ? 0.45 : 0.7}"
|
||||
style="stroke: {channel === 'rgb' ? '#6b7280' : CHANNEL_COLORS[ch]}; opacity: {channel ===
|
||||
'rgb'
|
||||
? 0.45
|
||||
: 0.7}"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -463,9 +690,11 @@
|
||||
<path
|
||||
d={curvePath(channel)}
|
||||
fill="none"
|
||||
stroke-width="1.4"
|
||||
style="stroke: {CHANNEL_COLORS[channel]}"
|
||||
stroke-width="1.2"
|
||||
style="stroke: {channel === 'rgb' ? '#e5e5e5' : CHANNEL_COLORS[channel]}"
|
||||
/>
|
||||
<circle cx="0" cy="100" r="1.8" fill="#e5e5e5" />
|
||||
<circle cx="100" cy="0" r="1.8" fill="#e5e5e5" />
|
||||
{#each channelPoints(channel) as point, index (index)}
|
||||
<circle
|
||||
cx={point.x * 100}
|
||||
@@ -473,7 +702,9 @@
|
||||
r="2.4"
|
||||
class="cursor-grab"
|
||||
stroke-width="1.2"
|
||||
style="fill: hsl(var(--bg)); stroke: {CHANNEL_COLORS[channel]}"
|
||||
style="fill: hsl(var(--bg)); stroke: {channel === 'rgb'
|
||||
? '#e5e5e5'
|
||||
: CHANNEL_COLORS[channel]}"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="Точка кривой {index + 1}"
|
||||
@@ -487,32 +718,369 @@
|
||||
</svg>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<span class="text-text-muted text-xs">Точечная кривая · линейная</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-muted text-xs">Точечная кривая</span>
|
||||
<span class="text-text-muted flex items-center gap-0.5 text-xs"
|
||||
>Линейный<Icon name="expand_more" class="size-3" /></span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
|
||||
disabled={!canEdit || !channelPoints(channel).length}
|
||||
onclick={resetCurve}
|
||||
>
|
||||
Сбросить кривую
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if busy}
|
||||
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
||||
{:else if dirty && !busy}
|
||||
<p class="text-text-subtle text-xs">
|
||||
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
|
||||
</p>
|
||||
{:else if rendering}
|
||||
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-danger text-xs">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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={!colorCollapsed}
|
||||
aria-controls="color-panel"
|
||||
onclick={() => (colorCollapsed = !colorCollapsed)}
|
||||
>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {colorCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
Цвет
|
||||
</button>
|
||||
<span class="text-text-muted" title="Показать цвет">
|
||||
<Icon name="visibility" class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !colorCollapsed}
|
||||
<div id="color-panel" class="mt-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-muted text-xs">Баланс белого</span>
|
||||
<select
|
||||
class="text-text-muted bg-transparent text-xs outline-none"
|
||||
value={wbPreset}
|
||||
disabled={!canEdit}
|
||||
onchange={(e) => applyWbPreset(e.currentTarget.value)}
|
||||
>
|
||||
{#each WB_PRESETS as preset (preset.value)}
|
||||
<option value={preset.value}>{preset.label}</option>
|
||||
{/each}
|
||||
<option value="custom">Пользовательский</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-text disabled:opacity-40 {eyedropper.mode === 'wb' &&
|
||||
eyedropper.photoId === photo.id
|
||||
? 'text-primary'
|
||||
: 'text-text-muted'}"
|
||||
title={viewer
|
||||
? 'Баланс белого по нейтральной точке'
|
||||
: 'Откройте фото в просмотрщике'}
|
||||
aria-pressed={eyedropper.mode === 'wb' && eyedropper.photoId === photo.id}
|
||||
disabled={!canEdit || !viewer}
|
||||
onclick={() => eyedropper.start('wb', photo.id)}
|
||||
>
|
||||
<Icon name="colorize" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{#if eyedropper.mode === 'wb' && eyedropper.photoId === photo.id}
|
||||
<p class="text-text-subtle text-xs">
|
||||
Кликните по нейтральному участку на фото.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2.5">
|
||||
{#each COLOR_SLIDERS as slider (slider.key)}
|
||||
<EditSlider
|
||||
id="color-{slider.key}"
|
||||
label={slider.label}
|
||||
value={params.color[slider.key]}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
disabled={!canEdit}
|
||||
gradient={colorGradient(slider.key)}
|
||||
format={formatValue}
|
||||
oninput={(value) => onColorSliderInput(slider.key, value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
|
||||
aria-expanded={!mixerCollapsed}
|
||||
aria-controls="color-mixer"
|
||||
onclick={() => (mixerCollapsed = !mixerCollapsed)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="tune" class="size-4" />
|
||||
Цветовой микшер
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {mixerCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !mixerCollapsed}
|
||||
<div id="color-mixer" class="mt-2">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
{#each MIXER_BANDS as band (band.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 rounded-full border-2 disabled:opacity-40"
|
||||
style="background: {bandColor(band.center)}; border-color: {mixerBand ===
|
||||
band.key
|
||||
? '#ffffff'
|
||||
: 'transparent'}"
|
||||
aria-label={band.label}
|
||||
title={band.label}
|
||||
aria-pressed={mixerBand === band.key}
|
||||
disabled={!canEdit}
|
||||
onclick={() => (mixerBand = band.key)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<Icon name="tune" class="text-text-muted size-4" />
|
||||
{#each MIXER_COMPONENTS as component (component.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-xs {mixerTab === component.key
|
||||
? 'bg-bg-muted text-text'
|
||||
: 'text-text-muted hover:text-text'}"
|
||||
aria-pressed={mixerTab === component.key}
|
||||
disabled={!canEdit}
|
||||
onclick={() => (mixerTab = component.key)}
|
||||
>
|
||||
{component.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 space-y-2.5">
|
||||
<EditSlider
|
||||
id="mixer-hue"
|
||||
label="Тон {MIXER_GENITIVE[mixerBand]}"
|
||||
value={params.color.mixer[mixerBand].hue}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
gradient={hueGradient(selectedBand.center)}
|
||||
format={formatValue}
|
||||
oninput={(value) => onMixerInput(mixerBand, 'hue', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="mixer-saturation"
|
||||
label="Насыщенность {MIXER_GENITIVE[mixerBand]}"
|
||||
value={params.color.mixer[mixerBand].saturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
gradient={saturationGradient(selectedBand.center)}
|
||||
format={formatValue}
|
||||
oninput={(value) => onMixerInput(mixerBand, 'saturation', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="mixer-luminance"
|
||||
label="Яркость {MIXER_GENITIVE[mixerBand]}"
|
||||
value={params.color.mixer[mixerBand].luminance}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
gradient={luminanceGradient(selectedBand.center)}
|
||||
format={formatValue}
|
||||
oninput={(value) => onMixerInput(mixerBand, 'luminance', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
|
||||
aria-expanded={!pointColorCollapsed}
|
||||
aria-controls="color-point"
|
||||
onclick={() => (pointColorCollapsed = !pointColorCollapsed)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="colorize" class="size-4" />
|
||||
Точечный цвет
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {pointColorCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !pointColorCollapsed}
|
||||
<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" />
|
||||
<span>Выберите цвет на фото пипеткой.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
|
||||
aria-expanded={!correctionCollapsed}
|
||||
aria-controls="color-correction"
|
||||
onclick={() => (correctionCollapsed = !correctionCollapsed)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="blur_on" class="size-4" />
|
||||
Цветокоррекция
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {correctionCollapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !correctionCollapsed}
|
||||
<div id="color-correction" class="mt-2 space-y-4">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each CORRECTION_VIEWS as view (view.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 disabled:opacity-40 {correctionView === view.key
|
||||
? 'bg-bg-muted text-text'
|
||||
: 'text-text-muted hover:text-text'}"
|
||||
aria-label={view.label}
|
||||
title={view.label}
|
||||
aria-pressed={correctionView === view.key}
|
||||
disabled={!canEdit}
|
||||
onclick={() => (correctionView = view.key)}
|
||||
>
|
||||
<Icon name={view.icon} class="size-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-3 {correctionWheels.length === 1
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-2'}"
|
||||
>
|
||||
{#each correctionWheels as wheel (wheel)}
|
||||
<div
|
||||
class="flex justify-center {wheel === 'midtones' &&
|
||||
correctionView === 'all'
|
||||
? 'col-span-2'
|
||||
: ''}"
|
||||
>
|
||||
<div class="w-full max-w-[88px]">
|
||||
<ColorWheel
|
||||
hue={params.color.grading[wheel].hue}
|
||||
saturation={params.color.grading[wheel].saturation}
|
||||
label={GRADING_WHEELS.find((item) => item.key === wheel)?.label}
|
||||
disabled={!canEdit}
|
||||
onchange={(hue, saturation) => onWheelInput(wheel, hue, saturation)}
|
||||
oncommit={onColorSliderCommit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<EditSlider
|
||||
id="correction-shadows"
|
||||
label="Яркость теней"
|
||||
value={params.color.grading.shadows.luminance}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onWheelLuminanceInput('shadows', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="correction-midtones"
|
||||
label="Яркость средних тонов"
|
||||
value={params.color.grading.midtones.luminance}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onWheelLuminanceInput('midtones', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="correction-highlights"
|
||||
label="Яркость светов"
|
||||
value={params.color.grading.highlights.luminance}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onWheelLuminanceInput('highlights', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="grading-blending"
|
||||
label="Наложение"
|
||||
value={params.color.grading.blending}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
resetValue={50}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onGradingInput('blending', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
<EditSlider
|
||||
id="grading-balance"
|
||||
label="Баланс"
|
||||
value={params.color.grading.balance}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onGradingInput('balance', value)}
|
||||
onchange={onColorSliderCommit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if busy}
|
||||
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
||||
{:else if dirty && !busy}
|
||||
<p class="text-text-subtle text-xs">
|
||||
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
|
||||
</p>
|
||||
{:else if rendering}
|
||||
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-danger text-xs">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -255,7 +255,12 @@ const tabClass =
|
||||
|
||||
{#if tab === 'edit'}
|
||||
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
|
||||
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
|
||||
<PhotoEditPanel
|
||||
{photo}
|
||||
canEdit={canEditPhoto}
|
||||
viewer={viewer}
|
||||
onUpdated={(updated) => onRenamed?.(updated)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import { viewer } from '$lib/stores/viewer.svelte';
|
||||
import { edit } from '$lib/stores/edit.svelte';
|
||||
import { applyLight, initLightWasm } from '$lib/light-wasm';
|
||||
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
|
||||
import { WebGLPreview } from '$lib/webgl-preview';
|
||||
import {
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
isEmptyLight,
|
||||
photoFullUrl,
|
||||
photoPreviewUrl,
|
||||
readEditParams
|
||||
@@ -30,10 +34,15 @@
|
||||
|
||||
let fresh = $state<Photo | null>(null);
|
||||
let touchX = 0;
|
||||
let previewLow = $state(false);
|
||||
let useGL = $state(false);
|
||||
|
||||
const current = $derived(photos[index]);
|
||||
|
||||
const MAX_ZOOM = 2;
|
||||
// В зуме фильтруем уменьшенную копию вьюпорта: предпросмотр остаётся
|
||||
// отзывчивым, а финальное качество даёт серверный рендер.
|
||||
const PREVIEW_MAX = 560;
|
||||
// Шаг зума клавишами `+`/`-`.
|
||||
const ZOOM_STEP = 1.25;
|
||||
|
||||
@@ -97,7 +106,15 @@
|
||||
|
||||
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
||||
const clientSource = $derived(
|
||||
zoomed ? originalSrc || basePreviewSrc : basePreviewSrc
|
||||
useGL
|
||||
? zoomed
|
||||
? originalSrc || basePreviewSrc
|
||||
: basePreviewSrc
|
||||
: previewLow
|
||||
? basePreviewSrc
|
||||
: zoomed
|
||||
? originalSrc || basePreviewSrc
|
||||
: basePreviewSrc
|
||||
);
|
||||
const displaySrc = $derived(
|
||||
clientPreview
|
||||
@@ -152,8 +169,9 @@
|
||||
});
|
||||
|
||||
// In zoom mode, preload the full-resolution original and swap it in once loaded.
|
||||
// Во время правок full-res не грузим: превью рисуется из base preview.
|
||||
$effect(() => {
|
||||
if (!zoomed || hiResLoaded) return;
|
||||
if (!zoomed || hiResLoaded || clientPreview) return;
|
||||
const url = fullSrc;
|
||||
if (!url) return;
|
||||
const im = new Image();
|
||||
@@ -179,62 +197,134 @@
|
||||
// Пока сервер не отрендерил отредактированную версию, видимую область
|
||||
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
|
||||
let canvas: HTMLCanvasElement | undefined = $state();
|
||||
let glCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let glPreview: WebGLPreview | null = null;
|
||||
let viewportFrame: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const el = glCanvas;
|
||||
if (!el) return;
|
||||
const preview = WebGLPreview.create(el);
|
||||
glPreview = preview;
|
||||
useGL = !!preview;
|
||||
return () => {
|
||||
preview?.dispose();
|
||||
glPreview = null;
|
||||
useGL = false;
|
||||
};
|
||||
});
|
||||
|
||||
// Пока пользователь активно крутит контролы, фильтруем уменьшенный буфер,
|
||||
// а после короткой паузы — полный кадр. Иначе тяжёлый фильтр блокирует UI.
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function markInteracting() {
|
||||
previewLow = true;
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null;
|
||||
previewLow = false;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
$effect(() => () => {
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Рисует видимую область фото с применёнными правками. Работает и в зуме,
|
||||
* и в обычном виде: берём ту часть картинки, что попала во вьюпорт.
|
||||
* Рисует видимую область фото с применёнными правками. Сначала пробует
|
||||
* WebGL (кадр считает GPU), затем CPU-фолбэк.
|
||||
*/
|
||||
function renderViewport() {
|
||||
const c = canvas;
|
||||
const img = imgEl;
|
||||
const el = scroller;
|
||||
if (!c || !img || !el) return;
|
||||
if (!img || !el) return;
|
||||
const natural = img.naturalWidth;
|
||||
if (!natural) return;
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const viewRect = el.getBoundingClientRect();
|
||||
if (!imgRect.width || !viewRect.width) return;
|
||||
const ratio = imgRect.width / natural;
|
||||
|
||||
// Видимая часть картинки.
|
||||
const left = Math.max(imgRect.left, viewRect.left);
|
||||
const top = Math.max(imgRect.top, viewRect.top);
|
||||
const right = Math.min(imgRect.right, viewRect.right);
|
||||
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
|
||||
const visibleW = right - left;
|
||||
const visibleH = bottom - top;
|
||||
if (visibleW <= 0 || visibleH <= 0) return;
|
||||
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
if (!width || !height) return;
|
||||
if (c.width !== width || c.height !== height) {
|
||||
c.width = width;
|
||||
c.height = height;
|
||||
}
|
||||
c.style.width = `${width}px`;
|
||||
c.style.height = `${height}px`;
|
||||
|
||||
// Кусок изображения и его место на холсте.
|
||||
let crop: { x: number; y: number; width: number; height: number };
|
||||
let dest: { x: number; y: number; width: number; height: number };
|
||||
if (!zoomed) {
|
||||
crop = { x: 0, y: 0, width: natural, height: img.naturalHeight };
|
||||
dest = {
|
||||
x: imgRect.left - viewRect.left,
|
||||
y: imgRect.top - viewRect.top,
|
||||
width: imgRect.width,
|
||||
height: imgRect.height
|
||||
};
|
||||
} else {
|
||||
const ratio = imgRect.width / natural;
|
||||
const left = Math.max(imgRect.left, viewRect.left);
|
||||
const top = Math.max(imgRect.top, viewRect.top);
|
||||
const right = Math.min(imgRect.right, viewRect.right);
|
||||
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
|
||||
const visibleW = right - left;
|
||||
const visibleH = bottom - top;
|
||||
if (visibleW <= 0 || visibleH <= 0) return;
|
||||
crop = {
|
||||
x: (left - imgRect.left) / ratio,
|
||||
y: (top - imgRect.top) / ratio,
|
||||
width: visibleW / ratio,
|
||||
height: visibleH / ratio
|
||||
};
|
||||
dest = {
|
||||
x: left - viewRect.left,
|
||||
y: top - viewRect.top,
|
||||
width: visibleW,
|
||||
height: visibleH
|
||||
};
|
||||
}
|
||||
|
||||
// GPU-путь: фильтр целиком на видеокарте, main thread свободен.
|
||||
if (useGL && glPreview) {
|
||||
const ok = glPreview.render({
|
||||
image: img,
|
||||
imageVersion: `${img.currentSrc || img.src}|${natural}|${img.naturalHeight}`,
|
||||
imageWidth: natural,
|
||||
imageHeight: img.naturalHeight,
|
||||
crop,
|
||||
viewport: { width, height },
|
||||
dest,
|
||||
light: liveParams.light,
|
||||
color: liveParams.color
|
||||
});
|
||||
if (ok) return;
|
||||
useGL = false;
|
||||
}
|
||||
|
||||
// CPU-фолбэк (если WebGL недоступен).
|
||||
const c = canvas;
|
||||
if (!c) return;
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Исходный кусок в пикселях картинки и его место на холсте.
|
||||
const sx = (left - imgRect.left) / ratio;
|
||||
const sy = (top - imgRect.top) / ratio;
|
||||
const sw = visibleW / ratio;
|
||||
const sh = visibleH / ratio;
|
||||
const dx = Math.round(left - viewRect.left);
|
||||
const dy = Math.round(top - viewRect.top);
|
||||
const dw = Math.round(visibleW);
|
||||
const dh = Math.round(visibleH);
|
||||
|
||||
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||
|
||||
// Фильтруем весь холст: так прошлый кадр не смешивается с новым.
|
||||
const frame = ctx.getImageData(0, 0, width, height);
|
||||
applyLight(frame.data, liveParams.light);
|
||||
const lowPx = isEmptyLight(liveParams.light) ? 900 : PREVIEW_MAX;
|
||||
const maxPx = previewLow ? lowPx : 4096;
|
||||
const k = Math.min(1, maxPx / Math.max(dest.width, dest.height));
|
||||
const bw = Math.max(1, Math.round(dest.width * k));
|
||||
const bh = Math.max(1, Math.round(dest.height * k));
|
||||
if (c.width !== bw || c.height !== bh) {
|
||||
c.width = bw;
|
||||
c.height = bh;
|
||||
}
|
||||
c.style.left = `${Math.round(dest.x)}px`;
|
||||
c.style.top = `${Math.round(dest.y)}px`;
|
||||
c.style.width = `${Math.round(dest.width)}px`;
|
||||
c.style.height = `${Math.round(dest.height)}px`;
|
||||
ctx.clearRect(0, 0, bw, bh);
|
||||
ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, bw, bh);
|
||||
const frame = ctx.getImageData(0, 0, bw, bh);
|
||||
if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light);
|
||||
if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color);
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
}
|
||||
|
||||
@@ -252,10 +342,22 @@
|
||||
zoomed;
|
||||
scale;
|
||||
liveParams.light;
|
||||
liveParams.color;
|
||||
displaySrc;
|
||||
previewLow;
|
||||
useGL;
|
||||
scheduleViewport();
|
||||
});
|
||||
|
||||
// Любое изменение параметров — это активное взаимодействие.
|
||||
let lastLiveParams: typeof liveParams | null = null;
|
||||
$effect(() => {
|
||||
const p = liveParams;
|
||||
if (p === lastLiveParams) return;
|
||||
lastLiveParams = p;
|
||||
markInteracting();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const onFs = () => {
|
||||
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||||
@@ -441,6 +543,7 @@
|
||||
if (!current || !scroller) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
markInteracting();
|
||||
|
||||
const natW = baseWidth();
|
||||
const natH = baseHeight();
|
||||
@@ -456,6 +559,7 @@
|
||||
}
|
||||
|
||||
function onPointerDown(ev: PointerEvent) {
|
||||
if (eyedropper.mode !== 'none') return;
|
||||
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
|
||||
dragging = true;
|
||||
startX = ev.clientX;
|
||||
@@ -485,6 +589,46 @@
|
||||
scheduleViewport();
|
||||
}
|
||||
|
||||
/** Пипетка: сэмплирует пиксель базового изображения под курсором. */
|
||||
async function onImageClick(ev: MouseEvent) {
|
||||
const photo = current;
|
||||
if (!photo || !imgEl || eyedropper.mode === 'none') return;
|
||||
const rect = imgEl.getBoundingClientRect();
|
||||
const x = (ev.clientX - rect.left) / rect.width;
|
||||
const y = (ev.clientY - rect.top) / rect.height;
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const src = (zoomed ? originalSrc : basePreviewSrc) || basePreviewSrc;
|
||||
if (!src) return;
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('image load failed'));
|
||||
img.src = src;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const px = Math.min(
|
||||
img.naturalWidth - 1,
|
||||
Math.max(0, Math.round(x * (img.naturalWidth - 1)))
|
||||
);
|
||||
const py = Math.min(
|
||||
img.naturalHeight - 1,
|
||||
Math.max(0, Math.round(y * (img.naturalHeight - 1)))
|
||||
);
|
||||
const data = ctx.getImageData(px, py, 1, 1).data;
|
||||
eyedropper.report(photo.id, data[0], data[1], data[2]);
|
||||
} catch {
|
||||
/* изображение не загрузилось — тихо выходим */
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleViewerFullscreen() {
|
||||
if (!viewerEl) return;
|
||||
try {
|
||||
@@ -732,7 +876,10 @@
|
||||
oncontextmenu={onContextMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
onscroll={scheduleViewport}
|
||||
onscroll={() => {
|
||||
markInteracting();
|
||||
scheduleViewport();
|
||||
}}
|
||||
>
|
||||
{#if current}
|
||||
<img
|
||||
@@ -748,12 +895,26 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if eyedropper.mode !== 'none' && eyedropper.photoId === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 z-20 cursor-crosshair"
|
||||
aria-label="Выберите нейтральную точку на фото"
|
||||
onclick={onImageClick}
|
||||
></button>
|
||||
{/if}
|
||||
{#if clientPreview}
|
||||
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
||||
сервер готовит отредактированную версию фото. -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||||
class:hidden={useGL}
|
||||
></canvas>
|
||||
<canvas
|
||||
bind:this={glCanvas}
|
||||
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||||
class:hidden={!useGL}
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
emptyColor,
|
||||
emptyEditParams,
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
normalizeEditParams,
|
||||
readEditParams
|
||||
readEditParams,
|
||||
whiteBalanceFromRGB
|
||||
} from './edit';
|
||||
import type { Photo } from './types';
|
||||
|
||||
@@ -56,3 +60,125 @@ describe('параметры правок', () => {
|
||||
expect(points[1].x).toBeCloseTo(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('цветовые параметры', () => {
|
||||
it('зажимает значения и оттенки колёс', () => {
|
||||
const params = normalizeEditParams({
|
||||
light: emptyEditParams().light,
|
||||
color: {
|
||||
...emptyColor(),
|
||||
temperature: 200,
|
||||
tint: -300,
|
||||
saturation: -500,
|
||||
grading: {
|
||||
...emptyColor().grading,
|
||||
shadows: { hue: 400, saturation: 150, luminance: 0 },
|
||||
balance: -200
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(params.color.temperature).toBe(100);
|
||||
expect(params.color.tint).toBe(-100);
|
||||
expect(params.color.saturation).toBe(-100);
|
||||
expect(params.color.grading.shadows.hue).toBe(360);
|
||||
expect(params.color.grading.shadows.saturation).toBe(100);
|
||||
expect(params.color.grading.balance).toBe(-100);
|
||||
});
|
||||
|
||||
it('считает пустыми нулевые цветовые параметры', () => {
|
||||
expect(isEmptyColor(emptyColor())).toBe(true);
|
||||
expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false);
|
||||
const hueOnly = emptyColor();
|
||||
hueOnly.grading.shadows.hue = 120;
|
||||
expect(isEmptyColor(hueOnly)).toBe(true);
|
||||
const grade = emptyColor();
|
||||
grade.grading.highlights.saturation = 10;
|
||||
expect(isEmptyColor(grade)).toBe(false);
|
||||
});
|
||||
|
||||
it('читает цвет из метаданных и не теряет свет', () => {
|
||||
const photo = {
|
||||
edit_params: {
|
||||
light: { exposure: 0.5 },
|
||||
color: { temperature: 20, vibrance: 10 }
|
||||
}
|
||||
} as unknown as Photo;
|
||||
const params = readEditParams(photo);
|
||||
expect(params.light.exposure).toBe(0.5);
|
||||
expect(params.color.temperature).toBe(20);
|
||||
expect(params.color.vibrance).toBe(10);
|
||||
});
|
||||
|
||||
it('осветляет тёплым балансом белого', () => {
|
||||
const pixels = new Uint8ClampedArray([100, 100, 100, 255]);
|
||||
applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 });
|
||||
expect(pixels[0]).toBeGreaterThan(pixels[1]);
|
||||
expect(pixels[1]).toBeGreaterThan(pixels[2]);
|
||||
});
|
||||
|
||||
it('обеспечивает серый цвет при насыщенности -100', () => {
|
||||
const pixels = new Uint8ClampedArray([200, 50, 50, 255]);
|
||||
applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 });
|
||||
expect(pixels[0]).toBe(pixels[1]);
|
||||
expect(pixels[1]).toBe(pixels[2]);
|
||||
});
|
||||
|
||||
it('тонирует тени синим колесом', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 };
|
||||
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||
applyColorToPixels(pixels, color);
|
||||
expect(pixels[2]).toBeGreaterThan(pixels[0]);
|
||||
});
|
||||
|
||||
it('повышает яркость теней', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 };
|
||||
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||
applyColorToPixels(pixels, color);
|
||||
expect(pixels[0]).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('одинаково обрабатывает все строки однородного изображения', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 };
|
||||
const pixels = new Uint8ClampedArray(64 * 64 * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
pixels[i] = 40;
|
||||
pixels[i + 1] = 40;
|
||||
pixels[i + 2] = 40;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
applyColorToPixels(pixels, color);
|
||||
for (let i = 4; i < pixels.length; i += 4) {
|
||||
expect(pixels[i]).toBe(pixels[0]);
|
||||
expect(pixels[i + 1]).toBe(pixels[1]);
|
||||
expect(pixels[i + 2]).toBe(pixels[2]);
|
||||
}
|
||||
});
|
||||
|
||||
it('не трогает прозрачные пиксели', () => {
|
||||
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||
applyColorToPixels(pixels, {
|
||||
...emptyColor(),
|
||||
temperature: 100,
|
||||
saturation: 100
|
||||
});
|
||||
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
|
||||
});
|
||||
|
||||
it('подбирает баланс белого по нейтральной точке', () => {
|
||||
const neutral = whiteBalanceFromRGB(128, 128, 128);
|
||||
expect(neutral.temperature).toBeCloseTo(0, 5);
|
||||
expect(neutral.tint).toBeCloseTo(0, 5);
|
||||
|
||||
const wb = whiteBalanceFromRGB(150, 140, 120);
|
||||
const temperature = wb.temperature / 100;
|
||||
const tint = wb.tint / 100;
|
||||
const r = 150 * (1 + 0.2 * temperature + 0.1 * tint);
|
||||
const g = 140 * (1 - 0.2 * tint);
|
||||
const b = 120 * (1 - 0.2 * temperature + 0.1 * tint);
|
||||
expect(r).toBeCloseTo(g, 5);
|
||||
expect(b).toBeCloseTo(g, 5);
|
||||
});
|
||||
});
|
||||
|
||||
+413
-12
@@ -30,10 +30,91 @@ export interface LightParams {
|
||||
curve: CurveParams;
|
||||
}
|
||||
|
||||
export interface MixerBand {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
}
|
||||
|
||||
export interface ColorMixer {
|
||||
red: MixerBand;
|
||||
orange: MixerBand;
|
||||
yellow: MixerBand;
|
||||
green: MixerBand;
|
||||
aqua: MixerBand;
|
||||
blue: MixerBand;
|
||||
purple: MixerBand;
|
||||
magenta: MixerBand;
|
||||
}
|
||||
|
||||
export interface ColorWheel {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
}
|
||||
|
||||
export interface ColorGrading {
|
||||
shadows: ColorWheel;
|
||||
midtones: ColorWheel;
|
||||
highlights: ColorWheel;
|
||||
blending: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface ColorCorrection {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
export interface ColorParams {
|
||||
temperature: number;
|
||||
tint: number;
|
||||
vibrance: number;
|
||||
saturation: number;
|
||||
mixer: ColorMixer;
|
||||
grading: ColorGrading;
|
||||
correction: ColorCorrection;
|
||||
}
|
||||
|
||||
export interface EditParams {
|
||||
light: LightParams;
|
||||
color: ColorParams;
|
||||
}
|
||||
|
||||
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
|
||||
export const MIXER_BANDS = [
|
||||
{ key: 'red', label: 'Красное', center: 0 },
|
||||
{ key: 'orange', label: 'Оранжевое', center: 30 },
|
||||
{ key: 'yellow', label: 'Жёлтое', center: 60 },
|
||||
{ key: 'green', label: 'Зелёное', center: 120 },
|
||||
{ key: 'aqua', label: 'Бирюзовое', center: 180 },
|
||||
{ key: 'blue', label: 'Синее', center: 240 },
|
||||
{ key: 'purple', label: 'Фиолетовое', center: 280 },
|
||||
{ key: 'magenta', label: 'Пурпурное', center: 320 }
|
||||
] as const;
|
||||
|
||||
export type MixerBandKey = (typeof MIXER_BANDS)[number]['key'];
|
||||
|
||||
/** Ползунки базового блока «Цвет». */
|
||||
export const COLOR_SLIDERS = [
|
||||
{ key: 'temperature', label: 'Темп.', min: -100, max: 100, step: 1 },
|
||||
{ key: 'tint', label: 'Оттенок', min: -100, max: 100, step: 1 },
|
||||
{ key: 'vibrance', label: 'Сочность', min: -100, max: 100, step: 1 },
|
||||
{ key: 'saturation', label: 'Насыщенность', min: -100, max: 100, step: 1 }
|
||||
] as const;
|
||||
|
||||
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key'];
|
||||
|
||||
/** Колёса точечного цвета. */
|
||||
export const GRADING_WHEELS = [
|
||||
{ key: 'shadows', label: 'Тени' },
|
||||
{ key: 'midtones', label: 'Средние тона' },
|
||||
{ key: 'highlights', label: 'Света' }
|
||||
] as const;
|
||||
|
||||
export type GradingWheelKey = (typeof GRADING_WHEELS)[number]['key'];
|
||||
|
||||
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
|
||||
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
|
||||
|
||||
@@ -86,11 +167,42 @@ export function emptyLight(): LightParams {
|
||||
}
|
||||
|
||||
export function emptyEditParams(): EditParams {
|
||||
return { light: emptyLight() };
|
||||
return { light: emptyLight(), color: emptyColor() };
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
function emptyMixerBand(): MixerBand {
|
||||
return { hue: 0, saturation: 0, luminance: 0 };
|
||||
}
|
||||
|
||||
export function emptyColor(): ColorParams {
|
||||
return {
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
vibrance: 0,
|
||||
saturation: 0,
|
||||
mixer: {
|
||||
red: emptyMixerBand(),
|
||||
orange: emptyMixerBand(),
|
||||
yellow: emptyMixerBand(),
|
||||
green: emptyMixerBand(),
|
||||
aqua: emptyMixerBand(),
|
||||
blue: emptyMixerBand(),
|
||||
purple: emptyMixerBand(),
|
||||
magenta: emptyMixerBand()
|
||||
},
|
||||
grading: {
|
||||
shadows: { hue: 0, saturation: 0, luminance: 0 },
|
||||
midtones: { hue: 0, saturation: 0, luminance: 0 },
|
||||
highlights: { hue: 0, saturation: 0, luminance: 0 },
|
||||
blending: 50,
|
||||
balance: 0
|
||||
},
|
||||
correction: { red: 0, green: 0, blue: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number | undefined, min: number, max: number): number {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
@@ -113,7 +225,10 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
|
||||
}
|
||||
|
||||
/** Приводит параметры к допустимым диапазонам. */
|
||||
export function normalizeEditParams(params: EditParams): EditParams {
|
||||
export function normalizeEditParams(params: {
|
||||
light?: Partial<LightParams>;
|
||||
color?: Partial<ColorParams>;
|
||||
}): EditParams {
|
||||
const light = params?.light ?? emptyLight();
|
||||
return {
|
||||
light: {
|
||||
@@ -129,13 +244,64 @@ export function normalizeEditParams(params: EditParams): EditParams {
|
||||
green: normalizeCurve(light.curve?.green),
|
||||
blue: normalizeCurve(light.curve?.blue)
|
||||
}
|
||||
},
|
||||
color: normalizeColor(params?.color)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBand(band?: Partial<MixerBand>): MixerBand {
|
||||
return {
|
||||
hue: clamp(band?.hue ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
saturation: clamp(band?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
luminance: clamp(band?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWheel(wheel?: Partial<ColorWheel>): ColorWheel {
|
||||
return {
|
||||
hue: clamp(wheel?.hue ?? 0, 0, 360),
|
||||
saturation: clamp(wheel?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
luminance: clamp(wheel?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||
};
|
||||
}
|
||||
|
||||
/** Приводит цветовые параметры к допустимым диапазонам. */
|
||||
export function normalizeColor(color?: Partial<ColorParams>): ColorParams {
|
||||
const mixer: Partial<ColorMixer> = color?.mixer ?? {};
|
||||
const grading: Partial<ColorGrading> = color?.grading ?? {};
|
||||
const correction: Partial<ColorCorrection> = color?.correction ?? {};
|
||||
return {
|
||||
temperature: clamp(color?.temperature ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
tint: clamp(color?.tint ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
vibrance: clamp(color?.vibrance ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
saturation: clamp(color?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
mixer: {
|
||||
red: normalizeBand(mixer.red),
|
||||
orange: normalizeBand(mixer.orange),
|
||||
yellow: normalizeBand(mixer.yellow),
|
||||
green: normalizeBand(mixer.green),
|
||||
aqua: normalizeBand(mixer.aqua),
|
||||
blue: normalizeBand(mixer.blue),
|
||||
purple: normalizeBand(mixer.purple),
|
||||
magenta: normalizeBand(mixer.magenta)
|
||||
},
|
||||
grading: {
|
||||
shadows: normalizeWheel(grading.shadows),
|
||||
midtones: normalizeWheel(grading.midtones),
|
||||
highlights: normalizeWheel(grading.highlights),
|
||||
blending: clamp(grading.blending ?? 50, 0, MAX_PERCENT),
|
||||
balance: clamp(grading.balance ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||
},
|
||||
correction: {
|
||||
red: clamp(correction.red ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
green: clamp(correction.green ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
blue: clamp(correction.blue ?? 0, -MAX_PERCENT, MAX_PERCENT)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Пустые ли параметры: фотография не тронута. */
|
||||
export function isEmptyEditParams(params: EditParams): boolean {
|
||||
const light = params.light;
|
||||
/** Пустой ли блок «Свет». */
|
||||
export function isEmptyLight(light: LightParams): boolean {
|
||||
if (
|
||||
light.exposure !== 0 ||
|
||||
light.contrast !== 0 ||
|
||||
@@ -154,16 +320,76 @@ export function isEmptyEditParams(params: EditParams): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Цвет можно посчитать быстрым путём (без HSL): не задействованы микшер и
|
||||
* насыщенность/вибрация — только колёса, яркость зон и температура/оттенок.
|
||||
*/
|
||||
export function isColorFast(color: ColorParams): boolean {
|
||||
if (color.vibrance !== 0 || color.saturation !== 0) return false;
|
||||
for (const band of MIXER_BANDS) {
|
||||
const value = color.mixer[band.key];
|
||||
if (value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Пустые ли параметры: фотография не тронута. */
|
||||
export function isEmptyEditParams(params: EditParams): boolean {
|
||||
return isEmptyLight(params.light) && isEmptyColor(params.color);
|
||||
}
|
||||
|
||||
/** Пусты ли цветовые параметры: цвет не тронут. */
|
||||
export function isEmptyColor(color?: ColorParams): boolean {
|
||||
if (!color) return true;
|
||||
if (color.temperature || color.tint || color.vibrance || color.saturation) return false;
|
||||
for (const band of MIXER_BANDS) {
|
||||
const value = color.mixer[band.key];
|
||||
if (value?.hue || value?.saturation || value?.luminance) return false;
|
||||
}
|
||||
for (const wheel of GRADING_WHEELS) {
|
||||
const value = color.grading[wheel.key];
|
||||
// Оттенок колеса без силы или яркости не влияет на картинку.
|
||||
if (value?.saturation || value?.luminance) return false;
|
||||
}
|
||||
const correction = color.correction;
|
||||
return !correction?.red && !correction?.green && !correction?.blue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подбирает баланс белого, при котором нейтральный пиксель (r, g, b) станет
|
||||
* серым. Возвращает температуру и оттенок в диапазоне -100..100.
|
||||
*/
|
||||
export function whiteBalanceFromRGB(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): { temperature: number; tint: number } {
|
||||
const rr = Math.max(1, r);
|
||||
const gg = Math.max(1, g);
|
||||
const bb = Math.max(1, b);
|
||||
const sum = gg * (1 / rr + 1 / bb);
|
||||
const tint = (sum - 2) / (0.2 * (1 + sum));
|
||||
const factor = 1 - 0.2 * tint;
|
||||
const temperature = (gg * (1 / rr - 1 / bb) * factor) / 0.4;
|
||||
return {
|
||||
temperature: clamp(temperature * 100, -MAX_PERCENT, MAX_PERCENT),
|
||||
tint: clamp(tint * 100, -MAX_PERCENT, MAX_PERCENT)
|
||||
};
|
||||
}
|
||||
|
||||
/** Читает параметры правок из метаданных фото. */
|
||||
export function readEditParams(photo: Photo | null | undefined): EditParams {
|
||||
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined;
|
||||
if (!raw?.light) return emptyEditParams();
|
||||
const raw = photo?.edit_params as
|
||||
| { light?: Partial<LightParams>; color?: Partial<ColorParams> }
|
||||
| undefined;
|
||||
if (!raw?.light && !raw?.color) return emptyEditParams();
|
||||
return normalizeEditParams({
|
||||
light: {
|
||||
...emptyLight(),
|
||||
...raw.light,
|
||||
curve: raw.light.curve ?? {}
|
||||
}
|
||||
...raw?.light,
|
||||
curve: raw?.light?.curve ?? {}
|
||||
},
|
||||
color: raw?.color ?? emptyColor()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,3 +487,178 @@ export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams):
|
||||
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
|
||||
}
|
||||
}
|
||||
|
||||
/** RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1. */
|
||||
function rgbToHSL(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h: number;
|
||||
if (max === r) {
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
} else if (max === g) {
|
||||
h = (b - r) / d + 2;
|
||||
} else {
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
return [h * 60, s, l];
|
||||
}
|
||||
|
||||
function hueToRGB(p: number, q: number, t: number): number {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** HSL -> RGB (0..255). */
|
||||
export function hslToRGB(h: number, s: number, l: number): [number, number, number] {
|
||||
if (s === 0) {
|
||||
const v = l * 255;
|
||||
return [v, v, v];
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hk = h / 360;
|
||||
return [
|
||||
hueToRGB(p, q, hk + 1 / 3) * 255,
|
||||
hueToRGB(p, q, hk) * 255,
|
||||
hueToRGB(p, q, hk - 1 / 3) * 255
|
||||
];
|
||||
}
|
||||
|
||||
/** Треугольный вес оттенка вокруг центра полосы микшера. */
|
||||
function bandWeight(h: number, center: number): number {
|
||||
let d = Math.abs(h - center);
|
||||
if (d > 180) d = 360 - d;
|
||||
return clamp01(1 - d / 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет правки «Цвет» к пикселям (в том же массиве). Порядок операций
|
||||
* повторяет серверный рендер: баланс белого, HSL, насыщенность/вибрация,
|
||||
* микшер, точечный цвет, цветокоррекция.
|
||||
*/
|
||||
export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams): void {
|
||||
const temperature = color.temperature / 100;
|
||||
const tint = color.tint / 100;
|
||||
const vibrance = color.vibrance / 100;
|
||||
const saturation = color.saturation / 100;
|
||||
const blending = color.grading.blending / 100;
|
||||
const balance = color.grading.balance / 100;
|
||||
const correction = color.correction;
|
||||
const tints = GRADING_WHEELS.map((wheel) =>
|
||||
hslToRGB(color.grading[wheel.key].hue, 1, 0.5)
|
||||
);
|
||||
const blend = blending;
|
||||
const hasMixer = MIXER_BANDS.some((band) => {
|
||||
const value = color.mixer[band.key];
|
||||
return value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0;
|
||||
});
|
||||
|
||||
for (let i = 0; i + 3 < data.length; i += 4) {
|
||||
if (data[i + 3] === 0) continue;
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
// Баланс белого.
|
||||
r *= 1 + 0.2 * temperature + 0.1 * tint;
|
||||
g *= 1 - 0.2 * tint;
|
||||
b *= 1 - 0.2 * temperature + 0.1 * tint;
|
||||
r = clamp(r, 0, 255);
|
||||
g = clamp(g, 0, 255);
|
||||
b = clamp(b, 0, 255);
|
||||
|
||||
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
|
||||
// не нужен HSL-переход, lightness считается напрямую.
|
||||
const fast = !hasMixer && saturation === 0 && vibrance === 0;
|
||||
let weights: number[];
|
||||
let rr: number;
|
||||
let gg: number;
|
||||
let bb: number;
|
||||
|
||||
if (fast) {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 510;
|
||||
const lb = clamp01(l + balance * 0.5);
|
||||
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
|
||||
const highlightW = lb * lb * lb * lb;
|
||||
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
|
||||
let targetL = l;
|
||||
GRADING_WHEELS.forEach((wheel, index) => {
|
||||
targetL +=
|
||||
((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index];
|
||||
});
|
||||
targetL = clamp01(targetL);
|
||||
const delta = (targetL - l) * 255;
|
||||
rr = r + delta;
|
||||
gg = g + delta;
|
||||
bb = b + delta;
|
||||
} else {
|
||||
// RGB -> HSL.
|
||||
let [h, s, l] = rgbToHSL(r, g, b);
|
||||
|
||||
// Насыщенность и вибрация.
|
||||
if (saturation !== 0 || vibrance !== 0) {
|
||||
s = clamp01(s * (1 + saturation + vibrance * (1 - s)));
|
||||
}
|
||||
|
||||
// Цветовой микшер.
|
||||
if (hasMixer) {
|
||||
for (const band of MIXER_BANDS) {
|
||||
const value = color.mixer[band.key];
|
||||
const w = bandWeight(h, band.center);
|
||||
if (w === 0) continue;
|
||||
h += (value.hue / 100) * 30 * w;
|
||||
s = clamp01(s * (1 + (value.saturation / 100) * w));
|
||||
l = clamp01(l + (value.luminance / 100) * 0.2 * w);
|
||||
}
|
||||
}
|
||||
h = ((h % 360) + 360) % 360;
|
||||
|
||||
// Зоны яркости: веса в сумме дают 1, более узкие у теней и светов.
|
||||
const lb = clamp01(l + balance * 0.5);
|
||||
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
|
||||
const highlightW = lb * lb * lb * lb;
|
||||
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
|
||||
GRADING_WHEELS.forEach((wheel, index) => {
|
||||
l = clamp01(
|
||||
l + ((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index]
|
||||
);
|
||||
});
|
||||
|
||||
// HSL -> RGB.
|
||||
[rr, gg, bb] = hslToRGB(h, s, l);
|
||||
}
|
||||
|
||||
// Точечный цвет: три колеса по зонам яркости.
|
||||
GRADING_WHEELS.forEach((wheel, index) => {
|
||||
const strength =
|
||||
(color.grading[wheel.key].saturation / 100) * 0.5 * blend * weights[index];
|
||||
if (strength === 0) return;
|
||||
const tintRGB = tints[index];
|
||||
rr = rr * (1 - strength) + tintRGB[0] * strength;
|
||||
gg = gg * (1 - strength) + tintRGB[1] * strength;
|
||||
bb = bb * (1 - strength) + tintRGB[2] * strength;
|
||||
});
|
||||
|
||||
// Цветокоррекция.
|
||||
rr *= 1 + (correction.red / 100) * 0.5;
|
||||
gg *= 1 + (correction.green / 100) * 0.5;
|
||||
bb *= 1 + (correction.blue / 100) * 0.5;
|
||||
|
||||
data[i] = Math.round(clamp(rr, 0, 255));
|
||||
data[i + 1] = Math.round(clamp(gg, 0, 255));
|
||||
data[i + 2] = Math.round(clamp(bb, 0, 255));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,13 @@ export const icons = {
|
||||
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
|
||||
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
|
||||
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
|
||||
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' }
|
||||
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
|
||||
visibility: { path: '<path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5m0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10m0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>' },
|
||||
colorize: { path: '<path fill="currentColor" d="M17.66 5.41l.92.92-2.69 2.69-.92-.92zM17.67 3c-.26 0-.51.1-.71.29l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42l-2.34-2.34c-.2-.19-.45-.29-.7-.29M6.92 19L5 17.08l8.06-8.06 1.92 1.92z"/>' },
|
||||
tune: { path: '<path fill="currentColor" d="M3 17v2h6v-2zm0-12v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"/>' },
|
||||
blur_on: { path: '<path fill="currentColor" d="M12 3a5 5 0 0 1 0 10 5 5 0 0 1 0-10m-5 6a5 5 0 1 1 0 10 5 5 0 0 1 0-10m10 0a5 5 0 1 1 0 10 5 5 0 0 1 0-10"/>' },
|
||||
circle: { path: '<path fill="currentColor" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16"/>' },
|
||||
show_chart: { path: '<path fill="currentColor" d="M3 3v18h18v-2H5V3zm16.7 3.3l-1.4-1.4-6.3 6.3-3-3L5 15v-2.8l3.6-3.6 3 3z"/>' }
|
||||
} satisfies Record<string, IconDef>;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
applyLightToPixels,
|
||||
buildCurveLUT,
|
||||
emptyColor,
|
||||
emptyEditParams,
|
||||
GRADING_WHEELS,
|
||||
MIXER_BANDS,
|
||||
type ColorParams,
|
||||
type LightParams
|
||||
} from './edit';
|
||||
|
||||
interface LightExports {
|
||||
memory: WebAssembly.Memory;
|
||||
@@ -15,11 +25,14 @@ interface LightExports {
|
||||
whites: number,
|
||||
blacks: number
|
||||
): void;
|
||||
apply_color(data: number, len: number, params: number): void;
|
||||
}
|
||||
|
||||
let wasm: LightExports;
|
||||
|
||||
const LUTS_OFFSET = 0;
|
||||
const COLOR_PARAMS_OFFSET = 2048;
|
||||
const COLOR_PARAM_COUNT = 42;
|
||||
const PIXELS_OFFSET = 4096;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -61,6 +74,44 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
|
||||
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
|
||||
}
|
||||
|
||||
/** Применяет цветовые правки модулем WASM к копии пикселей. */
|
||||
function applyColorWasm(pixels: Uint8ClampedArray, color: ColorParams): Uint8ClampedArray {
|
||||
const mem = wasm.memory;
|
||||
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
|
||||
const have = mem.buffer.byteLength / 65536;
|
||||
if (need > have) mem.grow(need - have);
|
||||
|
||||
const params = new Float64Array(COLOR_PARAM_COUNT);
|
||||
params[0] = color.temperature / 100;
|
||||
params[1] = color.tint / 100;
|
||||
params[2] = color.vibrance / 100;
|
||||
params[3] = color.saturation / 100;
|
||||
MIXER_BANDS.forEach((band, index) => {
|
||||
const value = color.mixer[band.key];
|
||||
const base = 4 + index * 3;
|
||||
params[base] = value.hue / 100;
|
||||
params[base + 1] = value.saturation / 100;
|
||||
params[base + 2] = value.luminance / 100;
|
||||
});
|
||||
GRADING_WHEELS.forEach((wheel, index) => {
|
||||
const value = color.grading[wheel.key];
|
||||
const base = 28 + index * 2;
|
||||
params[base] = value.hue;
|
||||
params[base + 1] = value.saturation / 100;
|
||||
params[34 + index] = (value.luminance ?? 0) / 100;
|
||||
});
|
||||
params[37] = color.grading.blending / 100;
|
||||
params[38] = color.grading.balance / 100;
|
||||
params[39] = color.correction.red / 100;
|
||||
params[40] = color.correction.green / 100;
|
||||
params[41] = color.correction.blue / 100;
|
||||
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
|
||||
|
||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
|
||||
wasm.apply_color(PIXELS_OFFSET, pixels.length, COLOR_PARAMS_OFFSET);
|
||||
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
|
||||
}
|
||||
|
||||
describe('WASM-модуль фильтров', () => {
|
||||
it('совпадает с JS-реализацией', () => {
|
||||
const light: LightParams = {
|
||||
@@ -98,4 +149,164 @@ describe('WASM-модуль фильтров', () => {
|
||||
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
|
||||
expect(out[0]).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('совпадает с JS на большом градиенте (свет)', () => {
|
||||
const light: LightParams = {
|
||||
...emptyEditParams().light,
|
||||
exposure: 0.4,
|
||||
contrast: 20,
|
||||
highlights: -20,
|
||||
shadows: 30
|
||||
};
|
||||
const width = 256;
|
||||
const height = 256;
|
||||
const source = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
source[i] = 20 + (y % 200);
|
||||
source[i + 1] = 30 + (x % 150);
|
||||
source[i + 2] = 40 + ((x + y) % 120);
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyLightToPixels(expected, light);
|
||||
const actual = applyWasm(source.slice(), light);
|
||||
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it('совпадает с JS-реализацией цвета', () => {
|
||||
const color = emptyColor();
|
||||
color.temperature = 30;
|
||||
color.tint = -10;
|
||||
color.vibrance = 20;
|
||||
color.saturation = -15;
|
||||
color.mixer.red = { hue: 20, saturation: 30, luminance: -10 };
|
||||
color.mixer.blue = { hue: -15, saturation: -20, luminance: 10 };
|
||||
color.grading.shadows = { hue: 240, saturation: 40, luminance: 10 };
|
||||
color.grading.highlights = { hue: 40, saturation: 20, luminance: -10 };
|
||||
color.grading.blending = 30;
|
||||
color.grading.balance = -20;
|
||||
color.correction = { red: 10, green: -5, blue: 8 };
|
||||
|
||||
const source = new Uint8ClampedArray(4 * 64);
|
||||
for (let i = 0; i < source.length; i += 4) {
|
||||
source[i] = (i * 7) % 256;
|
||||
source[i + 1] = (i * 13) % 256;
|
||||
source[i + 2] = (i * 29) % 256;
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyColorToPixels(expected, color);
|
||||
const actual = applyColorWasm(source.slice(), color);
|
||||
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it('не меняет прозрачные пиксели при цветовых правках', () => {
|
||||
const color = emptyColor();
|
||||
color.saturation = 100;
|
||||
color.temperature = 100;
|
||||
const out = applyColorWasm(new Uint8ClampedArray([10, 20, 30, 0]), color);
|
||||
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||
});
|
||||
|
||||
it('совпадает с JS на большом градиенте', () => {
|
||||
const color = emptyColor();
|
||||
color.temperature = 25;
|
||||
color.vibrance = 15;
|
||||
color.mixer.blue = { hue: -10, saturation: 20, luminance: 10 };
|
||||
color.grading.shadows = { hue: 240, saturation: 80, luminance: 10 };
|
||||
color.grading.highlights = { hue: 40, saturation: 30, luminance: -10 };
|
||||
|
||||
const width = 256;
|
||||
const height = 256;
|
||||
const source = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
source[i] = 20 + (y % 200);
|
||||
source[i + 1] = 30 + (x % 150);
|
||||
source[i + 2] = 40 + ((x + y) % 120);
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyColorToPixels(expected, color);
|
||||
const actual = applyColorWasm(source.slice(), color);
|
||||
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it('совпадает с JS на быстром пути (только колёса)', () => {
|
||||
const color = emptyColor();
|
||||
color.temperature = 20;
|
||||
color.tint = -10;
|
||||
color.grading.blending = 50;
|
||||
color.grading.balance = -20;
|
||||
color.grading.shadows = { hue: 240, saturation: 70, luminance: 25 };
|
||||
color.grading.midtones = { hue: 40, saturation: 20, luminance: -15 };
|
||||
color.grading.highlights = { hue: 200, saturation: 30, luminance: 10 };
|
||||
|
||||
const width = 128;
|
||||
const height = 128;
|
||||
const source = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
source[i] = 10 + (y % 220);
|
||||
source[i + 1] = 20 + (x % 180);
|
||||
source[i + 2] = 30 + ((x + y) % 160);
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyColorToPixels(expected, color);
|
||||
const actual = applyColorWasm(source.slice(), color);
|
||||
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it('совпадает с JS на случайных цветовых параметрах', () => {
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const color = emptyColor();
|
||||
color.temperature = Math.random() * 200 - 100;
|
||||
color.tint = Math.random() * 200 - 100;
|
||||
color.vibrance = Math.random() * 200 - 100;
|
||||
color.saturation = Math.random() * 200 - 100;
|
||||
color.grading.blending = Math.random() * 100;
|
||||
color.grading.balance = Math.random() * 200 - 100;
|
||||
color.grading.shadows = {
|
||||
hue: Math.random() * 360,
|
||||
saturation: Math.random() * 200 - 100,
|
||||
luminance: Math.random() * 200 - 100
|
||||
};
|
||||
color.grading.midtones = {
|
||||
hue: Math.random() * 360,
|
||||
saturation: Math.random() * 200 - 100,
|
||||
luminance: Math.random() * 200 - 100
|
||||
};
|
||||
color.grading.highlights = {
|
||||
hue: Math.random() * 360,
|
||||
saturation: Math.random() * 200 - 100,
|
||||
luminance: Math.random() * 200 - 100
|
||||
};
|
||||
|
||||
const source = new Uint8ClampedArray(64);
|
||||
for (let i = 0; i < source.length; i += 4) {
|
||||
source[i] = (i * 7) % 256;
|
||||
source[i + 1] = (i * 13) % 256;
|
||||
source[i + 2] = (i * 29) % 256;
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyColorToPixels(expected, color);
|
||||
const actual = applyColorWasm(source.slice(), color);
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (expected[i] !== actual[i]) {
|
||||
throw new Error(
|
||||
`mismatch n=${n} i=${i} expected=${expected[i]} actual=${actual[i]} color=${JSON.stringify(color)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
applyLightToPixels,
|
||||
buildCurveLUT,
|
||||
GRADING_WHEELS,
|
||||
MIXER_BANDS,
|
||||
type ColorParams,
|
||||
type LightParams
|
||||
} from './edit';
|
||||
|
||||
/**
|
||||
* WASM-модуль фильтров «Свет».
|
||||
* WASM-модуль фильтров «Свет» и «Цвет».
|
||||
*
|
||||
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
||||
* Он попиксельно применяет правки — это горячий путь предпросмотра в
|
||||
@@ -10,6 +18,8 @@ import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
|
||||
*/
|
||||
|
||||
const LUTS_OFFSET = 0;
|
||||
const COLOR_PARAMS_OFFSET = 2048;
|
||||
const COLOR_PARAM_COUNT = 42;
|
||||
const PIXELS_OFFSET = 4096;
|
||||
const PAGE = 65536;
|
||||
const LUT_LEN = 256;
|
||||
@@ -27,6 +37,7 @@ interface LightExports {
|
||||
whites: number,
|
||||
blacks: number
|
||||
): void;
|
||||
apply_color(data: number, len: number, params: number): void;
|
||||
}
|
||||
|
||||
let exportsRef: LightExports | null = null;
|
||||
@@ -141,3 +152,75 @@ export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
|
||||
applyLightToPixels(data, light);
|
||||
}
|
||||
|
||||
/** Пишет f64-блок цветовых параметров в память модуля. */
|
||||
function writeColorParams(mem: WebAssembly.Memory, color: ColorParams): void {
|
||||
const params = new Float64Array(COLOR_PARAM_COUNT);
|
||||
params[0] = color.temperature / 100;
|
||||
params[1] = color.tint / 100;
|
||||
params[2] = color.vibrance / 100;
|
||||
params[3] = color.saturation / 100;
|
||||
MIXER_BANDS.forEach((band, index) => {
|
||||
const value = color.mixer[band.key];
|
||||
const base = 4 + index * 3;
|
||||
params[base] = value.hue / 100;
|
||||
params[base + 1] = value.saturation / 100;
|
||||
params[base + 2] = value.luminance / 100;
|
||||
});
|
||||
GRADING_WHEELS.forEach((wheel, index) => {
|
||||
const value = color.grading[wheel.key];
|
||||
const base = 28 + index * 2;
|
||||
params[base] = value.hue;
|
||||
params[base + 1] = value.saturation / 100;
|
||||
params[34 + index] = (value.luminance ?? 0) / 100;
|
||||
});
|
||||
params[37] = color.grading.blending / 100;
|
||||
params[38] = color.grading.balance / 100;
|
||||
params[39] = color.correction.red / 100;
|
||||
params[40] = color.correction.green / 100;
|
||||
params[41] = color.correction.blue / 100;
|
||||
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет цветовые правки к пикселям. Возвращает false, если модуль не
|
||||
* готов и вызывающий код должен использовать JS-реализацию.
|
||||
*/
|
||||
export function applyColorWasm(data: Uint8ClampedArray, color: ColorParams): boolean {
|
||||
const ex = exportsRef;
|
||||
if (!ex) return false;
|
||||
const bytes = PIXELS_OFFSET + data.length;
|
||||
const mem = ensureMemory(bytes);
|
||||
if (!mem) return false;
|
||||
|
||||
writeColorParams(mem, color);
|
||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||
ex.apply_color(PIXELS_OFFSET, data.length, COLOR_PARAMS_OFFSET);
|
||||
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Сверяет цветовой модуль с эталонной JS-реализацией на образце. */
|
||||
function colorWasmMatchesReference(color: ColorParams): boolean {
|
||||
const probe = new Uint8ClampedArray(16 * 4);
|
||||
for (let i = 0; i < probe.length; i += 4) {
|
||||
probe[i] = (i * 7) % 256;
|
||||
probe[i + 1] = (i * 13) % 256;
|
||||
probe[i + 2] = (i * 29) % 256;
|
||||
probe[i + 3] = 255;
|
||||
}
|
||||
const expected = new Uint8ClampedArray(probe);
|
||||
applyColorToPixels(expected, color);
|
||||
const actual = new Uint8ClampedArray(probe);
|
||||
if (!applyColorWasm(actual, color)) return false;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (expected[i] !== actual[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Применяет цвет: сначала WASM, при его недоступности — JS. */
|
||||
export function applyColor(data: Uint8ClampedArray, color: ColorParams): void {
|
||||
if (colorWasmMatchesReference(color) && applyColorWasm(data, color)) return;
|
||||
applyColorToPixels(data, color);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Режим пипетки: панель редактирования включает его, а просмотрщик сообщает
|
||||
* цвет пикселя, по которому кликнули.
|
||||
*/
|
||||
export type EyedropperMode = 'none' | 'wb';
|
||||
|
||||
export interface EyedropperSample {
|
||||
photoId: string;
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
class EyedropperStore {
|
||||
mode = $state<EyedropperMode>('none');
|
||||
photoId = $state('');
|
||||
sample = $state<EyedropperSample | null>(null);
|
||||
private seq = 0;
|
||||
|
||||
start(mode: EyedropperMode, photoId: string) {
|
||||
this.mode = mode;
|
||||
this.photoId = photoId;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mode = 'none';
|
||||
this.photoId = '';
|
||||
}
|
||||
|
||||
report(photoId: string, r: number, g: number, b: number) {
|
||||
if (this.mode === 'none' || this.photoId !== photoId) return;
|
||||
this.seq += 1;
|
||||
this.sample = { photoId, r, g, b, seq: this.seq };
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const eyedropper = new EyedropperStore();
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
buildCurveLUT,
|
||||
GRADING_WHEELS,
|
||||
hslToRGB,
|
||||
MIXER_BANDS,
|
||||
type ColorParams,
|
||||
type LightParams
|
||||
} from './edit';
|
||||
|
||||
/**
|
||||
* GPU-превью правок на WebGL2.
|
||||
*
|
||||
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст,
|
||||
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL,
|
||||
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра,
|
||||
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс.
|
||||
*/
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 a_pos;
|
||||
uniform vec2 u_uvOffset;
|
||||
uniform vec2 u_uvScale;
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
vec2 uv = a_pos * 0.5 + 0.5;
|
||||
v_uv = u_uvOffset + uv * u_uvScale;
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 outColor;
|
||||
|
||||
uniform sampler2D u_image;
|
||||
uniform sampler2D u_luts;
|
||||
|
||||
uniform float u_exposureMul;
|
||||
uniform float u_contrastK;
|
||||
uniform float u_highlights;
|
||||
uniform float u_shadows;
|
||||
uniform float u_whites;
|
||||
uniform float u_blacks;
|
||||
|
||||
uniform float u_temperature;
|
||||
uniform float u_tint;
|
||||
uniform float u_vibrance;
|
||||
uniform float u_saturation;
|
||||
uniform float u_blending;
|
||||
uniform float u_balance;
|
||||
uniform vec3 u_correction;
|
||||
|
||||
uniform float u_mixerHue[8];
|
||||
uniform float u_mixerSat[8];
|
||||
uniform float u_mixerLum[8];
|
||||
uniform float u_gradeSat[3];
|
||||
uniform float u_gradeLum[3];
|
||||
uniform vec3 u_gradeTint[3];
|
||||
|
||||
const float CENTERS[8] = float[8](0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0);
|
||||
|
||||
vec4 lutAt(float v) {
|
||||
float idx = clamp(floor(v + 0.5), 0.0, 255.0);
|
||||
return texture(u_luts, vec2((idx + 0.5) / 256.0, 0.5));
|
||||
}
|
||||
|
||||
float lightChannel(float v, int ch) {
|
||||
v *= u_exposureMul;
|
||||
v = 128.0 + (v - 128.0) * u_contrastK;
|
||||
float w = clamp((v - 128.0) / 127.0, 0.0, 1.0);
|
||||
v += u_highlights * w * w;
|
||||
w = clamp((128.0 - v) / 128.0, 0.0, 1.0);
|
||||
v += u_shadows * w * w;
|
||||
w = clamp((v - 192.0) / 63.0, 0.0, 1.0);
|
||||
v += u_whites * w;
|
||||
w = clamp((64.0 - v) / 64.0, 0.0, 1.0);
|
||||
v += u_blacks * w;
|
||||
vec4 l1 = lutAt(v);
|
||||
float master = l1.r * 255.0;
|
||||
vec4 l2 = lutAt(master);
|
||||
float value = ch == 0 ? l2.g : (ch == 1 ? l2.b : l2.a);
|
||||
return value * 255.0;
|
||||
}
|
||||
|
||||
vec3 rgb2hsl(vec3 c) {
|
||||
float maxc = max(c.r, max(c.g, c.b));
|
||||
float minc = min(c.r, min(c.g, c.b));
|
||||
float l = (maxc + minc) * 0.5;
|
||||
if (maxc == minc) return vec3(0.0, 0.0, l);
|
||||
float d = maxc - minc;
|
||||
float s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
|
||||
float h;
|
||||
if (maxc == c.r) {
|
||||
h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
} else if (maxc == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
return vec3(h * 60.0, s, l);
|
||||
}
|
||||
|
||||
float hue2rgb(float p, float q, float t) {
|
||||
if (t < 0.0) t += 1.0;
|
||||
if (t > 1.0) t -= 1.0;
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 1.0 / 2.0) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
vec3 hsl2rgb(vec3 hsl) {
|
||||
float h = hsl.x / 360.0;
|
||||
float s = hsl.y;
|
||||
float l = hsl.z;
|
||||
if (s == 0.0) return vec3(l);
|
||||
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||
float p = 2.0 * l - q;
|
||||
return vec3(
|
||||
hue2rgb(p, q, h + 1.0 / 3.0),
|
||||
hue2rgb(p, q, h),
|
||||
hue2rgb(p, q, h - 1.0 / 3.0)
|
||||
);
|
||||
}
|
||||
|
||||
float bandWeight(float h, float center) {
|
||||
float d = abs(h - center);
|
||||
if (d > 180.0) d = 360.0 - d;
|
||||
return clamp(1.0 - d / 60.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 tex = texture(u_image, v_uv);
|
||||
if (tex.a == 0.0) {
|
||||
outColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 c = tex.rgb * 255.0;
|
||||
c.r = lightChannel(c.r, 0);
|
||||
c.g = lightChannel(c.g, 1);
|
||||
c.b = lightChannel(c.b, 2);
|
||||
|
||||
// Баланс белого.
|
||||
c.r *= 1.0 + 0.2 * u_temperature + 0.1 * u_tint;
|
||||
c.g *= 1.0 - 0.2 * u_tint;
|
||||
c.b *= 1.0 - 0.2 * u_temperature + 0.1 * u_tint;
|
||||
c = clamp(c, 0.0, 255.0);
|
||||
|
||||
vec3 hsl = rgb2hsl(c / 255.0);
|
||||
float h = hsl.x;
|
||||
float s = hsl.y;
|
||||
float l = hsl.z;
|
||||
|
||||
if (u_saturation != 0.0 || u_vibrance != 0.0) {
|
||||
s = clamp(s * (1.0 + u_saturation + u_vibrance * (1.0 - s)), 0.0, 1.0);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
float w = bandWeight(h, CENTERS[i]);
|
||||
if (w > 0.0) {
|
||||
h += u_mixerHue[i] * 30.0 * w;
|
||||
s = clamp(s * (1.0 + u_mixerSat[i] * w), 0.0, 1.0);
|
||||
l = clamp(l + u_mixerLum[i] * 0.2 * w, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
h = mod(h, 360.0);
|
||||
if (h < 0.0) h += 360.0;
|
||||
|
||||
float lb = clamp(l + u_balance * 0.5, 0.0, 1.0);
|
||||
float sw = (1.0 - lb) * (1.0 - lb);
|
||||
sw *= sw;
|
||||
float hw = lb * lb;
|
||||
hw *= hw;
|
||||
float weights[3] = float[3](sw, 1.0 - sw - hw, hw);
|
||||
|
||||
l = clamp(
|
||||
l +
|
||||
u_gradeLum[0] * 0.2 * weights[0] +
|
||||
u_gradeLum[1] * 0.2 * weights[1] +
|
||||
u_gradeLum[2] * 0.2 * weights[2],
|
||||
0.0, 1.0);
|
||||
|
||||
vec3 outc = hsl2rgb(vec3(h, s, l)) * 255.0;
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float strength = u_gradeSat[i] * 0.5 * u_blending * weights[i];
|
||||
if (strength != 0.0) {
|
||||
outc = outc * (1.0 - strength) + u_gradeTint[i] * strength;
|
||||
}
|
||||
}
|
||||
|
||||
outc *= 1.0 + u_correction * 0.5;
|
||||
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a);
|
||||
}`;
|
||||
|
||||
export interface PreviewRenderOptions {
|
||||
image: TexImageSource;
|
||||
imageVersion: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
crop: { x: number; y: number; width: number; height: number };
|
||||
viewport: { width: number; height: number };
|
||||
dest: { x: number; y: number; width: number; height: number };
|
||||
light: LightParams;
|
||||
color: ColorParams;
|
||||
}
|
||||
|
||||
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error('webgl preview shader:', gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function buildLuts(light: LightParams): Uint8Array {
|
||||
const master = buildCurveLUT(light.curve?.rgb);
|
||||
const red = buildCurveLUT(light.curve?.red);
|
||||
const green = buildCurveLUT(light.curve?.green);
|
||||
const blue = buildCurveLUT(light.curve?.blue);
|
||||
const out = new Uint8Array(256 * 4);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
out[i * 4] = master[i];
|
||||
out[i * 4 + 1] = red[i];
|
||||
out[i * 4 + 2] = green[i];
|
||||
out[i * 4 + 3] = blue[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class WebGLPreview {
|
||||
private readonly gl: WebGL2RenderingContext;
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly program: WebGLProgram;
|
||||
private readonly vao: WebGLVertexArrayObject;
|
||||
private readonly buffer: WebGLBuffer;
|
||||
private readonly imageTex: WebGLTexture;
|
||||
private readonly lutTex: WebGLTexture;
|
||||
private readonly u: Record<string, WebGLUniformLocation | null>;
|
||||
private imageVersion = '';
|
||||
|
||||
private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
|
||||
this.canvas = canvas;
|
||||
this.gl = gl;
|
||||
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
||||
if (!vs || !fs) throw new Error('shader compile failed');
|
||||
const program = gl.createProgram();
|
||||
if (!program) throw new Error('program create failed');
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(program);
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(`program link failed: ${log ?? ''}`);
|
||||
}
|
||||
this.program = program;
|
||||
|
||||
const vao = gl.createVertexArray();
|
||||
const buffer = gl.createBuffer();
|
||||
if (!vao || !buffer) throw new Error('buffer create failed');
|
||||
gl.bindVertexArray(vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.STATIC_DRAW
|
||||
);
|
||||
const aPos = gl.getAttribLocation(program, 'a_pos');
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
this.vao = vao;
|
||||
this.buffer = buffer;
|
||||
|
||||
const imageTex = gl.createTexture();
|
||||
const lutTex = gl.createTexture();
|
||||
if (!imageTex || !lutTex) throw new Error('texture create failed');
|
||||
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.bindTexture(gl.TEXTURE_2D, lutTex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
this.imageTex = imageTex;
|
||||
this.lutTex = lutTex;
|
||||
|
||||
const loc = (name: string) => gl.getUniformLocation(program, name);
|
||||
this.u = {
|
||||
u_image: loc('u_image'),
|
||||
u_luts: loc('u_luts'),
|
||||
u_uvOffset: loc('u_uvOffset'),
|
||||
u_uvScale: loc('u_uvScale'),
|
||||
u_exposureMul: loc('u_exposureMul'),
|
||||
u_contrastK: loc('u_contrastK'),
|
||||
u_highlights: loc('u_highlights'),
|
||||
u_shadows: loc('u_shadows'),
|
||||
u_whites: loc('u_whites'),
|
||||
u_blacks: loc('u_blacks'),
|
||||
u_temperature: loc('u_temperature'),
|
||||
u_tint: loc('u_tint'),
|
||||
u_vibrance: loc('u_vibrance'),
|
||||
u_saturation: loc('u_saturation'),
|
||||
u_blending: loc('u_blending'),
|
||||
u_balance: loc('u_balance'),
|
||||
u_correction: loc('u_correction'),
|
||||
u_mixerHue: loc('u_mixerHue[0]'),
|
||||
u_mixerSat: loc('u_mixerSat[0]'),
|
||||
u_mixerLum: loc('u_mixerLum[0]'),
|
||||
u_gradeSat: loc('u_gradeSat[0]'),
|
||||
u_gradeLum: loc('u_gradeLum[0]'),
|
||||
u_gradeTint: loc('u_gradeTint[0]')
|
||||
};
|
||||
}
|
||||
|
||||
static create(canvas: HTMLCanvasElement): WebGLPreview | null {
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
preserveDrawingBuffer: false
|
||||
});
|
||||
if (!gl) return null;
|
||||
try {
|
||||
return new WebGLPreview(canvas, gl);
|
||||
} catch (err) {
|
||||
console.error('webgl preview init:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
const gl = this.gl;
|
||||
gl.deleteTexture(this.imageTex);
|
||||
gl.deleteTexture(this.lutTex);
|
||||
gl.deleteBuffer(this.buffer);
|
||||
gl.deleteVertexArray(this.vao);
|
||||
gl.deleteProgram(this.program);
|
||||
}
|
||||
|
||||
render(opts: PreviewRenderOptions): boolean {
|
||||
const gl = this.gl;
|
||||
const { width, height } = opts.viewport;
|
||||
if (!width || !height) return false;
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
this.canvas.style.width = `${width}px`;
|
||||
this.canvas.style.height = `${height}px`;
|
||||
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.useProgram(this.program);
|
||||
|
||||
if (this.imageVersion !== opts.imageVersion) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, opts.image);
|
||||
this.imageVersion = opts.imageVersion;
|
||||
}
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
|
||||
gl.uniform1i(this.u.u_image, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
256,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
buildLuts(opts.light)
|
||||
);
|
||||
gl.uniform1i(this.u.u_luts, 1);
|
||||
|
||||
const light = opts.light;
|
||||
gl.uniform1f(this.u.u_exposureMul, Math.pow(2, light.exposure));
|
||||
gl.uniform1f(this.u.u_contrastK, 1 + light.contrast / 100);
|
||||
gl.uniform1f(this.u.u_highlights, (light.highlights / 100) * 80);
|
||||
gl.uniform1f(this.u.u_shadows, (light.shadows / 100) * 80);
|
||||
gl.uniform1f(this.u.u_whites, (light.whites / 100) * 80);
|
||||
gl.uniform1f(this.u.u_blacks, (light.blacks / 100) * 80);
|
||||
|
||||
const color = opts.color;
|
||||
gl.uniform1f(this.u.u_temperature, color.temperature / 100);
|
||||
gl.uniform1f(this.u.u_tint, color.tint / 100);
|
||||
gl.uniform1f(this.u.u_vibrance, color.vibrance / 100);
|
||||
gl.uniform1f(this.u.u_saturation, color.saturation / 100);
|
||||
gl.uniform1f(this.u.u_blending, color.grading.blending / 100);
|
||||
gl.uniform1f(this.u.u_balance, color.grading.balance / 100);
|
||||
gl.uniform3f(
|
||||
this.u.u_correction,
|
||||
color.correction.red / 100,
|
||||
color.correction.green / 100,
|
||||
color.correction.blue / 100
|
||||
);
|
||||
|
||||
const hue = new Float32Array(8);
|
||||
const sat = new Float32Array(8);
|
||||
const lum = new Float32Array(8);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const band = color.mixer[MIXER_BANDS[i].key];
|
||||
hue[i] = band.hue / 100;
|
||||
sat[i] = band.saturation / 100;
|
||||
lum[i] = band.luminance / 100;
|
||||
}
|
||||
gl.uniform1fv(this.u.u_mixerHue, hue);
|
||||
gl.uniform1fv(this.u.u_mixerSat, sat);
|
||||
gl.uniform1fv(this.u.u_mixerLum, lum);
|
||||
|
||||
const gradeSat = new Float32Array(3);
|
||||
const gradeLum = new Float32Array(3);
|
||||
const gradeTint = new Float32Array(9);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const wheel = color.grading[GRADING_WHEELS[i].key];
|
||||
gradeSat[i] = wheel.saturation / 100;
|
||||
gradeLum[i] = wheel.luminance / 100;
|
||||
const [r, g, b] = hslToRGB(wheel.hue, 1, 0.5);
|
||||
gradeTint[i * 3] = r;
|
||||
gradeTint[i * 3 + 1] = g;
|
||||
gradeTint[i * 3 + 2] = b;
|
||||
}
|
||||
gl.uniform1fv(this.u.u_gradeSat, gradeSat);
|
||||
gl.uniform1fv(this.u.u_gradeLum, gradeLum);
|
||||
gl.uniform3fv(this.u.u_gradeTint, gradeTint);
|
||||
|
||||
const u0 = opts.crop.x / opts.imageWidth;
|
||||
const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth;
|
||||
const vTop = opts.crop.y / opts.imageHeight;
|
||||
const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight;
|
||||
gl.uniform2f(this.u.u_uvOffset, u0, 1 - vBottom);
|
||||
gl.uniform2f(this.u.u_uvScale, u1 - u0, vBottom - vTop);
|
||||
|
||||
gl.viewport(
|
||||
Math.round(opts.dest.x),
|
||||
Math.round(height - opts.dest.y - opts.dest.height),
|
||||
Math.max(1, Math.round(opts.dest.width)),
|
||||
Math.max(1, Math.round(opts.dest.height))
|
||||
);
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
gl.bindVertexArray(null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -108,3 +108,272 @@ fn adjust(
|
||||
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
||||
channel[idx]
|
||||
}
|
||||
|
||||
/// Количество f64-параметров цвета.
|
||||
const COLOR_PARAM_COUNT: usize = 42;
|
||||
|
||||
/// Центры полос цветового микшера в градусах.
|
||||
const BAND_CENTERS: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0];
|
||||
|
||||
#[inline(always)]
|
||||
fn clampf(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
if v < lo {
|
||||
lo
|
||||
} else if v > hi {
|
||||
hi
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn clamp01d(v: f64) -> f64 {
|
||||
clampf(v, 0.0, 1.0)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn to_u8(v: f64) -> u8 {
|
||||
(clampf(v, 0.0, 255.0) + 0.5) as u8
|
||||
}
|
||||
|
||||
/// RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1.
|
||||
fn rgb_to_hsl(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
let r = r / 255.0;
|
||||
let g = g / 255.0;
|
||||
let b = b / 255.0;
|
||||
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
||||
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 {
|
||||
d / (2.0 - max - min)
|
||||
} else {
|
||||
d / (max + min)
|
||||
};
|
||||
let mut h;
|
||||
if max == r {
|
||||
h = (g - b) / d;
|
||||
if g < b {
|
||||
h += 6.0;
|
||||
}
|
||||
} else if max == g {
|
||||
h = (b - r) / d + 2.0;
|
||||
} else {
|
||||
h = (r - g) / d + 4.0;
|
||||
}
|
||||
(h * 60.0, s, l)
|
||||
}
|
||||
|
||||
fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
|
||||
if t < 0.0 {
|
||||
t += 1.0;
|
||||
}
|
||||
if t > 1.0 {
|
||||
t -= 1.0;
|
||||
}
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 1.0 / 2.0 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// HSL -> RGB (0..255).
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
if s == 0.0 {
|
||||
let v = l * 255.0;
|
||||
return (v, v, v);
|
||||
}
|
||||
let q = if l < 0.5 {
|
||||
l * (1.0 + s)
|
||||
} else {
|
||||
l + s - l * s
|
||||
};
|
||||
let p = 2.0 * l - q;
|
||||
let hk = h / 360.0;
|
||||
(
|
||||
hue_to_rgb(p, q, hk + 1.0 / 3.0) * 255.0,
|
||||
hue_to_rgb(p, q, hk) * 255.0,
|
||||
hue_to_rgb(p, q, hk - 1.0 / 3.0) * 255.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn band_weight(h: f64, center: f64) -> f64 {
|
||||
let mut d = h - center;
|
||||
if d < 0.0 {
|
||||
d = -d;
|
||||
}
|
||||
if d > 180.0 {
|
||||
d = 360.0 - d;
|
||||
}
|
||||
clamp01d(1.0 - d / 60.0)
|
||||
}
|
||||
|
||||
/// Применяет правки «Цвет» к пикселям RGBA в памяти.
|
||||
///
|
||||
/// `params` — блок из `COLOR_PARAM_COUNT` значений f64, подготовленный
|
||||
/// вызывающим кодом. Порядок операций повторяет серверный рендер и JS.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn apply_color(data: *mut u8, len: u32, params: *const f64) {
|
||||
let p = unsafe { core::slice::from_raw_parts(params, COLOR_PARAM_COUNT) };
|
||||
let temperature = p[0];
|
||||
let tint = p[1];
|
||||
let vibrance = p[2];
|
||||
let saturation = p[3];
|
||||
let blending = p[37];
|
||||
let balance = p[38];
|
||||
let blend = blending;
|
||||
|
||||
// Микшер часто пуст (правят только колёса) — тогда не тратим на него время.
|
||||
let mut has_mixer = false;
|
||||
let mut band = 0usize;
|
||||
while band < 8 {
|
||||
let base = 4 + band * 3;
|
||||
if p[base] != 0.0 || p[base + 1] != 0.0 || p[base + 2] != 0.0 {
|
||||
has_mixer = true;
|
||||
break;
|
||||
}
|
||||
band += 1;
|
||||
}
|
||||
|
||||
let mut tints = [(0.0f64, 0.0f64, 0.0f64); 3];
|
||||
let mut w = 0usize;
|
||||
while w < 3 {
|
||||
tints[w] = hsl_to_rgb(p[28 + w * 2], 1.0, 0.5);
|
||||
w += 1;
|
||||
}
|
||||
|
||||
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
||||
let mut i = 0usize;
|
||||
while i + 3 < buf.len() {
|
||||
let alpha = unsafe { *buf.get_unchecked(i + 3) };
|
||||
if alpha != 0 {
|
||||
let mut r = unsafe { *buf.get_unchecked(i) } as f64;
|
||||
let mut g = unsafe { *buf.get_unchecked(i + 1) } as f64;
|
||||
let mut b = unsafe { *buf.get_unchecked(i + 2) } as f64;
|
||||
|
||||
// Баланс белого.
|
||||
r *= 1.0 + 0.2 * temperature + 0.1 * tint;
|
||||
g *= 1.0 - 0.2 * tint;
|
||||
b *= 1.0 - 0.2 * temperature + 0.1 * tint;
|
||||
r = clampf(r, 0.0, 255.0);
|
||||
g = clampf(g, 0.0, 255.0);
|
||||
b = clampf(b, 0.0, 255.0);
|
||||
|
||||
// Быстрый путь: только колёса/яркость, без микшера и насыщенности.
|
||||
// Тогда HSL-переход не нужен — lightness считается напрямую.
|
||||
let fast = !has_mixer && saturation == 0.0 && vibrance == 0.0;
|
||||
let weights: [f64; 3];
|
||||
let mut rr: f64;
|
||||
let mut gg: f64;
|
||||
let mut bb: f64;
|
||||
|
||||
if fast {
|
||||
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
||||
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
||||
let l = (max + min) / 510.0;
|
||||
let lb = clamp01d(l + balance * 0.5);
|
||||
let shadow_w = {
|
||||
let t = (1.0 - lb) * (1.0 - lb);
|
||||
t * t
|
||||
};
|
||||
let highlight_w = {
|
||||
let t = lb * lb;
|
||||
t * t
|
||||
};
|
||||
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
||||
|
||||
let mut target_l = l;
|
||||
let mut wheel = 0usize;
|
||||
while wheel < 3 {
|
||||
target_l += p[34 + wheel] * 0.2 * weights[wheel];
|
||||
wheel += 1;
|
||||
}
|
||||
target_l = clamp01d(target_l);
|
||||
let delta = (target_l - l) * 255.0;
|
||||
rr = r + delta;
|
||||
gg = g + delta;
|
||||
bb = b + delta;
|
||||
} else {
|
||||
// RGB -> HSL.
|
||||
let (mut h, mut s, mut l) = rgb_to_hsl(r, g, b);
|
||||
|
||||
// Насыщенность и вибрация.
|
||||
if saturation != 0.0 || vibrance != 0.0 {
|
||||
s = clamp01d(s * (1.0 + saturation + vibrance * (1.0 - s)));
|
||||
}
|
||||
|
||||
// Цветовой микшер.
|
||||
if has_mixer {
|
||||
let mut band = 0usize;
|
||||
while band < 8 {
|
||||
let base = 4 + band * 3;
|
||||
let weight = band_weight(h, BAND_CENTERS[band]);
|
||||
if weight != 0.0 {
|
||||
h += p[base] * 30.0 * weight;
|
||||
s = clamp01d(s * (1.0 + p[base + 1] * weight));
|
||||
l = clamp01d(l + p[base + 2] * 0.2 * weight);
|
||||
}
|
||||
band += 1;
|
||||
}
|
||||
}
|
||||
h = h % 360.0;
|
||||
if h < 0.0 {
|
||||
h += 360.0;
|
||||
}
|
||||
|
||||
let lb = clamp01d(l + balance * 0.5);
|
||||
let shadow_w = {
|
||||
let t = (1.0 - lb) * (1.0 - lb);
|
||||
t * t
|
||||
};
|
||||
let highlight_w = {
|
||||
let t = lb * lb;
|
||||
t * t
|
||||
};
|
||||
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
||||
let mut wheel = 0usize;
|
||||
while wheel < 3 {
|
||||
l = clamp01d(l + p[34 + wheel] * 0.2 * weights[wheel]);
|
||||
wheel += 1;
|
||||
}
|
||||
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||
rr = r2;
|
||||
gg = g2;
|
||||
bb = b2;
|
||||
}
|
||||
|
||||
// Точечный цвет: три колеса по зонам яркости.
|
||||
let mut wheel = 0usize;
|
||||
while wheel < 3 {
|
||||
let strength = p[28 + wheel * 2 + 1] * 0.5 * blend * weights[wheel];
|
||||
if strength != 0.0 {
|
||||
let t = tints[wheel];
|
||||
rr = rr * (1.0 - strength) + t.0 * strength;
|
||||
gg = gg * (1.0 - strength) + t.1 * strength;
|
||||
bb = bb * (1.0 - strength) + t.2 * strength;
|
||||
}
|
||||
wheel += 1;
|
||||
}
|
||||
|
||||
// Цветокоррекция.
|
||||
rr *= 1.0 + p[39] * 0.5;
|
||||
gg *= 1.0 + p[40] * 0.5;
|
||||
bb *= 1.0 + p[41] * 0.5;
|
||||
|
||||
unsafe {
|
||||
*buf.get_unchecked_mut(i) = to_u8(rr);
|
||||
*buf.get_unchecked_mut(i + 1) = to_u8(gg);
|
||||
*buf.get_unchecked_mut(i + 2) = to_u8(bb);
|
||||
} }
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user