Real-image posters: scale to the box's pixel size

Ghostty shows an image smaller than its placement at its own size, so
library posters (Radarr's poster-500 is 333x500, not 500x750) came out
smaller than the 426x640 remote ones. Images are now scaled (bilinear)
to exactly cols*cellWidth x rows*cellHeight pixels before upload, and
library titles use the original poster for real images. The disk cache
stores posters as decoded (JPEG) instead of the possibly large originals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:25:45 +02:00
co-authored by Claude Opus 5.5
parent 786e783951
commit 15e97f4582
8 changed files with 100 additions and 25 deletions
+19 -5
View File
@@ -32,11 +32,12 @@ type view struct {
// App is the top-level arr-tui application.
type App struct {
app *tview.Application
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists, casts
people *peopleCache // casts loaded from Trakt
disk *diskCache // posters and casts kept between runs
cellW, cellH int // size of a terminal cell in pixels, 0 if unknown
app *tview.Application
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists, casts
people *peopleCache // casts loaded from Trakt
disk *diskCache // posters and casts kept between runs
// Called (on the UI goroutine) a moment after a release was grabbed.
afterGrab []func()
// The Movies and Series views, to refresh their Trakt lists.
@@ -218,6 +219,7 @@ func New(cfg *config.Config) *App {
s.HideCursor()
a.screen = s
a.cellAspect = cellAspectOf(s)
a.cellW, a.cellH = cellPixelsOf(s)
a.noteWindow(s)
return false
})
@@ -240,6 +242,7 @@ func New(cfg *config.Config) *App {
// helps to find out why a poster is drawn wrongly in some terminal.
func (a *App) newGraphics() *kittyGraphics {
k := newKittyGraphics(a.writeTerminal)
k.cellPixels = func() (int, int) { return a.cellW, a.cellH }
switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); {
case v == "":
case strings.Contains(v, "/"):
@@ -298,6 +301,17 @@ func (a *App) writeTerminal(b []byte) error {
// cellAspectOf measures the height of a terminal cell divided by its width in
// pixels; 2 when the terminal does not report pixel sizes.
// cellPixelsOf returns the size of a terminal cell in pixels, 0, 0 when the
// terminal does not say.
func cellPixelsOf(s tcell.Screen) (w, h int) {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 {
return ws.PixelWidth / ws.Width, ws.PixelHeight / ws.Height
}
}
return 0, 0
}
func cellAspectOf(s tcell.Screen) float64 {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil {
+9 -4
View File
@@ -6,9 +6,9 @@ import (
"fmt"
"image"
"image/color"
_ "image/gif" // poster formats
_ "image/jpeg" // (Radarr and Sonarr store the provider's bytes under a .jpg name,
_ "image/png" // whatever format they are in)
_ "image/gif" // poster formats (Radarr and Sonarr store the provider's bytes
"image/jpeg" // under a .jpg name, whatever format they are in); jpeg also
_ "image/png" // encodes the copies kept on disk
_ "golang.org/x/image/webp"
"net/url"
@@ -563,7 +563,12 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
img, err = decodeCover(data, maxHeight)
}
if err == nil {
a.disk.writePoster(ref.key, large, data)
// Stored as decoded (at most maxHeight tall), not as downloaded:
// originals can be megabytes.
var shrunk bytes.Buffer
if jpeg.Encode(&shrunk, img, &jpeg.Options{Quality: 90}) == nil {
a.disk.writePoster(ref.key, large, shrunk.Bytes())
}
}
}
a.post(func() {
+27 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"fmt"
xdraw "golang.org/x/image/draw"
"image"
"image/png"
"strings"
@@ -43,6 +44,11 @@ type kittyGraphics struct {
holdUntil time.Time // no real images until then (the window is being resized)
debug func(string) // reports what is uploaded, if set
// cellPixels returns the size of a cell in pixels (0 if unknown): images are
// scaled to exactly fill their cells, because terminals do not all enlarge
// 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)
}
// settleDelay is how long the window size must stay the same before real
@@ -92,9 +98,15 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
if id, ok := k.ids[key]; ok {
return id, nil
}
var src image.Image = img.SubImage(rect)
if k.cellPixels != nil {
if cw, ch := k.cellPixels(); cw > 0 && ch > 0 {
src = scaleImage(src, cols*cw, rows*ch)
}
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, img.SubImage(rect)); err != nil {
if err := enc.Encode(&data, src); err != nil {
return 0, err
}
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
@@ -130,7 +142,8 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
k.ids[key] = id
k.latest[img] = key
if k.debug != nil {
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows))
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))
}
return id, nil
}
@@ -175,3 +188,15 @@ func kittyTerminal(env func(string) string) bool {
}
return false
}
// scaleImage resizes src to w×h pixels, smoothly (bilinear) both up and down.
// Sizes beyond 4000 pixels are not made (a misreported cell size).
func scaleImage(src image.Image, w, h int) image.Image {
b := src.Bounds()
if w <= 0 || h <= 0 || w > 4000 || h > 4000 || (w == b.Dx() && h == b.Dy()) {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, b, xdraw.Src, nil)
return dst
}
+24
View File
@@ -393,3 +393,27 @@ func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
t.Error("a known terminal that answers gets real images")
}
}
func TestPostersAreSentAtTheBoxSize(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 333, 500)) // Radarr's "500" copy, smaller than the box
var msgs []string
k := newKittyGraphics(func([]byte) error { return nil })
k.debug = func(m string) { msgs = append(msgs, m) }
k.cellPixels = func() (int, int) { return 22, 48 } // a high-resolution screen
if _, err := k.show(img, img.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 682x1008 px") {
t.Errorf("upload: %q", msgs)
}
// Without the cell size the image goes as it is.
k.cellPixels = func() (int, int) { return 0, 0 }
img2 := image.NewRGBA(image.Rect(0, 0, 426, 640))
msgs = nil
if _, err := k.show(img2, img2.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 426x640 px") {
t.Errorf("upload without cell size: %q", msgs)
}
}