mirror of
https://github.com/mr0xb/gif2png.git
synced 2026-08-27 20:44:55 -04:00
initial commit
This commit is contained in:
commit
ca23249fba
2 changed files with 239 additions and 0 deletions
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -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
|
||||||
|
|
||||||
208
main.go
Normal file
208
main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue