commit - dd0408215b51197334ac5262c29d457559f41a92
commit + 47e20ffb625184095c05c9a21b83032fe0a7f6cd
blob - /dev/null
blob + f662ff07f62effed028259d51fc6a0349e803774 (mode 644)
--- /dev/null
+++ examples/gif.md
+---
+author: Gopher
+paging: Slide %d / %d
+---
+
+# Animated GIFs
+
+`` 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.
+
+
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
_ "image/jpeg"
_ "image/png"
"os"
+ "path/filepath"
+ "strings"
"sync"
+ "time"
)
// cellWidthOverHeight approximates a terminal cell's pixel aspect ratio
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
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
+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
+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
import (
"bytes"
+ "fmt"
stdimage "image"
"strconv"
"strings"
+ "github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
)
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).