ハイパーマッスルエンジニア

Vim、ShellScriptについてよく書く

GolangでGIF分割・結合・文字入れ

f:id:rasukarusan:20210501112456p:plain
 

全体のソース

github.com

動機

GIFの開始と終わりを判別するために、GIFの最初の方に”START”みたいなラベルを挿入したい。
ImageMagickには依存したくなかったのでGolangの標準パッケージでなんとかしてみた。

環境

$ go version
go version go1.16.2 darwin/arm64

GIFの分割

GIFファイルをフレームごとにpngファイルに分割する

go - How to split gif into images - Stack Overflow

func splitGif(reader io.Reader) (names []string, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("Error while decoding: %s", r)
        }
    }()

    gif, err := gif.DecodeAll(reader)

    if err != nil {
        return []string{""}, err
    }

    imgWidth, imgHeight := getGifDimensions(gif)

    overpaintImage := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
    draw.Draw(overpaintImage, overpaintImage.Bounds(), gif.Image[0], image.ZP, draw.Src)

    var ns []string
    for i, srcImg := range gif.Image {
        draw.Draw(overpaintImage, overpaintImage.Bounds(), srcImg, image.ZP, draw.Over)

        file, err := os.Create(fmt.Sprintf("%s%d%s", "temp", i, ".png"))
        if err != nil {
            return []string{""}, err
        }

        err = png.Encode(file, overpaintImage)
        if err != nil {
            return []string{""}, err
        }

        ns = append(ns, file.Name())
        file.Close()
    }

    return ns, nil
}

func getGifDimensions(gif *gif.GIF) (x, y int) {
    var lowestX int
    var lowestY int
    var highestX int
    var highestY int

    for _, img := range gif.Image {
        if img.Rect.Min.X < lowestX {
            lowestX = img.Rect.Min.X
        }
        if img.Rect.Min.Y < lowestY {
            lowestY = img.Rect.Min.Y
        }
        if img.Rect.Max.X > highestX {
            highestX = img.Rect.Max.X
        }
        if img.Rect.Max.Y > highestY {
            highestY = img.Rect.Max.Y
        }
    }

    return highestX - lowestX, highestY - lowestY
}

呼び出し

func main() {
    // 対象のGIFを読み込む
    filename := os.Args[1]
    f, err := os.Open(filename)
    if err != nil {
        log.Fatalf("cannot open file %q: %v", filename, err)
    }
    defer f.Close()

    // GIF分割
    names, err := splitGif(f)
    if err != nil {
        log.Fatalf(err.Error())
    }
}

画像に文字をいれる

フォントファイルはmain.goと同じ階層に設置しておく。851MkPOP_002.ttfをフォントとして利用させてもらった。

func addLabel(file *os.File, text string) {
    img, err := png.Decode(file)
    if err != nil {
        log.Fatalf("failed to decode image: %s", err.Error())
    }
    fmt.Println(img.Bounds())
    dst := image.NewRGBA(img.Bounds())
    draw.Draw(dst, dst.Bounds(), img, image.Point{}, draw.Src)

    // col := color.RGBA{255, 255, 255, 1.0}
    opt := truetype.Options{
        Size: 40,
    }
    ft := loadFont()
    face := truetype.NewFace(ft, &opt)

    x, y := 100, 100
    dot := fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 26)}

    d := &font.Drawer{
        Dst:  dst,
        Src:  image.White,
        Face: face,
        Dot:  dot,
    }
    d.DrawString(text)

    newFile, err := os.Create(file.Name())
    if err != nil {
        log.Fatalf("failed to create file: %s", err.Error())
    }
    defer newFile.Close()
    b := bufio.NewWriter(newFile)
    if err := png.Encode(b, dst); err != nil {
        log.Fatalf("failed to encode image: %s", err.Error())
    }
    b.Flush()
}

func loadFont() (font *truetype.Font) {
    ttf, err := ioutil.ReadFile("851MkPOP_002.ttf")
    if err != nil {
        log.Fatalf("failed to load font: %s", err.Error())
    }
    ft, err := truetype.Parse(ttf)
    if err != nil {
        log.Fatalf("failed to parse font: %s", err.Error())
    }
    return ft
}

呼び出し

func main() {
    // 対象のGIFを読み込む
    // ...略
    // GIF分割
    // ...略

    // 最初の5フレームに文字を挿入
    for i := 0; i < 5; i++ {
        f1, err := os.Open(names[i])
        if err != nil {
            log.Fatalf("failed to open file: %s", err.Error())
        }
        defer f1.Close()
        addLabel(f1, "START")
    }
}

GIFの結合

pngファイルを結合してGIFに戻す。image.NewPaletted(png.Bounds(), palette.Plan9)でパレットを作成してGIFを作成という流れになるが、このときに画像の全体の色が若干くすむので完全に元通りのGIFにはならない。

func makeGif(names []string) {
    outGif := &gif.GIF{}
    for _, name := range names {
        fmt.Println(name)
        f, err := os.Open(name)
        if err != nil {
            panic(err)
        }
        defer f.Close()
        png, _, err := image.Decode(f)
        if err != nil {
            panic(err)
        }
        palettedImage := image.NewPaletted(png.Bounds(), palette.Plan9)
        draw.Draw(palettedImage, palettedImage.Rect, png, png.Bounds().Min, draw.Over)
        outGif.Image = append(outGif.Image, palettedImage)
        outGif.Delay = append(outGif.Delay, 0)
    }
    f, _ := os.OpenFile("out.gif", os.O_WRONLY|os.O_CREATE, 0600)
    defer f.Close()
    gif.EncodeAll(f, outGif)
}

後片付け(GIF分割時に生成したpngファイルの削除)

func removeTempFile() {
    files, err := filepath.Glob("temp*")
    if err != nil {
        panic(err)
    }
    for _, f := range files {
        if err := os.Remove(f); err != nil {
            panic(err)
        }
    }
}

これをmain関数終了時とCtrl-Cで終了されたときに発火するようにしておく。

func main() {
    defer removeTempFile()

    go func() {
        trap := make(chan os.Signal, 1)
        signal.Notify(trap, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT)
        s := <-trap
        fmt.Printf("Received signal %s\n", s)
        removeTempFile()
        os.Exit(1)
    }()
}

全体のソース

github.com

終わり

ここまでやったところでGIF Breweryで文字を挿入することができることが発覚した。しかもFadeIn/OutもできてGIFの画質も落ちないので完璧だった。GIF Brewery最高。