Real-image posters: scale and encode in the background

Done in Draw it took about 200 ms per new poster (BiLinear scale to the
box plus PNG encoding), so each step to another title stalled the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:22:41 +02:00
co-authored by Claude Opus 5.5
parent f0bc284e82
commit 45e8daaa71
5 changed files with 108 additions and 10 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased
- **Moving through lists is quick again with real-image posters:** scaling and encoding a poster
for the terminal (about 200 ms since posters are sent at the size of their box) now happens in
the background; the poster appears when it is ready, and the selection never waits for it.
- **Trakt lists show faster after a restart:** the Radarr/Sonarr lookups of their titles are kept
on disk too (3 days, `lookups.json`), so a list takes one request to Trakt and a quick library
check per title instead of a lookup (about a second each) per title. Whether a title is in the
+6
View File
@@ -245,6 +245,12 @@ func New(cfg *config.Config) *App {
func (a *App) newGraphics() *kittyGraphics {
k := newKittyGraphics(a.writeTerminal)
k.cellPixels = func() (int, int) { return a.cellW, a.cellH }
k.background = func(prepare func() prepared, ready func(prepared)) {
go func() {
p := prepare()
a.post(func() { ready(p) })
}()
}
switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); {
case v == "":
case strings.Contains(v, "/"):
+3
View File
@@ -447,6 +447,9 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
if err != nil {
return false
}
if id == 0 {
return true // being prepared: the place stays empty until it is ready
}
if c.gfx.debug != nil {
if drawn := [5]int{int(id), x, y, cols, rows}; drawn != c.lastDrawn {
c.lastDrawn = drawn
+70 -10
View File
@@ -49,8 +49,27 @@ type kittyGraphics struct {
// an image that is smaller than its placement (Ghostty shows it at its own
// size, so a small poster came out smaller than the others).
cellPixels func() (w, h int)
// background, if set, runs prepare off the UI goroutine and then ready on
// it. Scaling and encoding a poster takes about 200 ms; done in Draw, every
// step to a title with a poster not uploaded yet waited that long. Without
// it (tests) posters are prepared at once.
background func(prepare func() prepared, ready func(prepared))
preparing map[footprint]bool
done map[footprint]prepared // prepared, not uploaded yet
}
// prepared is a poster encoded for upload.
type prepared struct {
encoded string // base64 PNG
w, h int // its size in pixels
err error
}
// maxPrepared bounds the prepared posters that wait for a draw (the
// selection moved on before they were ready).
const maxPrepared = 8
// settleDelay is how long the window size must stay the same before real
// images are shown again. While a window is dragged to another size, tens of
// sizes go by; uploading and placing images for each of them races with the
@@ -73,7 +92,8 @@ type footprint struct {
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{},
preparing: map[footprint]bool{}, done: map[footprint]prepared{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
@@ -90,26 +110,65 @@ func kittyAPC(keys string, payload []byte) []byte {
}
// show makes sure the part rect of the image is uploaded and placed over
// cols×rows cells, and returns the id the placeholder cells must carry. The same
// footprint is reused; a different one for the same poster replaces the old
// upload (which is removed from the terminal).
// cols×rows cells, and returns the id the placeholder cells must carry, or 0
// while the poster is still being prepared in the background (show is called
// again on the redraw when it is ready). The same footprint is reused; a
// different one for the same poster replaces the old upload (which is removed
// from the terminal).
func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) {
key := footprint{img, rect, cols, rows}
if id, ok := k.ids[key]; ok {
return id, nil
}
var src image.Image = img.SubImage(rect)
cw, ch := 0, 0
if k.cellPixels != nil {
if cw, ch := k.cellPixels(); cw > 0 && ch > 0 {
src = scaleImage(src, cols*cw, rows*ch)
cw, ch = k.cellPixels()
}
prepare := func() prepared { return encodePoster(img, rect, cols*cw, rows*ch) }
p, ok := k.done[key]
switch {
case ok:
delete(k.done, key)
case k.background == nil:
p = prepare()
default:
if !k.preparing[key] {
k.preparing[key] = true
k.background(prepare, func(p prepared) {
delete(k.preparing, key)
if len(k.done) >= maxPrepared {
clear(k.done)
}
k.done[key] = p
})
}
return 0, nil
}
if p.err != nil {
return 0, p.err
}
return k.upload(key, p)
}
// encodePoster scales the part rect of img to w×h pixels (if both are known)
// and encodes it. Safe for use from any goroutine.
func encodePoster(img *image.RGBA, rect image.Rectangle, w, h int) prepared {
var src image.Image = img.SubImage(rect)
if w > 0 && h > 0 {
src = scaleImage(src, w, h)
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, src); err != nil {
return 0, err
return prepared{err: err}
}
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
return prepared{encoded: base64.StdEncoding.EncodeToString(data.Bytes()), w: src.Bounds().Dx(), h: src.Bounds().Dy()}
}
// upload sends a prepared poster and places it.
func (k *kittyGraphics) upload(key footprint, p prepared) (uint32, error) {
img, cols, rows := key.img, key.cols, key.rows
encoded := p.encoded
id := k.nextID
for first, rest := true, encoded; len(rest) > 0; first = false {
n := min(len(rest), kittyChunk)
@@ -143,7 +202,7 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
k.latest[img] = key
if k.debug != nil {
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, sent as %dx%d px, placed over %dx%d cells",
id, img.Bounds().Dx(), img.Bounds().Dy(), rect, src.Bounds().Dx(), src.Bounds().Dy(), cols, rows))
id, img.Bounds().Dx(), img.Bounds().Dy(), key.rect, p.w, p.h, cols, rows))
}
return id, nil
}
@@ -156,6 +215,7 @@ func (k *kittyGraphics) deleteAll() {
}
k.ids = map[footprint]uint32{}
k.latest = map[*image.RGBA]footprint{}
clear(k.done) // posters in preparation still arrive, but are not used without a draw asking for them
}
// placeholderCell returns what to draw in the cell at (row, col) of the image
+26
View File
@@ -417,3 +417,29 @@ func TestPostersAreSentAtTheBoxSize(t *testing.T) {
t.Errorf("upload without cell size: %q", msgs)
}
}
func TestPostersArePreparedInTheBackground(t *testing.T) {
var rec recorder
k := newKittyGraphics(rec.write)
var queued []func()
k.background = func(prepare func() prepared, ready func(prepared)) {
p := prepare()
queued = append(queued, func() { ready(p) })
}
img := image.NewRGBA(image.Rect(0, 0, 40, 60))
if id, err := k.show(img, img.Bounds(), 4, 3); id != 0 || err != nil || len(rec.cmds) != 0 {
t.Fatalf("first draw: id %d, err %v, %d commands", id, err, len(rec.cmds))
}
k.show(img, img.Bounds(), 4, 3) // a redraw meanwhile does not prepare it twice
if len(queued) != 1 {
t.Fatalf("%d preparations", len(queued))
}
queued[0]()
id, err := k.show(img, img.Bounds(), 4, 3)
if id == 0 || err != nil || len(rec.cmds) == 0 {
t.Fatalf("once ready: id %d, err %v, %d commands", id, err, len(rec.cmds))
}
if again, _ := k.show(img, img.Bounds(), 4, 3); again != id || len(queued) != 1 {
t.Errorf("uploaded posters are reused: id %d, %d preparations", again, len(queued))
}
}