animate/animate.go
2025-02-16 15:58:01 -05:00

291 lines
6.9 KiB
Go

// This package enables creating simple animations by drawing onto an image, using ffmpeg which could be in the system path.
package animate
import (
"cmp"
"fmt"
"image"
"image/color"
"image/png"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// Animation implements the [draw.Image] interface; you can draw to it and then invoke [EmitFrame].
//
// That said, some implementations might be optimized for known image types, in which case, passing a pointer to the
// [NRGBA] field might be more efficient.
type Animation struct {
image.NRGBA
// the first error where we encountered during writing
err error
// the pipe to ffmpeg's stdin, nil if the animation is closed
w io.WriteCloser
// the ffmpeg process, only applicable if w above is not nil
c *exec.Cmd
// the name of the file we're writing to,
// and where we'll move it to if nothing explodes
tmp, out string
// framerate
rate float64
// the current frame number
frame int
}
// EmitFrame writes the image as it presently appears as a frame to the animation.
//
// If this is successful, then the frame counter is advanced. The frame buffer isn't cleared.
func (a *Animation) EmitFrame() error {
// if we've already encountered an error, return it
if a.err != nil {
return a.err
}
// if the pipe is closed, then we can't write, obviously.
if a.w == nil {
return os.ErrClosed
}
// try to write the frame to the pipe,
// if if that fails, record the error and close the animation.
if _, err := a.w.Write(a.Pix); err != nil {
a.err = err
return a.Close()
}
a.frame++
// success!
return nil
}
// Frame returns the current frame number, with the first frame being 0.
func (a *Animation) Frame() int {
return a.frame
}
// Elapsed returns the timestamp for the current frame, with first frame having a timestamp of 0.
func (a *Animation) Elapsed() time.Duration {
return time.Duration(float64(a.frame) * float64(time.Second) / a.rate)
}
// Close finishes writing the animation, and cleans up any resources.
func (a *Animation) Close() error {
// the pipe is open, shut everything down.
if a.w != nil {
// no longer needs to be finalized
runtime.SetFinalizer(a, nil)
// close the pipe and wait for ffmpeg to finish,
// setting a.err to the first error encountered.
a.err = cmp.Or(a.err, a.w.Close(), a.c.Wait())
a.w, a.c = nil, nil
// if there are no errors so far, attempt to move the file to the destination.
if a.err == nil {
a.err = os.Rename(a.tmp, a.out)
}
// if there was an error, delete the temporary file we wrote.
if a.err != nil {
os.Remove(a.tmp)
}
}
// return any error we may have encountered.
return a.err
}
func paletteToFile(palette color.Palette) (*os.File, error) {
r, w, err := os.Pipe()
if err != nil {
return nil, err
}
go func() {
defer w.Close()
img := image.NewPaletted(image.Rect(0, 0, 16, 16), palette)
for i := 0; i < 256; i++ {
img.Pix[i] = uint8(min(i, len(palette)-1))
}
// I'm not going to bother dealing with possible write errors,
// those would be because ffmpeg terminated unexpectedly,
// and has nothing to do with us.
png.Encode(w, img)
}()
return r, nil
}
func finalize(a *Animation) {
err := a.Close()
log.Printf("animation was finalized without being closed; err=%v", err)
}
func new(r image.Rectangle, rate float64, palette color.Palette, out string, fmtArgs []string) (a *Animation, err error) {
tmp, err := os.CreateTemp(filepath.Dir(out), "."+strings.TrimSuffix(filepath.Base(out), filepath.Ext(out))+"*.tmp")
if err != nil {
return nil, err
}
defer func() {
// if things go wrong, delete the file we wrote.
if err != nil {
os.Remove(tmp.Name())
}
}()
// we don't need the file open, we just want a safe place for ffmpeg to write to.
if err := tmp.Close(); err != nil {
return nil, err
}
var args []string
args = append(args,
"-hide_banner",
"-loglevel", "error",
"-f", "rawvideo",
"-pixel_format", "rgba",
"-video_size", fmt.Sprintf("%dx%d", r.Dx(), r.Dy()),
"-framerate", fmt.Sprint(rate),
"-i", "pipe:0",
)
var extraFiles []*os.File
if palette != nil {
f, err := paletteToFile(palette)
if err != nil {
return nil, err
}
args = append(args,
"-i", fmt.Sprintf("pipe:%d", len(extraFiles)+3),
"-filter_complex", "[0:v][1:v]paletteuse=dither=bayer",
)
extraFiles = append(extraFiles, f)
}
args = append(args, fmtArgs...)
args = append(args, "-y", "file:"+tmp.Name())
cmd := exec.Command("ffmpeg", args...)
cmd.ExtraFiles = extraFiles
cmd.Stderr = os.Stderr
w, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
// close our copies of the file descriptors. The ffmpeg process, if it even started, has inherited them.
// and if it didn't, we'd needed to close them ourselves.
for _, f := range extraFiles {
f.Close()
}
if err != nil {
return nil, err
}
animation := &Animation{
NRGBA: image.NRGBA{
Pix: make([]uint8, r.Dx()*r.Dy()*4),
Stride: r.Dx() * 4,
Rect: r,
},
w: w,
c: cmd,
tmp: tmp.Name(),
out: out,
rate: rate,
frame: 0,
}
// failing to close the animation will leave temporary files on the filesystem, possibly leave the ffmpeg process hanging,
// so let's try not to let that happen.
runtime.SetFinalizer(animation, finalize)
return animation, nil
}
// New begins a new animation at the given size and frame rate, to be written to the specified output file.
//
// The output filename must have one of the following extensions:
// - .gif (lossy, uses a default fixed palette)
// - .png / .apng (lossless)
// - .mp4 (lossless RGB, doesn't support transparency)
// - .mkv (lossless RGB, doesn't support transparency)
// - .webp (lossy YUV colorspace conversion, alpha supported)
// - .webm (lossy YUV colorspace conversion, alpha supported)
//
// Note that the output file won't be created immediately, the animation will be written to a temporary file and then renamed
// by the [Close] method if no errors are encountered.
func New(r image.Rectangle, rate float64, out string) (*Animation, error) {
switch ext := filepath.Ext(out); ext {
case ".gif":
return new(r, rate, defaultPalette, out, []string{
"-loop", "0",
"-f", "gif",
})
case ".png", ".apng":
return new(r, rate, nil, out, []string{
"-vcodec", "apng",
"-plays", "0",
"-f", "apng",
})
case ".mp4":
return new(r, rate, nil, out, []string{
"-vcodec", "libx264rgb",
"-preset", "ultrafast",
"-qp", "0",
"-f", "mp4",
})
case ".mkv":
return new(r, rate, nil, out, []string{
"-vcodec", "libx264rgb",
"-preset", "ultrafast",
"-qp", "0",
"-f", "matroska",
})
case ".webp":
return new(r, rate, nil, out, []string{
"-vcodec", "libwebp_anim",
"-lossless", "1",
"-loop", "0",
"-f", "webp",
})
case ".webm":
return new(r, rate, nil, out, []string{
"-vcodec", "vp9",
"-lossless", "1",
"-f", "webm",
})
default:
return nil, fmt.Errorf("unsupported extension: %q", ext)
}
}