// 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) }