298 lines
8.2 KiB
Go
298 lines
8.2 KiB
Go
package art
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const MaxPaletteBytes = 128 << 10
|
|
|
|
type paletteFile struct {
|
|
Version int `json:"version"`
|
|
Colors []Swatch `json:"colors"`
|
|
}
|
|
|
|
func validatePalette(p []Swatch) error {
|
|
if len(p) < 1 || len(p) > 256 {
|
|
return fmt.Errorf("art: palette needs 1..256 colors")
|
|
}
|
|
if p[0].Color.A != 0 {
|
|
return fmt.Errorf("art: palette index zero must be transparent")
|
|
}
|
|
for _, v := range p {
|
|
if len(v.Name) > 256 || !utf8.ValidString(v.Name) {
|
|
return fmt.Errorf("art: invalid swatch name")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodePalette is the lossless RGBA palette interchange. GPL is intentionally
|
|
// not used for export because it cannot represent alpha or transparent indices.
|
|
func EncodePalette(p []Swatch) ([]byte, error) {
|
|
if err := validatePalette(p); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(paletteFile{Version: 1, Colors: p}, "", " ")
|
|
}
|
|
|
|
// DecodePalette accepts versioned JSON or a GIMP Palette. GPL's opaque colors
|
|
// are preceded by Aego's reserved transparent entry. No pixel algorithm lives
|
|
// in browser import code.
|
|
func DecodePalette(data []byte, format string) ([]Swatch, error) {
|
|
if len(data) == 0 || len(data) > MaxPaletteBytes {
|
|
return nil, ErrLimit
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return nil, fmt.Errorf("art: palette must be UTF-8")
|
|
}
|
|
var p []Swatch
|
|
switch format {
|
|
case "json":
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.DisallowUnknownFields()
|
|
var f paletteFile
|
|
if err := dec.Decode(&f); err != nil {
|
|
return nil, fmt.Errorf("art: invalid palette: %w", err)
|
|
}
|
|
if f.Version != 1 {
|
|
return nil, fmt.Errorf("art: unsupported palette version %d", f.Version)
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != io.EOF {
|
|
return nil, fmt.Errorf("art: trailing palette data")
|
|
}
|
|
p = f.Colors
|
|
case "gpl":
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
scanner.Buffer(make([]byte, 1024), MaxPaletteBytes)
|
|
if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "GIMP Palette" {
|
|
return nil, fmt.Errorf("art: missing GIMP Palette header")
|
|
}
|
|
p = []Swatch{{Name: "Transparent"}}
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "Columns:") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 {
|
|
return nil, fmt.Errorf("art: invalid GPL row")
|
|
}
|
|
var rgb [3]uint8
|
|
for i := range rgb {
|
|
v, err := strconv.ParseUint(fields[i], 10, 8)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("art: invalid GPL channel")
|
|
}
|
|
rgb[i] = uint8(v)
|
|
}
|
|
if len(p) == 256 {
|
|
return nil, ErrLimit
|
|
}
|
|
p = append(p, Swatch{Color: Color{rgb[0], rgb[1], rgb[2], 255}, Name: strings.Join(fields[3:], " ")})
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(p) == 1 {
|
|
return nil, fmt.Errorf("art: empty GPL palette")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("art: unsupported palette format %q", format)
|
|
}
|
|
if err := validatePalette(p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// AddPaletteEntry appends a swatch without remapping existing indexed pixels.
|
|
// Replacing an otherwise identical palette can coalesce duplicate colors, so
|
|
// that operation is not suitable for this seemingly simple edit.
|
|
func (e *Edit) AddPaletteEntry(value Swatch) error {
|
|
if err := e.check(); err != nil {
|
|
return err
|
|
}
|
|
next := append(append([]Swatch(nil), e.work.Palette...), value)
|
|
if err := validatePalette(next); err != nil {
|
|
return err
|
|
}
|
|
e.work.Palette = next
|
|
e.structural = true
|
|
return nil
|
|
}
|
|
|
|
// ReplacePalette preserves existing indexed colors by default, rejecting an
|
|
// insufficient replacement rather than silently recoloring art. Quantize is an
|
|
// explicit lossy operation; data channels are never quantized with albedo.
|
|
func (e *Edit) ReplacePalette(p []Swatch, quantize bool) error {
|
|
if err := e.check(); err != nil {
|
|
return err
|
|
}
|
|
if err := validatePalette(p); err != nil {
|
|
return err
|
|
}
|
|
p = append([]Swatch(nil), p...)
|
|
old := *e.work
|
|
target := old
|
|
target.Palette = p
|
|
if old.Canvas.Mode == Indexed && !quantize {
|
|
exact := map[Color]uint32{}
|
|
for i, v := range p {
|
|
if _, ok := exact[v.Color]; !ok {
|
|
exact[v.Color] = uint32(i)
|
|
}
|
|
}
|
|
used := map[uint32]bool{0: true}
|
|
for _, c := range e.work.Cels {
|
|
if c.Channel != Albedo {
|
|
continue
|
|
}
|
|
for key, block := range e.work.Images[c.Image].Tiles {
|
|
pixels := e.work.Images[c.Image]
|
|
for i, v := range block {
|
|
if key.X*TileSize+i%TileSize < pixels.Width && key.Y*TileSize+i/TileSize < pixels.Height {
|
|
used[v] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
remap := make([]uint32, len(old.Palette))
|
|
for v := range used {
|
|
if v == 0 {
|
|
continue
|
|
}
|
|
n, ok := exact[old.Palette[v].Color]
|
|
if !ok {
|
|
return fmt.Errorf("art: replacement omits used color %d; explicit quantization is required", v)
|
|
}
|
|
remap[v] = n
|
|
}
|
|
if err := e.mapPixels(func(v uint32) uint32 { return remap[v] }); err != nil {
|
|
return err
|
|
}
|
|
} else if quantize {
|
|
if err := e.mapPixels(func(v uint32) uint32 {
|
|
c := decode(&old, v)
|
|
if c.A == 0 {
|
|
return 0
|
|
}
|
|
idx := nearest(p, c, nil)
|
|
if old.Canvas.Mode == Indexed {
|
|
return idx
|
|
}
|
|
return encode(&target, p[idx].Color)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
e.work.Palette = p
|
|
e.structural = true
|
|
return nil
|
|
}
|
|
|
|
// ExtractPalette uses frequency-ranked exact colors, not a platform-dependent
|
|
// quantizer. Ties are sorted by RGBA value so native and WASM agree. The bounded
|
|
// histogram makes large true-color imports fail explicitly instead of growing
|
|
// memory without limit. The first result is always transparent.
|
|
func ExtractPalette(ctx context.Context, im *image.NRGBA, limit int) ([]Swatch, error) {
|
|
if limit < 2 || limit > 256 || im == nil {
|
|
return nil, fmt.Errorf("art: palette limit must be 2..256")
|
|
}
|
|
histogram := map[Color]int{}
|
|
for y := im.Bounds().Min.Y; y < im.Bounds().Max.Y; y++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for x := im.Bounds().Min.X; x < im.Bounds().Max.X; x++ {
|
|
v := im.NRGBAAt(x, y)
|
|
if v.A == 0 {
|
|
continue
|
|
}
|
|
c := Color{v.R, v.G, v.B, v.A}
|
|
if _, ok := histogram[c]; !ok && len(histogram) >= 65536 {
|
|
return nil, fmt.Errorf("art: palette extraction exceeds 65536 distinct colors")
|
|
}
|
|
histogram[c]++
|
|
}
|
|
}
|
|
colors := make([]Color, 0, len(histogram))
|
|
for c := range histogram {
|
|
colors = append(colors, c)
|
|
}
|
|
sort.Slice(colors, func(i, j int) bool {
|
|
a, b := colors[i], colors[j]
|
|
if histogram[a] != histogram[b] {
|
|
return histogram[a] > histogram[b]
|
|
}
|
|
return a.packed() < b.packed()
|
|
})
|
|
out := []Swatch{{Name: "Transparent"}}
|
|
for _, c := range colors[:min(len(colors), limit-1)] {
|
|
out = append(out, Swatch{Color: c})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (e *Edit) PaletteFromFrame(ctx context.Context, frame ID, limit int, quantize bool) error {
|
|
im, err := e.Render(frame, RenderOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := ExtractPalette(ctx, im, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.ReplacePalette(p, quantize)
|
|
}
|
|
|
|
// ShadeRamp shifts selected albedo pixels along a palette range. It respects
|
|
// selection, linked cel topology and alpha lock via the ordinary write path.
|
|
func (e *Edit) ShadeRamp(ctx context.Context, cel ID, sel *Selection, first, last, step int) error {
|
|
c, err := e.celForWrite(cel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.Channel != Albedo {
|
|
return fmt.Errorf("art: ramp shading is only defined for albedo")
|
|
}
|
|
if first < 1 || last < first || last >= len(e.work.Palette) || step == 0 || step < -255 || step > 255 {
|
|
return fmt.Errorf("art: invalid palette ramp")
|
|
}
|
|
p := e.work.Images[c.Image]
|
|
for y := 0; y < e.work.Canvas.Height; y++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
for x := 0; x < e.work.Canvas.Width; x++ {
|
|
v := p.at(x-c.X, y-c.Y)
|
|
col := decode(e.work, v)
|
|
if col.A == 0 {
|
|
continue
|
|
}
|
|
idx := int(v)
|
|
if e.work.Canvas.Mode != Indexed {
|
|
idx = int(nearest(e.work.Palette, col, nil))
|
|
}
|
|
if idx < first || idx > last {
|
|
continue
|
|
}
|
|
idx = max(first, min(last, idx+step))
|
|
if err := e.write(c, x, y, encode(e.work, e.work.Palette[idx].Color), sel, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|