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 }