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:
+11
-9
@@ -18,8 +18,8 @@ const maxImageBytes = 8 << 20
|
||||
// function that downloads it, or ok=false when there is none.
|
||||
//
|
||||
// Library items have a copy on the Radarr/Sonarr server, which is fetched
|
||||
// with the API key, preferring a scaled variant: -250 (250×375), or -500
|
||||
// (500×750) when fetch is asked for a large poster. Titles that are not in
|
||||
// with the API key: the copy 250 pixels tall (-250), or the original when
|
||||
// fetch is asked for a large poster. Titles that are not in
|
||||
// the library yet only have the remote URL of the metadata provider; that is
|
||||
// fetched without the API key.
|
||||
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context, large bool) ([]byte, error), ok bool) {
|
||||
@@ -50,10 +50,11 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// localPoster fetches a poster from the instance: the scaled variant (-500
|
||||
// when large, else -250), then the original. When the instance answers with a page instead of an image (it
|
||||
// wants a login for /MediaCover) the API key is also tried as a query
|
||||
// parameter, which is the other way Radarr and Sonarr accept it.
|
||||
// localPoster fetches a poster from the instance: the small variant (-250),
|
||||
// then the original; when large, only the original. When the instance
|
||||
// answers with a page instead of an image (it wants a login for /MediaCover)
|
||||
// the API key is also tried as a query parameter, which is the other way
|
||||
// Radarr and Sonarr accept it.
|
||||
func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]byte, error) {
|
||||
try := func(ref string) ([]byte, error) {
|
||||
data, err := c.GetBytes(ctx, ref)
|
||||
@@ -65,11 +66,12 @@ func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]b
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
width := 250
|
||||
// Radarr/Sonarr keep copies 250 and 500 pixels tall; even the larger one
|
||||
// is smaller than a real image is shown, so those take the original.
|
||||
if large {
|
||||
width = 500
|
||||
return try(local)
|
||||
}
|
||||
if scaled := scaledVariant(local, width); scaled != local {
|
||||
if scaled := scaledVariant(local, 250); scaled != local {
|
||||
data, err := try(scaled)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestScaledVariant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargePosterUsesThe500Variant(t *testing.T) {
|
||||
func TestLargePosterUsesTheOriginal(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
@@ -39,7 +39,7 @@ func TestLargePosterUsesThe500Variant(t *testing.T) {
|
||||
if _, err := fetch(context.Background(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(paths, " ") != "/MediaCover/1/poster-500.jpg /MediaCover/1/poster-250.jpg" {
|
||||
if strings.Join(paths, " ") != "/MediaCover/1/poster.jpg /MediaCover/1/poster-250.jpg" {
|
||||
t.Errorf("requests = %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-5
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user