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