gif2png/main.go
2025-12-13 02:19:57 -05:00

208 lines
4.5 KiB
Go

// main.go
package main
import (
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"os"
"path/filepath"
)
func main() {
inPath := stringFlagAliases(
[]string{"in", "i"},
"",
"Input GIF path (required)",
)
outDir := stringFlagAliases(
[]string{"out", "o"},
"frames",
"Output directory",
)
prefix := stringFlagAliases(
[]string{"prefix", "p"},
"frame_",
"Output filename prefix",
)
compose := boolFlagAliases(
[]string{"compose", "c"},
true,
"Compose frames (honor disposal); false = raw stored frames",
)
flag.Parse()
if *inPath == "" {
fatal(errors.New("missing -in (or -i)"))
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fatal(fmt.Errorf("create out dir: %w", err))
}
f, err := os.Open(*inPath)
if err != nil {
fatal(fmt.Errorf("open input: %w", err))
}
defer f.Close()
g, err := gif.DecodeAll(f)
if err != nil {
fatal(fmt.Errorf("decode gif: %w", err))
}
if len(g.Image) == 0 {
fatal(errors.New("gif has no frames"))
}
if *compose {
if err := writeCompositedFrames(g, *outDir, *prefix); err != nil {
fatal(err)
}
} else {
if err := writeRawFrames(g, *outDir, *prefix); err != nil {
fatal(err)
}
}
fmt.Printf("Wrote %d frames to %s\n", len(g.Image), *outDir)
}
// Registers multiple flag names that all write to the same *string variable.
func stringFlagAliases(names []string, def, usage string) *string {
if len(names) == 0 {
panic("stringFlagAliases: no names provided")
}
v := new(string)
*v = def
for _, n := range names {
flag.StringVar(v, n, def, usage)
}
return v
}
// Registers multiple flag names that all write to the same *bool variable.
func boolFlagAliases(names []string, def bool, usage string) *bool {
if len(names) == 0 {
panic("boolFlagAliases: no names provided")
}
v := new(bool)
*v = def
for _, n := range names {
flag.BoolVar(v, n, def, usage)
}
return v
}
func writeRawFrames(g *gif.GIF, outDir, prefix string) error {
for i, frame := range g.Image {
outPath := filepath.Join(outDir, fmt.Sprintf("%s%04d.png", prefix, i))
if err := writePNG(outPath, frame); err != nil {
return fmt.Errorf("write frame %d: %w", i, err)
}
}
return nil
}
// Composites frames to match what you'd visually see when playing the GIF.
func writeCompositedFrames(g *gif.GIF, outDir, prefix string) error {
canvasRect := image.Rect(0, 0, g.Config.Width, g.Config.Height)
canvas := image.NewRGBA(canvasRect)
// Fill initial background.
bg := gifBackgroundColor(g)
draw.Draw(canvas, canvasRect, image.NewUniform(bg), image.Point{}, draw.Src)
var prevCanvas *image.RGBA
for i, palFrame := range g.Image {
disposal := byte(0)
if len(g.Disposal) > i {
disposal = g.Disposal[i]
}
// Save for "restore to previous"
if disposal == gif.DisposalPrevious {
prevCanvas = cloneRGBA(canvas)
} else {
prevCanvas = nil
}
// Draw this frame onto the canvas.
draw.Draw(canvas, palFrame.Bounds(), palFrame, palFrame.Bounds().Min, draw.Over)
// Output the composited image for this frame.
outPath := filepath.Join(outDir, fmt.Sprintf("%s%04d.png", prefix, i))
if err := writePNG(outPath, canvas); err != nil {
return fmt.Errorf("write composited frame %d: %w", i, err)
}
// Apply disposal *after* output.
switch disposal {
case gif.DisposalBackground:
draw.Draw(canvas, palFrame.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src)
case gif.DisposalPrevious:
if prevCanvas != nil {
copy(canvas.Pix, prevCanvas.Pix)
}
default:
// DisposalNone / Unspecified: keep canvas as-is
}
}
return nil
}
func gifBackgroundColor(g *gif.GIF) color.Color {
// BackgroundIndex refers to the GIF's global palette if present.
// In practice, DecodeAll gives each frame a palette; we can use frame 0.
if len(g.Image) == 0 || g.Image[0] == nil {
return color.Transparent
}
pal := g.Image[0].Palette
idx := int(g.BackgroundIndex)
if idx >= 0 && idx < len(pal) {
return pal[idx]
}
return color.Transparent
}
func writePNG(path string, img image.Image) error {
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if err := png.Encode(f, img); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
func cloneRGBA(src *image.RGBA) *image.RGBA {
dst := image.NewRGBA(src.Rect)
copy(dst.Pix, src.Pix)
return dst
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}