commit ca23249fbaa48f026b806344d084f8b01ef6c939 Author: mr0xb Date: Sat Dec 13 02:19:57 2025 -0500 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e865cab --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# GIF Frame Extractor (Go) + +A small, fast Go CLI tool that extracts frames from an animated GIF and saves them as PNG files. + +It supports: +- **Raw frame extraction** (exact stored frames) +- **Composited frame rendering** (what you visually see when the GIF plays) +- **Shorthand and long CLI flags** +- Correct handling of GIF **disposal methods** + +--- + +## Features + +- 🚀 Fast and dependency-free (pure Go standard library) +- 🖼️ Export each GIF frame as a PNG +- 🎞️ Correct visual compositing using GIF disposal rules +- 🧩 Optional raw frame dumping (no compositing) +- 🔤 Short and long command-line flags + +--- + +## Installation + +### Build from source + +```bash +git clone https://github.com/mr0xb/gif2png.git +cd gif2png +go build -o gif2png + diff --git a/main.go b/main.go new file mode 100644 index 0000000..8d7ae02 --- /dev/null +++ b/main.go @@ -0,0 +1,208 @@ +// 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) +}