commit 47e20ffb625184095c05c9a21b83032fe0a7f6cd from: ale date: Sat Aug 1 05:14:05 2026 UTC Play animated GIFs for real via Kitty's native animation A .gif with more than one frame now decodes every frame (respecting per-frame disposal, since GIF frames can be partial and rely on compositing over previous ones), transmits them under a single image id, and starts looping playback -- all server-side, so once sent there's nothing left for this process to do per frame. Single-frame GIFs still take the plain static-image path. Adds DeleteSeq to free an animated image's memory once it's no longer needed, though nothing calls it automatically yet: an off-screen animation keeps ticking in the terminal for the rest of the session, same accepted tradeoff this fork already makes for static images never being explicitly freed either. 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).