diff --git a/README.md b/README.md index b7537de..ce21ef4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ again when you get more games or want to update the category overlays. * *(optional)* Append `-skip` to skip searching and downloading parts from certain artwork elements. Available choices : `-skipbanner`,`-skipcover`,`-skiphero`,`-skiplogo`. For example: Appending `-skiplogo -skipbanner` will prevent steamgrid to search and download logo and banners for any games. * *(optional)* Append `-skipsteam` to not download the default artworks from Steam. * *(optional)* Append `-skipgoogle` to skip search and downloads from Google. + * *(optional)* Append `--webpasapng` to convert all WEBP animations to APNG - they are displayed faster but take more time and memory to convert + * *(optional)* Append `--coverwebpasapng` to convert covers and banners' WEBP animations to APNG - skip hero and logo as they are larger + * *(optional)* Append `--convertmaxmem ` to limit memory usage for conversion from WEBP to APNG, if it would go over the limit, the conversion will be skipped. * *(tip)* Run with `--help` to see all available options again. 6. Read the report and open Steam in grid view to check the results. diff --git a/download.go b/download.go index 8babcb0..42b6cd3 100644 --- a/download.go +++ b/download.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/kmicki/webpanimation" "go.deanishe.net/fuzzy" ) @@ -445,11 +446,30 @@ func DownloadImage(gridDir string, game *Game, artStyle string, artStyleExtensio response.Body.Close() // catch false aspect ratios - image, _, err := image.Decode(bytes.NewBuffer(imageBytes)) + var webpImage *webpanimation.WebpAnimationDecoded + defer func() { + if webpImage != nil { + webpanimation.ReleaseDecoder(webpImage) + } + }() + var dlImage image.Image + if strings.Contains(contentType, "webp") { + webpImage, err = webpanimation.GetInfo(bytes.NewBuffer(imageBytes)) + if err == nil { + webpFrame, ok := webpanimation.GetNextFrame(webpImage) + if ok { + dlImage = webpFrame.Image + } else { + err = errors.New("can't read the first frame of WEBP animation") + } + } + } else { + dlImage, _, err = image.Decode(bytes.NewBuffer(imageBytes)) + } if err != nil { return "", err } - imageSize := image.Bounds().Max + imageSize := dlImage.Bounds().Max if artStyle == "Banner" && imageSize.X < imageSize.Y { return "", nil } else if artStyle == "Cover" && imageSize.X > imageSize.Y { diff --git a/overlays.go b/overlays.go index 3807a9f..7075e85 100644 --- a/overlays.go +++ b/overlays.go @@ -2,7 +2,10 @@ package main import ( "bytes" + "errors" + "fmt" "image" + "runtime/debug" // "image/draw" "image/jpeg" @@ -12,7 +15,8 @@ import ( "path/filepath" "strings" - "github.com/kettek/apng" + "github.com/kmicki/apng" + "github.com/kmicki/webpanimation" "golang.org/x/image/draw" ) @@ -69,28 +73,86 @@ func LoadOverlays(dir string, artStyles map[string][]string) (overlays map[strin // ApplyOverlay to the game image, depending on the category. The // resulting image is saved over the original. -func ApplyOverlay(game *Game, overlays map[string]image.Image, artStyleExtensions []string) error { +func ApplyOverlay(game *Game, overlays map[string]image.Image, artStyleExtensions []string, convertWebpToApng bool, convertWebpToApngCoversBanners bool, maxMem uint64) error { if game.CleanImageBytes == nil || len(game.Tags) == 0 { return nil } + buf := new(bytes.Buffer) + bufReady := false + var errBuff error + errBuff = nil + + convertWebpToApng = convertWebpToApng || (convertWebpToApngCoversBanners && + (strings.Contains(artStyleExtensions[1], "cover")) || (strings.Contains(artStyleExtensions[1], "banner"))) + isApng := false + isWebp := false + formatFound := false + + var err error + var webpImage *webpanimation.WebpAnimationDecoded + defer func() { + if webpImage != nil { + webpanimation.ReleaseDecoder(webpImage) + } + }() + + // Try WEBP var gameImage image.Image - apngImage, err := apng.DecodeAll(bytes.NewBuffer(game.CleanImageBytes)) + webpImage, err = webpanimation.GetInfo(bytes.NewBuffer(game.CleanImageBytes)) if err == nil { - if len(apngImage.Frames) > 1 { - isApng = true + formatFound = true + if err != nil { + formatFound = false + } else if webpImage.FrameCnt <= 1 { + webpFrame, ok := webpanimation.GetNextFrame(webpImage) + if ok { + gameImage = webpFrame.Image + } else { + err = errors.New("can't get the first frame of single-frame WEBP image") + } } else { - gameImage = apngImage.Frames[0].Image + isWebp = true + memNeeded := uint64(webpImage.Width) * uint64(webpImage.Height) * 4 * uint64(webpImage.FrameCnt) + if convertWebpToApng && maxMem > 0 { + if memNeeded > maxMem { + fmt.Println("WEBP animation too big to convert to APNG. Leaving WEBP.") + convertWebpToApng = false + } else if memNeeded > maxMem/2 { + // free up memory for big conversion + debug.FreeOSMemory() + } + } } - } else { - gameImage, _, err = image.Decode(bytes.NewBuffer(game.CleanImageBytes)) - if err != nil { - return err + } + + // Try APNG + var apngImage apng.APNG + if !formatFound { + apngImage, err = apng.DecodeAll(bytes.NewBuffer(game.CleanImageBytes)) + if err == nil { + if len(apngImage.Frames) > 1 { + isApng = true + } else { + gameImage = apngImage.Frames[0].Image + } + } else { + gameImage, _, err = image.Decode(bytes.NewBuffer(game.CleanImageBytes)) + if err != nil { + return err + } } } applied := false + var webpanim *webpanimation.WebpAnimation + defer func() { + if webpanim != nil { + webpanim.ReleaseMemory() + //fmt.Println("WEBPAnim Memory Released") + } + }() for _, tag := range game.Tags { // Normalize tag name by lower-casing it and remove trailing "s" from // plurals. Also, <, > and / are replaced with - because you can't have @@ -108,18 +170,20 @@ func ApplyOverlay(game *Game, overlays map[string]image.Image, artStyleExtension overlaySize := overlayImage.Bounds().Max if isApng { + fmt.Printf("Apply Overlay to APNG.") originalSize := apngImage.Frames[0].Image.Bounds().Max + // Scale overlay to imageSize so the images won't get that huge… + overlayScaled := image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) + if originalSize.X != overlaySize.X && originalSize.Y != overlaySize.Y { + // https://godoc.org/golang.org/x/image/draw#Kernel.Scale + draw.ApproxBiLinear.Scale(overlayScaled, overlayScaled.Bounds(), overlayImage, overlayImage.Bounds(), draw.Over, nil) + } else { + draw.Draw(overlayScaled, overlayScaled.Bounds(), overlayImage, image.Point{}, draw.Src) + } + for i, frame := range apngImage.Frames { - // Scale overlay to imageSize so the images won't get that huge… - overlayScaled := image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) result := image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) - if originalSize.X != overlaySize.X && originalSize.Y != overlaySize.Y { - // https://godoc.org/golang.org/x/image/draw#Kernel.Scale - draw.ApproxBiLinear.Scale(overlayScaled, overlayScaled.Bounds(), overlayImage, overlayImage.Bounds(), draw.Over, nil) - } else { - draw.Draw(overlayScaled, overlayScaled.Bounds(), overlayImage, image.ZP, draw.Src) - } // No idea why these offsets are negative: draw.Draw(result, result.Bounds(), frame.Image, image.Point{0 - frame.XOffset, 0 - frame.YOffset}, draw.Over) draw.Draw(result, result.Bounds(), overlayScaled, image.Point{0, 0}, draw.Over) @@ -127,9 +191,90 @@ func ApplyOverlay(game *Game, overlays map[string]image.Image, artStyleExtension apngImage.Frames[i].XOffset = 0 apngImage.Frames[i].YOffset = 0 apngImage.Frames[i].BlendOp = apng.BLEND_OP_OVER + fmt.Printf("\rApply Overlay to APNG. Overlayed frame %8d/%d", i, len(apngImage.Frames)) } applied = true + fmt.Printf("\rOverlay applied to %v frames of APNG \n", len(apngImage.Frames)) + } else if isWebp { + fmt.Printf("Apply Overlay to WEBP.") + if webpImage == nil { + fmt.Printf("\rWebPImage not initialized.\n") + continue + } + originalSize := image.Point{webpImage.Width, webpImage.Height} + var webpConfig webpanimation.WebPConfig + var encoder *apng.FrameByFrameEncoder + if convertWebpToApng { + bufReady = true + encoder = apng.InitializeEncoding(buf, uint32(webpImage.FrameCnt), uint(webpImage.LoopCount)) + } else { + webpanim = webpanimation.NewWebpAnimation(webpImage.Width, webpImage.Height, webpImage.LoopCount) + webpanim.WebPAnimEncoderOptions.SetKmin(9) + webpanim.WebPAnimEncoderOptions.SetKmax(17) + webpConfig = webpanimation.NewWebpConfig() + webpConfig.SetLossless(1) + } + + // Scale overlay to imageSize so the images won't get that huge… + overlayScaled := image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) + var result *image.RGBA + if originalSize.X != overlaySize.X && originalSize.Y != overlaySize.Y { + // https://godoc.org/golang.org/x/image/draw#Kernel.Scale + draw.ApproxBiLinear.Scale(overlayScaled, overlayScaled.Bounds(), overlayImage, overlayImage.Bounds(), draw.Over, nil) + } else { + draw.Draw(overlayScaled, overlayScaled.Bounds(), overlayImage, image.Point{}, draw.Src) + } + + i := 0 + var lastTimestamp int + frame, ok := webpanimation.GetNextFrame(webpImage) + for ok { + if v, o := frame.Image.(*image.RGBA); o { + result = v + } else { + result = image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) + draw.Draw(result, result.Bounds(), frame.Image, image.Point{0, 0}, draw.Over) + } + draw.Draw(result, result.Bounds(), overlayScaled, image.Point{0, 0}, draw.Over) + + var delay uint16 + if i == 0 { + delay = 0 + } else { + delay = uint16(frame.Timestamp - lastTimestamp) + } + lastTimestamp = frame.Timestamp + + if convertWebpToApng { + apngFrame := apng.Frame{ + Image: result, + IsDefault: false, + XOffset: 0, + YOffset: 0, + DisposeOp: apng.DISPOSE_OP_NONE, + BlendOp: apng.BLEND_OP_OVER, + DelayNumerator: delay, + DelayDenominator: 1000, + } + encoder.EncodeFrame(apngFrame) + + fmt.Printf("\rApply Overlay to WEBP as APNG. Overlayed frame %8d/%d", i, webpImage.FrameCnt) + } else { + err = webpanim.AddFrame(result, frame.Timestamp, webpConfig) + fmt.Printf("\rApply Overlay to WEBP. Overlayed frame %8d/%d", i, webpImage.FrameCnt) + } + i++ + frame, ok = webpanimation.GetNextFrame(webpImage) + } + applied = true + if convertWebpToApng { + errBuff = encoder.Finish() + fmt.Printf("\rOverlay applied to %v frames of WEBP as APNG \n", webpImage.FrameCnt) + } else { + fmt.Printf("\rOverlay applied to %v frames of WEBP \n", webpImage.FrameCnt) + } } else { + fmt.Printf("Apply Overlay to Single Image.") originalSize := gameImage.Bounds().Max // We expect overlays in the correct format so we have to scale the image if it doesn't fit @@ -144,21 +289,82 @@ func ApplyOverlay(game *Game, overlays map[string]image.Image, artStyleExtension draw.Draw(result, result.Bounds(), overlayImage, image.Point{0, 0}, draw.Over) gameImage = result applied = true + fmt.Printf("\rApplied Overlay to Single Image.\n") } } if !applied { - return nil + if isWebp && convertWebpToApng { + bufReady = true + + // Convert to APNG without overlay + fmt.Printf("Convert WEBP to APNG.") + if webpImage == nil { + fmt.Printf("\rWebPImage not initialized.\n") + return nil + } + originalSize := image.Point{webpImage.Width, webpImage.Height} + encoder := apng.InitializeEncoding(buf, uint32(webpImage.FrameCnt), uint(webpImage.LoopCount)) + + i := 0 + var lastTimestamp int + frame, ok := webpanimation.GetNextFrame(webpImage) + var result *image.RGBA + for ok { + if v, o := frame.Image.(*image.RGBA); o { + result = v + } else { + result = image.NewRGBA(image.Rect(0, 0, originalSize.X, originalSize.Y)) + draw.Draw(result, result.Bounds(), frame.Image, image.Point{0, 0}, draw.Over) + } + + var delay uint16 + if i == 0 { + delay = 0 + } else { + delay = uint16(frame.Timestamp - lastTimestamp) + } + lastTimestamp = frame.Timestamp + + apngFrame := apng.Frame{ + Image: result, + IsDefault: false, + XOffset: 0, + YOffset: 0, + DisposeOp: apng.DISPOSE_OP_NONE, + BlendOp: apng.BLEND_OP_OVER, + DelayNumerator: delay, + DelayDenominator: 1000, + } + encoder.EncodeFrame(apngFrame) + + fmt.Printf("\rConvert to WEBP as APNG. Frame %8d/%d", i, webpImage.FrameCnt) + i++ + frame, ok = webpanimation.GetNextFrame(webpImage) + } + + errBuff = encoder.Finish() + applied = true + fmt.Printf("\rConverted %v frames from WEBP to APNG \n", webpImage.FrameCnt) + } else { + return nil + } } - buf := new(bytes.Buffer) - if game.ImageExt == ".jpg" || game.ImageExt == ".jpeg" { - err = jpeg.Encode(buf, gameImage, &jpeg.Options{95}) - } else if game.ImageExt == ".png" && isApng { - err = apng.Encode(buf, apngImage) - } else if game.ImageExt == ".png" { - err = png.Encode(buf, gameImage) + if bufReady { + err = errBuff + } else { + if game.ImageExt == ".jpg" || game.ImageExt == ".jpeg" { + err = jpeg.Encode(buf, gameImage, &jpeg.Options{Quality: 95}) + } else if (game.ImageExt == ".png" && isApng) || (isWebp && convertWebpToApng) { + err = apng.Encode(buf, apngImage) + } else if (game.ImageExt == ".png" && !isWebp) || (formatFound && !isWebp) { + err = png.Encode(buf, gameImage) + } else if isWebp { + err = webpanim.Encode(buf) + } } + if err != nil { return err } diff --git a/steamgrid.go b/steamgrid.go index a0fe6c9..8e59220 100644 --- a/steamgrid.go +++ b/steamgrid.go @@ -11,7 +11,9 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strconv" + "strings" "time" ) @@ -27,6 +29,24 @@ func main() { startApplication() } +func bToMb(b uint64) uint64 { + return b / 1024 / 1024 +} + +func printMemStats(endline ...bool) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + // For info on each, see: https://golang.org/pkg/runtime/#MemStats + fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc)) + fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc)) + //fmt.Printf("\tSys = %v MiB", bToMb(m.Sys)) + //fmt.Printf("\tNumGC = %v", m.NumGC) + + if len(endline) == 0 || endline[0] { + fmt.Printf("\n") + } +} + func startApplication() { steamGridDBApiKey := flag.String("steamgriddb", "", "Your personal SteamGridDB api key, get one here: https://www.steamgriddb.com/profile/preferences") IGDBSecret := flag.String("igdbsecret", "", "Your personal IGDB api key, get one here: https://api.igdb.com/signup") @@ -51,6 +71,9 @@ func startApplication() { nonSteamOnly := flag.Bool("nonsteamonly", false, "Only search artwork for Non-Steam-Games") appIDs := flag.String("appids", "", "Comma separated list of appIds that should be processed") onlyMissingArtwork := flag.Bool("onlymissingartwork", false, "Only download artworks missing on the official servers") + convertWebpToApng := flag.Bool("webpasapng", false, "Convert WEBP animations to APNG.\nMakes them load faster in Steam but takes longer to apply.") + convertWebpToApngCoversBanners := flag.Bool("coverwebpasapng", false, "Convert only WEBP animations to APNG (only covers and banners)\nAvoid Hero and Logo which may be too memory and time consuming to apply.") + maxMemoryForConvert := flag.Int("convertmaxmem", 0, "Convert only those animations that will use less memory (in GB) than specified here. By default there is no limit.") flag.Parse() if flag.NArg() == 1 { steamDir = &flag.Args()[0] @@ -59,6 +82,12 @@ func startApplication() { os.Exit(1) } + var maxMem uint64 + maxMem = 0 + if *maxMemoryForConvert > 0 { + maxMem = uint64(*maxMemoryForConvert) * 1024 * 1024 * 1024 + } + // Process command line flags steamGridDBBannerFilter := "?styles=" + *steamGridDBStyles + "&types=" + *steamGridDBTypes + "&nsfw=" + *steamGridDBNsfw + "&humor=" + *steamGridDBHumor + "&dimensions=" + *steamGridDBBannerDimensions steamGridDBCoverFilter := "?styles=" + *steamGridDBStyles + "&types=" + *steamGridDBTypes + "&nsfw=" + *steamGridDBNsfw + "&humor=" + *steamGridDBHumor + "&dimensions=" + *steamGridDBCoverDimensions @@ -180,6 +209,7 @@ func startApplication() { } else { name = "unknown game with id " + game.ID } + fmt.Printf("Processing %v (%v/%v)\n", name, i, len(games)) for artStyle, artStyleExtensions := range artStyles { @@ -239,7 +269,7 @@ func startApplication() { // Hero: favorites.hero.png // Logo: favorites.logo.png /////////////////////// - err := ApplyOverlay(game, overlays, artStyleExtensions) + err := ApplyOverlay(game, overlays, artStyleExtensions, *convertWebpToApng, *convertWebpToApngCoversBanners, maxMem) if err != nil { print(err.Error(), "\n") failedGames[artStyle] = append(failedGames[artStyle], game) @@ -259,6 +289,10 @@ func startApplication() { errorAndExit(err) } + if strings.Contains(game.ImageExt, "webp") { + game.ImageExt = ".png" + } + imagePath := filepath.Join(gridDir, game.ID+artStyleExtensions[0]+game.ImageExt) err = ioutil.WriteFile(imagePath, game.OverlayImageBytes, 0666) @@ -278,6 +312,9 @@ func startApplication() { if err != nil { fmt.Printf("Failed to write image for %v (%v) because: %v\n", game.Name, artStyle, err.Error()) } + + game.OverlayImageBytes = nil + game.CleanImageBytes = nil } } }