Commit Diff


commit - dd0408215b51197334ac5262c29d457559f41a92
commit + 47e20ffb625184095c05c9a21b83032fe0a7f6cd
blob - /dev/null
blob + f662ff07f62effed028259d51fc6a0349e803774 (mode 644)
--- /dev/null
+++ examples/gif.md
@@ -0,0 +1,19 @@
+---
+author: Gopher
+paging: Slide %d / %d
+---
+
+# Animated GIFs
+
+`![alt](path.gif)` plays back for real, looping, entirely on the
+terminal's side -- once the frames are sent there's nothing left for
+`slides` to do every tick.
+
+---
+
+# Simple pendulum
+
+Generated with Julia (Plots.jl + GR): θ(t) = θ₀ cos(ωt), one frame per
+render, transparent background, colors matching this theme.
+
+![Simple pendulum swinging under gravity](images/pendulum.gif)
blob - /dev/null
blob + c10c61a7e80d05b0d0f855d6595195e85e8e37b8 (mode 644)
Binary files /dev/null and examples/images/pendulum.gif differ
blob - b5fd7cedd7d4b9f52b460685e9fd9454a5844acf
blob + ee7381f1c3055ee86a138d77993f30a0551c9de9
--- internal/image/cache.go
+++ internal/image/cache.go
@@ -6,7 +6,10 @@ import (
 	_ "image/jpeg"
 	_ "image/png"
 	"os"
+	"path/filepath"
+	"strings"
 	"sync"
+	"time"
 )
 
 // cellWidthOverHeight approximates a terminal cell's pixel aspect ratio
@@ -61,18 +64,33 @@ func (c *Cache) Load(path string, maxCols, maxRows int
 		return e, "", nil
 	}
 
-	f, err := os.Open(path)
-	if err != nil {
-		return Entry{}, "", err
-	}
-	defer f.Close()
+	var (
+		frames []stdimage.Image
+		delays []time.Duration
+		bounds stdimage.Rectangle
+	)
 
