mirror of
https://github.com/mr0xb/gif2png.git
synced 2026-08-27 20:44:55 -04:00
208 lines
4.5 KiB
Go
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)
|
|
}
|