Files
gen2d/backend/pkg/gifmaker/gifmaker.go
T

129 lines
3.2 KiB
Go
Raw Normal View History

// Package gifmaker encodes sprite animation frames into a GIF preview.
//
// Features:
// - Unified canvas: all frames normalized to the same dimensions
// - Transparent background: palette index 0 = fully transparent
// - DisposalBackground: each frame clears the previous one, no ghosting
//
// Pipeline integration:
//
// frames, _ := splitsprite.Process(img, splitsprite.DefaultOptions())
// gifmaker.Save("preview.gif", frames, nil)
package gifmaker
import (
"fmt"
"image"
"image/color"
"image/gif"
"io"
"os"
)
// Options configures GIF generation.
type Options struct {
// Delay is the frame delay in 1/100s (default 10).
Delay int
// MaxColors is the maximum palette size (default 256).
MaxColors int
}
// DefaultOptions returns sensible defaults.
func DefaultOptions() *Options {
return &Options{
Delay: 10,
MaxColors: 256,
}
}
// Save is a convenience wrapper that writes frames to a GIF file.
func Save(path string, frames []image.Image, opts *Options) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create gif file: %w", err)
}
defer f.Close()
return Encode(f, frames, opts)
}
// Encode writes an animated GIF to w. All frames are normalized to a unified
// canvas (max width/height across frames), the palette starts with a
// transparent color, and DisposalBackground prevents inter-frame ghosting.
func Encode(w io.Writer, frames []image.Image, opts *Options) error {
if len(frames) == 0 {
return fmt.Errorf("no frames to encode")
}
if opts == nil {
opts = DefaultOptions()
}
delay := opts.Delay
if delay <= 0 {
delay = 10
}
maxColors := opts.MaxColors
if maxColors <= 0 || maxColors > 256 {
maxColors = 256
}
// Unified canvas
maxW, maxH := 0, 0
for _, f := range frames {
b := f.Bounds()
if b.Dx() > maxW {
maxW = b.Dx()
}
if b.Dy() > maxH {
maxH = b.Dy()
}
}
// Palette with transparent at index 0
pal := color.Palette{color.RGBA{0, 0, 0, 0}}
seen := make(map[color.RGBA]bool)
for _, fr := range frames {
b := fr.Bounds()
for y := b.Min.Y; y < b.Max.Y; y += 3 {
for x := b.Min.X; x < b.Max.X; x += 3 {
r, g, bl, a := fr.At(x, y).RGBA()
c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)}
if !seen[c] && len(pal) < maxColors-1 {
seen[c] = true
pal = append(pal, c)
}
}
}
}
anim := &gif.GIF{
Config: image.Config{Width: maxW, Height: maxH},
}
for _, frame := range frames {
pl := image.NewPaletted(image.Rect(0, 0, maxW, maxH), pal)
// Manually map pixels: transparent → index 0, colored → nearest palette
b := frame.Bounds()
for y := 0; y < maxH; y++ {
for x := 0; x < maxW; x++ {
sx := x + b.Min.X
sy := y + b.Min.Y
if sx < b.Max.X && sy < b.Max.Y {
r, g, bl, a := frame.At(sx, sy).RGBA()
if a > 0 {
c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)}
pl.Set(x, y, c)
}
// else: stays at index 0 (transparent)
}
}
}
anim.Image = append(anim.Image, pl)
anim.Delay = append(anim.Delay, delay)
anim.Disposal = append(anim.Disposal, gif.DisposalBackground)
}
anim.LoopCount = 0
anim.BackgroundIndex = 0
return gif.EncodeAll(w, anim)
}