github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/server/cover/cover.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // Package cover loads and resizes album art cover images.
     5  //
     6  // More than you ever wanted to know about image sizes:
     7  //
     8  // Web notification icons should be 192x192 per
     9  // https://developers.google.com/web/fundamentals/push-notifications/display-a-notification: "Sadly
    10  // there aren't any solid guidelines for what size image to use for an icon. Android seems to want a
    11  // 64dp image (which is 64px multiples by the device pixel ratio). If we assume the highest pixel
    12  // ratio for a device will be 3, an icon size of 192px or more is a safe bet." On Chrome OS, icons
    13  // look like they're just 58x58 on a device with a DPR of 1.6, suggesting that they're around 36x36
    14  // dp, or 72x72 on a device with a DPR of 2.
    15  //
    16  // mediaSession on Chrome for Android uses 512x512 per https://web.dev/media-session/, although
    17  // Chrome OS media notifications display album art at a substantially smaller size (128x128 at 1.6
    18  // DPR, for 80x80 dp or 160x160 with a DPR of 2. The code seems to specify that 72x72 (dp?) is the
    19  // desired size, though:
    20  // https://github.com/chromium/chromium/blob/3abe39d/components/media_message_center/media_notification_view_modern_impl.cc#L50
    21  //
    22  // In the web interface, <play-view> uses 70x70 CSS pixels for the current song's cover and
    23  // <fullscreen-overlay> uses 80x80 CSS pixels for the next song's cover. The song info dialog uses
    24  // 192x192 CSS pixels. Favicons allegedly take a wide variety of sizes:
    25  // https://stackoverflow.com/a/26807004
    26  //
    27  // In the Android client, NupActivity displays cover images at 100dp. Per
    28  // https://developer.android.com/training/multiscreen/screendensities, the highest screen density is
    29  // xxxhdpi, which looks like it's 4x (i.e. 4 pixels per dp), for 400x400. The Pixel 4a appears to
    30  // just have a device pixel ratio of 2.75, i.e. xxhdpi. It sounds like xxxhdpi resources are maybe
    31  // only used for launcher icons; 3x is realistically probably the most that needs to be handled:
    32  // https://stackoverflow.com/questions/21452353/android-xxx-hdpi-real-devices
    33  //
    34  // For Android media-session-related stuff, the framework docs are not very helpful!
    35  // https://developer.android.com/reference/kotlin/android/support/v4/media/MediaMetadataCompat#METADATA_KEY_ALBUM_ART:kotlin.String:
    36  // "The artwork should be relatively small and may be scaled down by the system if it is too large."
    37  // Thanks, I will try to make the images relatively small and not too large.
    38  //
    39  // My Android Auto head unit is only 800x480 (and probably only uses around a third of the vertical
    40  // height for album art, with a blurry, scaled-up version in the background). The most expensive
    41  // aftermarket AA units that I see right now have 1280x720 displays.
    42  //
    43  // So I think that the Android client, which downloads and caches images in a single size,
    44  // realistically just needs something like 384x384. 512x512 is probably safer to handle future
    45  // Android Auto UI changes.
    46  //
    47  // For the web interface, I don't think that there's much in the way of non-mobile devices that have
    48  // a DPR above 2 (the 2021 MacBook Pro reports 2.0 for window.devicePixelRatio, for instance).
    49  // 160x160 is probably enough for everything except the song info dialog (which can use the same
    50  // 512x512 images as Android), but I'm going to go with 256x256 for a bit of future-proofing (and
    51  // because it typically seems to be only a few KB larger than 160x160 in WebP).
    52  package cover
    53  
    54  import (
    55  	"bytes"
    56  	"context"
    57  	"errors"
    58  	"fmt"
    59  	"image"
    60  	"image/jpeg"
    61  	_ "image/png"
    62  	"io"
    63  	"io/ioutil"
    64  	"net/http"
    65  	"os"
    66  	"regexp"
    67  	"strings"
    68  	"sync"
    69  	"time"
    70  
    71  	"cloud.google.com/go/storage"
    72  
    73  	"golang.org/x/image/draw"
    74  
    75  	"google.golang.org/api/option"
    76  	"google.golang.org/appengine/v2/log"
    77  	"google.golang.org/appengine/v2/memcache"
    78  )
    79  
    80  // A single storage.Client is initialized in response to the first load() call
    81  // that needs to read from Cloud Storage and then reused. I was initially seeing
    82  // very slow NewClient() and Object() calls in load(), sometimes taking close to
    83  // a second in total. When reusing a single client, I frequently see 90-160 ms,
    84  // but the numbers are noisy enough that I'm still not completely convinced
    85  // that this helps.
    86  var client *storage.Client
    87  var clientOnce sync.Once
    88  
    89  // More superstition: https://github.com/googleapis/google-cloud-go/issues/530
    90  const grpcPoolSize = 4
    91  
    92  const (
    93  	cacheKeyPrefix  = "cover"   // memcache key prefix
    94  	cacheExpiration = time.Hour // memcache expiration
    95  )
    96  
    97  // cacheKey returns the memcache key that should be used for caching a
    98  // cover image with the supplied filename, size (i.e. width/height), and format.
    99  func cacheKey(fn string, size int, it imageType) string {
   100  	// TODO: Hash the filename?
   101  	// https://godoc.org/google.golang.org/appengine/memcache#Get says that the
   102  	// key can be at most 250 bytes.
   103  	key := fmt.Sprintf("%s-%d-", cacheKeyPrefix, size)
   104  	if it == webpType {
   105  		key += "webp-"
   106  	}
   107  	return key + fn
   108  }
   109  
   110  // OrigExt is the extension for original (non-WebP) cover images.
   111  const OrigExt = ".jpg"
   112  
   113  // WebPSizes contains the sizes for which WebP versions of images can be requested.
   114  // See the package comment for the origin of these numbers.
   115  var WebPSizes = []int{256, 512}
   116  
   117  // WebPFilename returns the filename that should be used for the WebP version of JPEG
   118  // file fn scaled to the specified size. fn can be a full path.
   119  // Given fn "foo/bar.jpg" and size 256, returns "foo/bar.256.webp".
   120  func WebPFilename(fn string, size int) string {
   121  	if strings.HasSuffix(fn, OrigExt) {
   122  		fn = fn[:len(fn)-4]
   123  	}
   124  	return fmt.Sprintf("%s.%d.webp", fn, size)
   125  }
   126  
   127  var webpRegexp = regexp.MustCompile(`(.+)\.\d+\.webp$`)
   128  
   129  // OrigFilename attempts to return the original JPEG filename for the supplied WebP cover image
   130  // (generated by WebPFilename). Given "foo/bar.256.webp", returns "foo/bar.jpeg".
   131  // fn is returned unchanged if it doesn't appear to be a generated image.
   132  func OrigFilename(fn string) string {
   133  	ms := webpRegexp.FindStringSubmatch(fn)
   134  	if ms == nil {
   135  		return fn
   136  	}
   137  	return ms[1] + OrigExt
   138  }
   139  
   140  // Scale reads the cover image at fn (corresponding to Song.CoverFilename),
   141  // scales and crops it to be a square image with the supplied width and height
   142  // size, and writes it in JPEG format to w.
   143  //
   144  // If size is zero or negative, the original (possibly non-square) cover data is written.
   145  // If webp is true, a prescaled WebP version of the image will be returned if available.
   146  // The bucket and baseURL args correspond to CoverBucket and CoverBaseURL in ServerConfig.
   147  // If w is an http.ResponseWriter, its Content-Type header will be set.
   148  // os.ErrNotExist is replied if the specified file does not exist.
   149  func Scale(ctx context.Context, bucket, baseURL, fn string,
   150  	size, quality int, webp bool, w io.Writer) error {
   151  	// If WebP was requested, try to load it first before falling back to JPEG.
   152  	// There's sadly still no native Go library for encoding to WebP (only decoding),
   153  	// so we rely on files generated by the "nup covers" command.
   154  	if webp {
   155  		log.Debugf(ctx, "Checking cache for WebP cover")
   156  		if data, _ := getCachedCover(ctx, fn, size, webpType); len(data) > 0 {
   157  			log.Debugf(ctx, "Writing %d-byte cached WebP cover", len(data))
   158  			setContentType(w, webpType)
   159  			_, err := w.Write(data)
   160  			return err
   161  		}
   162  		log.Debugf(ctx, "Loading WebP cover")
   163  		wfn := WebPFilename(fn, size)
   164  		if data, err := load(ctx, bucket, baseURL, wfn); err != nil {
   165  			log.Debugf(ctx, "Failed loading WebP cover: %v", err)
   166  		} else {
   167  			setContentType(w, webpType)
   168  			_, werr := w.Write(data)
   169  			log.Debugf(ctx, "Caching %v-byte WebP cover", len(data))
   170  			if err := setCachedCover(ctx, fn, size, webpType, data); err != nil {
   171  				log.Errorf(ctx, "Cache write failed: %v", err) // swallow error
   172  			}
   173  			return werr
   174  		}
   175  	}
   176  
   177  	log.Debugf(ctx, "Checking cache for scaled cover")
   178  	if data, _ := getCachedCover(ctx, fn, size, jpegType); len(data) > 0 {
   179  		log.Debugf(ctx, "Writing %d-byte cached scaled cover", len(data))
   180  		setContentType(w, jpegType)
   181  		_, err := w.Write(data)
   182  		return err
   183  	}
   184  
   185  	var data []byte
   186  	var err error
   187  	log.Debugf(ctx, "Checking cache for original cover")
   188  	if data, err = getCachedCover(ctx, fn, 0, jpegType); len(data) > 0 {
   189  		log.Debugf(ctx, "Got %d-byte cached original cover", len(data))
   190  	} else if err != nil {
   191  		log.Errorf(ctx, "Cache lookup failed: %v", err) // swallow error
   192  	}
   193  
   194  	if len(data) == 0 {
   195  		log.Debugf(ctx, "Loading original cover")
   196  		if data, err = load(ctx, bucket, baseURL, fn); err != nil {
   197  			return fmt.Errorf("failed to read cover: %v", err)
   198  		}
   199  		log.Debugf(ctx, "Caching %v-byte original cover", len(data))
   200  		if err = setCachedCover(ctx, fn, 0, jpegType, data); err != nil {
   201  			log.Errorf(ctx, "Cache write failed: %v", err) // swallow error
   202  		}
   203  	}
   204  
   205  	if size <= 0 {
   206  		log.Debugf(ctx, "Writing %d-byte original cover", len(data))
   207  		setContentType(w, jpegType)
   208  		_, err = w.Write(data)
   209  		return err
   210  	}
   211  
   212  	log.Debugf(ctx, "Decoding %v bytes", len(data))
   213  	src, _, err := image.Decode(bytes.NewBuffer(data))
   214  	if err != nil {
   215  		return err
   216  	}
   217  
   218  	// Crop the source image rect if it isn't square.
   219  	sr := src.Bounds()
   220  	if sr.Dx() > sr.Dy() {
   221  		sr.Min.X += (sr.Dx() - sr.Dy()) / 2
   222  		sr.Max.X = sr.Min.X + sr.Dy()
   223  	} else if sr.Dy() > sr.Dx() {
   224  		sr.Min.Y += (sr.Dy() - sr.Dx()) / 2
   225  		sr.Max.Y = sr.Min.Y + sr.Dx()
   226  	}
   227  
   228  	// TODO: Would it be better to never upscale?
   229  
   230  	log.Debugf(ctx, "Scaling from %vx%v to %vx%v", sr.Dx(), sr.Dy(), size, size)
   231  	dr := image.Rect(0, 0, size, size)
   232  	dst := image.NewRGBA(dr)
   233  	// draw.CatmullRom seems to be very slow. I've seen a Scale call from
   234  	// 1200x1200 to 512x512 take 908 ms on App Engine.
   235  	draw.ApproxBiLinear.Scale(dst, dr, src, sr, draw.Src, nil)
   236  
   237  	log.Debugf(ctx, "JPEG-encoding scaled image")
   238  	setContentType(w, jpegType)
   239  	var b bytes.Buffer
   240  	w = io.MultiWriter(w, &b)
   241  	if err := jpeg.Encode(w, dst, &jpeg.Options{Quality: quality}); err != nil {
   242  		return err
   243  	}
   244  	log.Debugf(ctx, "Caching %v-byte scaled cover", b.Len())
   245  	if err := setCachedCover(ctx, fn, size, jpegType, b.Bytes()); err != nil {
   246  		log.Errorf(ctx, "Cache write failed: %v", err) // swallow error
   247  	}
   248  	return nil
   249  }
   250  
   251  // load loads and returns the cover image with the supplied original filename (see Song.CoverFilename).
   252  func load(ctx context.Context, bucket, baseURL, fn string) ([]byte, error) {
   253  	var r io.ReadCloser
   254  	if bucket != "" {
   255  		// It would seem more reasonable to call NewClient from an init()
   256  		// function instead, but that produces an error like the following:
   257  		//
   258  		//   dialing: google: could not find default credentials. See
   259  		//   https://developers.google.com/accounts/docs/application-default-credentials for more information.
   260  		//
   261  		// This happens regardless of whether I pass context.Background() or
   262  		// appengine.BackgroundContext(). It feels wrong to use the credentials
   263  		// from the first request for all later requests, but it seems to work.
   264  		// Requests are only accepted from a specific list of users and are all
   265  		// satisfied using the same GCS bucket, so hopefully there are no
   266  		// security implications from doing this.
   267  		var err error
   268  		clientOnce.Do(func() {
   269  			log.Debugf(ctx, "Initializing storage client")
   270  			client, err = storage.NewClient(ctx, option.WithGRPCConnectionPool(grpcPoolSize))
   271  		})
   272  		if err != nil {
   273  			return nil, err
   274  		}
   275  		log.Debugf(ctx, "Opening object %q from bucket %q", fn, bucket)
   276  		if r, err = client.Bucket(bucket).Object(fn).NewReader(ctx); err == storage.ErrObjectNotExist {
   277  			return nil, os.ErrNotExist
   278  		} else if err != nil {
   279  			return nil, err
   280  		}
   281  	} else if baseURL != "" {
   282  		url := baseURL + fn
   283  		log.Debugf(ctx, "Opening %v", url)
   284  		resp, err := http.Get(url)
   285  		if err != nil {
   286  			return nil, err
   287  		} else if resp.StatusCode >= 300 {
   288  			resp.Body.Close()
   289  			if resp.StatusCode == 404 {
   290  				return nil, os.ErrNotExist
   291  			}
   292  			return nil, fmt.Errorf("server replied with %q", resp.Status)
   293  		}
   294  		r = resp.Body
   295  	} else {
   296  		return nil, errors.New("neither CoverBucket nor CoverBaseURL is set")
   297  	}
   298  	defer r.Close()
   299  
   300  	log.Debugf(ctx, "Reading cover data")
   301  	return ioutil.ReadAll(r)
   302  }
   303  
   304  // setCachedCover caches a cover image with the supplied filename, requested size,
   305  // format, and raw data. size should be 0 when caching the original image.
   306  func setCachedCover(ctx context.Context, fn string, size int, it imageType, data []byte) error {
   307  	return memcache.Set(ctx, &memcache.Item{
   308  		Key:        cacheKey(fn, size, it),
   309  		Value:      data,
   310  		Expiration: cacheExpiration,
   311  	})
   312  }
   313  
   314  // getCachedCover attempts to look up raw data for the cover image with the supplied
   315  // filename, size, and format. If the image isn't present, both the returned byte slice
   316  // and the error are nil.
   317  func getCachedCover(ctx context.Context, fn string, size int, it imageType) ([]byte, error) {
   318  	item, err := memcache.Get(ctx, cacheKey(fn, size, it))
   319  	if err == memcache.ErrCacheMiss {
   320  		return nil, nil
   321  	} else if err != nil {
   322  		return nil, err
   323  	}
   324  	return item.Value, nil
   325  }
   326  
   327  type imageType string
   328  
   329  const (
   330  	jpegType imageType = "image/jpeg"
   331  	webpType imageType = "image/webp"
   332  )
   333  
   334  // setContentType sets w's Content-Type to it if w is an http.ResponseWriter.
   335  func setContentType(w io.Writer, it imageType) {
   336  	if rw, ok := w.(http.ResponseWriter); ok {
   337  		rw.Header().Set("Content-Type", string(it))
   338  	}
   339  }