-	img, _, err := stdimage.Decode(f)
-	if err != nil {
-		return Entry{}, "", err
+	if strings.EqualFold(filepath.Ext(path), ".gif") {
+		frames, delays, err = decodeGIFFrames(path)
+		if err != nil {
+			return Entry{}, "", err
+		}
 	}
+	if len(frames) == 0 {
+		f, err := os.Open(path)
+		if err != nil {
+			return Entry{}, "", err
+		}
+		img, _, err := stdimage.Decode(f)
+		f.Close()
+		if err != nil {
+			return Entry{}, "", err
+		}
+		frames = []stdimage.Image{img}
+	}
+	bounds = frames[0].Bounds()
 
-	cols, rows := fitSize(img.Bounds(), maxCols, maxRows)
+	cols, rows := fitSize(bounds, maxCols, maxRows)
 	padLeft := (maxCols - cols) / 2
 	if padLeft < 0 {
 		padLeft = 0
@@ -85,7 +103,15 @@ func (c *Cache) Load(path string, maxCols, maxRows int
 	c.entries[cacheKey{path, maxCols, maxRows}] = e
 	c.mu.Unlock()
 
-	seq, err := TransmitSeq(img, id, cols, rows)
+	// A GIF with only one real frame (common: a still image saved with a
+	// .gif extension) takes the plain, single-frame path -- no point
+	// paying for animation control codes that would never fire.
+	var seq string
+	if len(frames) > 1 {
+		seq, err = TransmitAnimSeq(frames, delays, id, cols, rows)
+	} else {
+		seq, err = TransmitSeq(frames[0], id, cols, rows)
+	}
 	if err != nil {
 		return Entry{}, "", err
 	}
blob - /dev/null
blob + 7d9a8189c5843cae32508b9536311b9b9275830d (mode 644)
--- /dev/null
+++ internal/image/anim.go
@@ -0,0 +1,153 @@
+package image
+
+import (
+	"bytes"
+	"encoding/base64"
+	"fmt"
+	stdimage "image"
+	"image/color"
+	"image/draw"
+	"image/gif"
+	"image/png"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/kitty"
+)
+
+// decodeGIFFrames decodes every frame of an animated GIF into full-canvas
+// RGBA images (GIF frames can cover only part of the logical screen and
+// rely on the previous frame's pixels showing through, or a disposal
+// method, for the rest -- composing here means every returned frame is
+// already a complete, self-contained image). A single-frame GIF returns
+// one frame, same as decoding it as a plain static image would.
+func decodeGIFFrames(path string) ([]stdimage.Image, []time.Duration, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, nil, err
+	}
+	defer f.Close()
+
+	g, err := gif.DecodeAll(f)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	screen := stdimage.Rect(0, 0, g.Config.Width, g.Config.Height)
+	canvas := stdimage.NewRGBA(screen)
+
+	frames := make([]stdimage.Image, len(g.Image))
+	delays := make([]time.Duration, len(g.Image))
+	var previous *stdimage.RGBA
+
+	for i, src := range g.Image {
+		var disposal byte
+		if i < len(g.Disposal) {
+			disposal = g.Disposal[i]
+		}
+		if disposal == gif.DisposalPrevious {
+			snapshot := stdimage.NewRGBA(screen)
+			draw.Draw(snapshot, screen, canvas, screen.Min, draw.Src)
+			previous = snapshot
+		}
+
+		draw.Draw(canvas, src.Bounds(), src, src.Bounds().Min, draw.Over)
+
+		frame := stdimage.NewRGBA(screen)
+		draw.Draw(frame, screen, canvas, screen.Min, draw.Src)
+		frames[i] = frame
+
+		// GIF delay is in 100ths of a second; encoders commonly leave it
+		// at 0 meaning "as fast as possible", which every major viewer
+		// instead treats as ~100ms to avoid pegging a CPU core.
+		ms := 10
+		if i < len(g.Delay) {
+			ms = g.Delay[i] * 10
+		}
+		if ms <= 10 {
+			ms = 100
+		}
+		delays[i] = time.Duration(ms) * time.Millisecond
+
+		switch disposal {
+		case gif.DisposalBackground:
+			draw.Draw(canvas, src.Bounds(), &stdimage.Uniform{C: color.Transparent}, stdimage.Point{}, draw.Src)
+		case gif.DisposalPrevious:
+			if previous != nil {
+				draw.Draw(canvas, screen, previous, screen.Min, draw.Src)
+			}
+		}
+	}
+
+	return frames, delays, nil
+}
+
+// TransmitAnimSeq transmits frames as a Kitty native animation under a
+// single image id: the root frame goes through the same virtual-placement
+// path as any static image (TransmitSeq), the rest are appended with a=f,
+// and a final a=a control starts an infinite, terminal-driven loop -- so
+// once sent, playback needs no further involvement from this process.
+func TransmitAnimSeq(frames []stdimage.Image, delays []time.Duration, id, cols, rows int) (string, error) {
+	var out strings.Builder
+
+	seq, err := TransmitSeq(frames[0], id, cols, rows)
+	if err != nil {
+		return "", err
+	}
+	out.WriteString(seq)
+
+	// The root frame's gap can't be set at transmit time, so it's set
+	// separately, same as the protocol documents.
+	out.WriteString(ansi.KittyGraphics(nil, "a=a", fmt.Sprintf("i=%d", id), "r=1", fmt.Sprintf("z=%d", delays[0].Milliseconds()), "q=2"))
+
+	for i := 1; i < len(frames); i++ {
+		var buf bytes.Buffer
+		if err := png.Encode(&buf, frames[i]); err != nil {
+			return "", err
+		}
+		out.WriteString(transmitAnimFrame(id, delays[i].Milliseconds(), buf.Bytes()))
+	}
+
+	// s=3: run, looping back to the first frame at the end. v=1: loop
+	// forever.
+	out.WriteString(ansi.KittyGraphics(nil, "a=a", fmt.Sprintf("i=%d", id), "s=3", "v=1", "q=2"))
+
+	return out.String(), nil
+}
+
+// transmitAnimFrame base64-encodes and, if needed, chunks a single frame's
+// PNG payload across multiple escape codes (mirroring the chunking
+// convention x/ansi/kitty's own EncodeGraphics uses for a normal image
+// transmission: continuation chunks repeat a= and q=, never i=).
+func transmitAnimFrame(id int, gapMs int64, png []byte) string {
+	b64 := []byte(base64.StdEncoding.EncodeToString(png))
+
+	var out strings.Builder
+	for i := 0; i < len(b64); i += kitty.MaxChunkSize {
+		end := i + kitty.MaxChunkSize
+		if end > len(b64) {
+			end = len(b64)
+		}
+		chunk := b64[i:end]
+		isFirst := i == 0
+		isLast := end == len(b64)
+
+		var opts []string
+		if isFirst {
+			opts = []string{"a=f", fmt.Sprintf("i=%d", id), "f=100", fmt.Sprintf("z=%d", gapMs), "q=2"}
+		} else {
+			opts = []string{"a=f", "q=2"}
+		}
+		if !isFirst || !isLast {
+			if isLast {
+				opts = append(opts, "m=0")
+			} else {
+				opts = append(opts, "m=1")
+			}
+		}
+		out.WriteString(ansi.KittyGraphics(chunk, opts...))
+	}
+	return out.String()
+}
blob - /dev/null
blob + ae7ee4d417cc5f6400b11d978b71ed4bc1f5f228 (mode 644)
--- /dev/null
+++ internal/image/anim_test.go
@@ -0,0 +1,79 @@
+package image_test
+
+import (
+	stdimage "image"
+	"image/color"
+	"image/gif"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/maaslalani/slides/internal/image"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func writeTestGIF(t *testing.T, dir, name string, delays []int) string {
+	t.Helper()
+	palette := []color.Color{color.RGBA{R: 255, A: 255}, color.RGBA{G: 255, A: 255}, color.RGBA{B: 255, A: 255}}
+
+	g := &gif.GIF{}
+	for i, d := range delays {
+		frame := stdimage.NewPaletted(stdimage.Rect(0, 0, 20, 10), palette)
+		fill := uint8(i % len(palette))
+		for y := 0; y < 10; y++ {
+			for x := 0; x < 20; x++ {
+				frame.SetColorIndex(x, y, fill)
+			}
+		}
+		g.Image = append(g.Image, frame)
+		g.Delay = append(g.Delay, d)
+		g.Disposal = append(g.Disposal, gif.DisposalNone)
+	}
+
+	path := filepath.Join(dir, name)
+	f, err := os.Create(path)
+	require.NoError(t, err)
+	defer f.Close()
+	require.NoError(t, gif.EncodeAll(f, g))
+	return path
+}
+
+func TestCache_LoadAnimatedGIF(t *testing.T) {
+	dir := t.TempDir()
+	path := writeTestGIF(t, dir, "anim.gif", []int{10, 20, 0})
+
+	c := image.NewCache()
+	entry, seq, err := c.Load(path, 40, 20)
+	require.NoError(t, err)
+	require.NotEmpty(t, seq)
+	assert.Equal(t, 1, entry.ID)
+
+	// Root frame, then two more appended, then playback control.
+	assert.Equal(t, 2, strings.Count(seq, "a=f"))
+	assert.Contains(t, seq, "a=a")
+	assert.Contains(t, seq, "s=3")
+	assert.Contains(t, seq, "v=1")
+	// Frame with Delay=0 falls back to the ~100ms convention, not 0ms.
+	assert.NotContains(t, seq, "z=0")
+}
+
+func TestCache_LoadSingleFrameGIFIsPlain(t *testing.T) {
+	dir := t.TempDir()
+	path := writeTestGIF(t, dir, "still.gif", []int{10})
+
+	c := image.NewCache()
+	_, seq, err := c.Load(path, 40, 20)
+	require.NoError(t, err)
+
+	assert.NotContains(t, seq, "a=f", "a single-frame GIF shouldn't pay for animation control codes")
+	assert.NotContains(t, seq, "a=a")
+}
+
+func TestDeleteSeq(t *testing.T) {
+	seq := image.DeleteSeq(42)
+	assert.Contains(t, seq, "a=d")
+	assert.Contains(t, seq, "d=I")
+	assert.Contains(t, seq, "i=42")
+}
blob - 72b5f2ddc5df96f6deb9fa698fc274c3ea83ebf6
blob + 97428cacd21cfa50ec637c73386e36d57bbdea5e
--- internal/image/kitty.go
+++ internal/image/kitty.go
@@ -2,10 +2,12 @@ package image
 
 import (
 	"bytes"
+	"fmt"
 	stdimage "image"
 	"strconv"
 	"strings"
 
+	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/ansi/kitty"
 )
 
@@ -50,6 +52,15 @@ func TransmitSeq(img stdimage.Image, id, cols, rows in
 	return buf.String(), err
 }
 
+// DeleteSeq returns the escape sequence that frees an image and all its
+// frames/placements (id, uppercase D value = also free the pixel data, not
+// just the placements) from the terminal's memory -- needed for animated
+// images once they scroll off screen, since the terminal keeps decoding
+// and redrawing their frames for as long as their placement exists.
+func DeleteSeq(id int) string {
+	return ansi.KittyGraphics(nil, "a=d", "d=I", fmt.Sprintf("i=%d", id), "q=2")
+}
+
 // fgSGR encodes id into a foreground color escape, replicating
 // ultraviolet's own reference encoding (id bytes -> indexed color when the
 // two high bytes are zero, else 24-bit truecolor).