feat(artwork): preserve animated image artwork during resize (#5184)

* feat(artwork): preserve animated image artwork during resize

Detect animated GIFs, WebPs, and APNGs via lightweight byte scanning
and preserve their animation when serving resized artwork. Animated GIFs
are converted to animated WebP via ffmpeg with optional downscaling;
animated WebP/APNG are returned as-is since ffmpeg cannot re-encode them.

Adds ConvertAnimatedImage to the FFmpeg interface for piping stdin data
through ffmpeg with animated WebP output.

* fix(artwork): address code review feedback for animated artwork

Fix ReadCloser leak where ffmpeg pipe's Close was discarded by
io.NopCloser wrapping — now preserves ReadCloser semantics when the
resized reader already supports Close. Use uint64 for PNG chunk position
to prevent potential overflow on 32-bit platforms. Add integration tests
for the animation branching logic in resizeImage.
This commit is contained in:
Deluan Quintão
2026-03-13 18:11:12 -04:00
committed by GitHub
parent 4ddb0774ec
commit a50b2a1e72
8 changed files with 532 additions and 10 deletions
+29 -4
View File
@@ -43,6 +43,7 @@ type AudioProbeResult struct {
type FFmpeg interface {
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
CmdPath() (string, error)
@@ -78,6 +79,23 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
cmdPath, err := ffmpegCmd()
if err != nil {
return nil, err
}
args := []string{cmdPath, "-i", "pipe:0"}
if maxSize > 0 {
vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize)
args = append(args, "-vf", vf)
}
args = append(args, "-loop", "0", "-c:v", "libwebp_anim",
"-quality", strconv.Itoa(quality), "-f", "webp", "-")
return e.start(ctx, args, reader)
}
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
@@ -223,9 +241,12 @@ func (e *ffmpeg) Version() string {
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
if len(input) > 0 {
j.input = input[0]
}
j.PipeReader, j.out = io.Pipe()
err := j.start(ctx)
if err != nil {
@@ -237,14 +258,18 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
type ffCmd struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
out *io.PipeWriter
args []string
cmd *exec.Cmd
input io.Reader // optional stdin source
}
func (j *ffCmd) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if j.input != nil {
cmd.Stdin = j.input
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {