Skip to content

Commit

Permalink
Support advanced tail/head customizations (#25)
Browse files Browse the repository at this point in the history
See #25
  • Loading branch information
torbensky authored Feb 28, 2022
1 parent 8a8eb3e commit 3c916f5
Show file tree
Hide file tree
Showing 19 changed files with 874 additions and 282 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

bin/
media/assets/downloads/
render/media/assets/downloads/

# General
.DS_Store
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.17

require (
github.com/disintegration/imaging v1.6.2
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4
github.com/fogleman/gg v1.3.0
github.com/julienschmidt/httprouter v1.3.0
github.com/patrickmn/go-cache v2.1.0+incompatible
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
Expand Down
33 changes: 33 additions & 0 deletions imagetest/imagetest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package imagetest

import (
"fmt"
"image"
"image/color"
"testing"

"github.com/stretchr/testify/require"
)

// Equal ensures that the two images are equivalent.
// Equivalent means that both images have the same bounds and each pixel has the same RGBA values.
func Equal(t *testing.T, a, b image.Image) {
require.Equal(t, a.Bounds().Min.X, b.Bounds().Min.X)
require.Equal(t, a.Bounds().Min.Y, b.Bounds().Min.Y)
require.Equal(t, a.Bounds().Max.X, b.Bounds().Max.X)
require.Equal(t, a.Bounds().Max.Y, b.Bounds().Max.Y)

for x := 0; x < a.Bounds().Max.X; x++ {
for y := 0; y < a.Bounds().Max.Y; y++ {
c1 := a.At(x, y)
c2 := b.At(x, y)
sameColor(t, c1, c2, x, y)
}
}
}

func sameColor(t *testing.T, c1, c2 color.Color, x, y int) {
r1, g1, b1, a1 := c1.RGBA()
r2, g2, b2, a2 := c2.RGBA()
require.True(t, r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2, fmt.Sprintf("%v != %v at pixel (%d,%d)", c1, c2, x, y))
}
23 changes: 2 additions & 21 deletions inkscape/wrapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package inkscape_test
import (
"errors"
"image"
"image/color"
"io/fs"
"os"
"testing"

"github.com/BattlesnakeOfficial/exporter/imagetest"
"github.com/BattlesnakeOfficial/exporter/inkscape"
"github.com/stretchr/testify/require"
)
Expand All @@ -20,7 +20,7 @@ func TestSVGToPNG(t *testing.T) {
require.Equal(t, 100, got.Bounds().Max.X)
require.Equal(t, 100, got.Bounds().Max.Y)
want := loadTestImage(t)
same(t, want, got)
imagetest.Equal(t, want, got)

// client should validate width/height
_, err = client.SVGToPNG("testdata/example.svg", 0, 100)
Expand Down Expand Up @@ -52,25 +52,6 @@ func TestIsAvailable(t *testing.T) {
require.False(t, client.IsAvailable())
}

func same(t *testing.T, a, b image.Image) {
require.Equal(t, a.Bounds().Max.X, b.Bounds().Max.X)
require.Equal(t, a.Bounds().Max.Y, b.Bounds().Max.Y)

for x := 0; x < a.Bounds().Max.X; x++ {
for y := 0; y < a.Bounds().Min.Y; y++ {
c1 := a.At(x, y)
c2 := b.At(x, y)
sameColor(t, c1, c2)
}
}
}

func sameColor(t *testing.T, c1, c2 color.Color) {
r1, g1, b1, a1 := c1.RGBA()
r2, g2, b2, a2 := c1.RGBA()
require.True(t, r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2)
}

func loadTestImage(t *testing.T) image.Image {
f, err := os.Open("testdata/example.png")
require.NoError(t, err)
Expand Down
14 changes: 5 additions & 9 deletions media/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"image"
"image/color"
"io/ioutil"
"net/http"
"time"
Expand All @@ -15,11 +16,6 @@ import (
var ErrNotFound = errors.New("resource not found")
var mediaServerURL = "https://media.battlesnake.com"

const (
fallbackHeadID = "default"
fallbackTailID = "default"
)

// Create an in-mem media cache (6 hours, evicting every 10 mins)
var mediaCache = cache.New(6*60*time.Minute, 10*time.Minute)

Expand Down Expand Up @@ -72,12 +68,12 @@ func GetTailSVG(id string) (string, error) {
return getCachedMediaResource(tailSVGPath(id))
}

func GetHeadPNG(id string, w, h int) (image.Image, error) {
return getSVGImageWithFallback(headSVGPath(id), headSVGPath(fallbackHeadID), w, h)
func GetHeadPNG(id string, w, h int, c color.Color) (image.Image, error) {
return getSnakeSVGImage(headSVGPath(id), fallbackHead, w, h, c)
}

func GetTailPNG(id string, w, h int) (image.Image, error) {
return getSVGImageWithFallback(tailSVGPath(id), tailSVGPath(fallbackTailID), w, h)
func GetTailPNG(id string, w, h int, c color.Color) (image.Image, error) {
return getSnakeSVGImage(tailSVGPath(id), fallbackTail, w, h, c)
}

func headSVGPath(id string) string {
Expand Down
17 changes: 17 additions & 0 deletions media/image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package media

import (
"image"
"image/color"
"image/draw"
)

func changeImageColor(src image.Image, c color.Color) image.Image {
dstRect := image.Rect(src.Bounds().Min.X, src.Bounds().Min.Y, src.Bounds().Max.X, src.Bounds().Max.Y)
dst := image.NewNRGBA(dstRect)

srcImage := &image.Uniform{c}

draw.DrawMask(dst, dstRect, srcImage, image.Point{}, src, image.Point{}, draw.Over)
return dst
}
126 changes: 109 additions & 17 deletions media/media.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package media

import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"image"
"image/color"
"io"
"io/fs"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"time"

"github.com/BattlesnakeOfficial/exporter/inkscape"
Expand All @@ -16,6 +22,11 @@ import (
log "github.com/sirupsen/logrus"
)

const (
fallbackHead = "heads/default.png" // relative to base path
fallbackTail = "tails/default.png" // relative to base path
)

// imageCache is a cache that contains image.Image values
var imageCache = cache.New(time.Hour, 10*time.Minute)

Expand All @@ -27,7 +38,7 @@ var svgMgr = &svgManager{

// GetWatermarkPNG gets the watermark asset, scaled to the requested width/height
func GetWatermarkPNG(w, h int) (image.Image, error) {
return loadLocalImageAsset(filepath.Join(baseDir, "watermark.png"), w, h)
return loadLocalImageAsset("watermark.png", w, h)
}

func loadImageFile(path string) (image.Image, error) {
Expand All @@ -41,20 +52,26 @@ func loadImageFile(path string) (image.Image, error) {
return img, err
}

func imageCacheKey(path string, w, h int) string {
return fmt.Sprintf("%s:%d:%d", path, w, h)
// imageCacheKey creates a cache key that is unique to the given parameters.
// color can be nil when there is no color.
func imageCacheKey(path string, w, h int, c color.Color) string {
return fmt.Sprintf("%s:%d:%d:%s", path, w, h, colorToHex6(c))
}

// loadLocalImageAsset loads the specified media asset from the local filesystem.
// It assumes the "mediaPath" is relative to the base path.
// The base path is the directory where all media assets should be located within.
func loadLocalImageAsset(mediaPath string, w, h int) (image.Image, error) {
key := imageCacheKey(mediaPath, w, h)
key := imageCacheKey(mediaPath, w, h, nil)
cachedImage, ok := imageCache.Get(key)
if ok {
return cachedImage.(image.Image), nil
}

img, err := loadImageFile(mediaPath)
fullPath := filepath.Join(baseDir, mediaPath) // file is within the baseDir on disk
img, err := loadImageFile(fullPath)
if err != nil {
log.WithField("path", mediaPath).WithError(err).Errorf("Error loading asset from file")
log.WithField("path", fullPath).WithError(err).Errorf("Error loading asset from file")
return nil, err
}
img = scaleImage(img, w, h)
Expand All @@ -63,9 +80,9 @@ func loadLocalImageAsset(mediaPath string, w, h int) (image.Image, error) {
return img, nil
}

func getSVGImageWithFallback(path, fallbackPath string, w, h int) (image.Image, error) {
func getSnakeSVGImage(path, fallbackPath string, w, h int, c color.Color) (image.Image, error) {
// first we try to load from the media server SVG's
img, err := svgMgr.loadSVGImage(path, w, h)
img, err := svgMgr.loadSnakeSVGImage(path, w, h, c)
if err != nil {
// log at info, because this could error just for people specifying snake types that don't exist
log.WithFields(log.Fields{
Expand All @@ -80,14 +97,16 @@ func getSVGImageWithFallback(path, fallbackPath string, w, h int) (image.Image,
"path": path,
"fallback": fallbackPath,
}).WithError(err).Error("Unable to load local fallback image from file")
return nil, err
}
img = changeImageColor(img, c)
}

return img, err
}

func (sm svgManager) loadSVGImage(mediaPath string, w, h int) (image.Image, error) {
key := imageCacheKey(mediaPath, w, h)
func (sm svgManager) loadSnakeSVGImage(mediaPath string, w, h int, c color.Color) (image.Image, error) {
key := imageCacheKey(mediaPath, w, h, c)
cachedImage, ok := imageCache.Get(key)
if ok {
return cachedImage.(image.Image), nil
Expand All @@ -98,10 +117,11 @@ func (sm svgManager) loadSVGImage(mediaPath string, w, h int) (image.Image, erro
return nil, errors.New("inkscape is not available - unable to load SVG")
}

err := sm.ensureDownloaded(mediaPath)
mediaPath, err := sm.ensureDownloaded(mediaPath, c)
if err != nil {
return nil, err
}

path := sm.getFullPath(mediaPath)

// rasterize the SVG
Expand Down Expand Up @@ -143,21 +163,82 @@ func (sm svgManager) getFullPath(mediaPath string) string {
return filepath.Join(sm.baseDir, mediaPath)
}

func (sm svgManager) ensureDownloaded(mediaPath string) error {
func (sm svgManager) ensureDownloaded(mediaPath string, c color.Color) (string, error) {
// use the colour as a directory to separate different colours of SVG's
customizedMediaPath := path.Join(colorToHex6(c), mediaPath)

// check if we need to download the SVG from the media server
_, err := os.Stat(sm.getFullPath(mediaPath))
_, err := os.Stat(sm.getFullPath(customizedMediaPath))
if errors.Is(err, fs.ErrNotExist) {
svg, err := getCachedMediaResource(mediaPath)
if err != nil {
return err
return "", err
}

svg = customiseSnakeSVG(svg, c)

err = sm.writeFile(customizedMediaPath, []byte(svg))
if err != nil {
return "", err
}
}

// return the new media path which includes the colour directory
return customizedMediaPath, nil
}

// customiseSnakeSVG sets the fill colour for the outer SVG tag
func customiseSnakeSVG(svg string, c color.Color) string {
var buf bytes.Buffer
decoder := xml.NewDecoder(strings.NewReader(svg))
encoder := xml.NewEncoder(&buf)

rootSVGFound := false

for {
token, err := decoder.Token()
if err == io.EOF {
break
}
err = sm.writeFile(mediaPath, []byte(svg))
if err != nil {
return err
log.WithError(err).Info("error while decoding SVG token")
break // skip this token
}
token = xml.CopyToken(token)
switch v := (token).(type) {
case xml.StartElement:

// check if this is the root SVG tag that we can change the colour on
if !rootSVGFound && v.Name.Local == "svg" {
rootSVGFound = true
attrs := append(v.Attr, xml.Attr{Name: xml.Name{Local: "fill"}, Value: colorToHex6(c)})
(&v).Attr = attrs
}

// this is necessary to prevent a weird behavior in Go's XML serialization where every tag gets the
// "xmlns" set, even if it already has that as an attribute
// see also: https://github.com/golang/go/issues/7535
(&v).Name.Space = ""
token = v
case xml.EndElement:
// this is necessary to prevent a weird behavior in Go's XML serialization where every tag gets the
// "xmlns" set, even if it already has that as an attribute
// see also: https://github.com/golang/go/issues/7535
(&v).Name.Space = ""
token = v
}

if err := encoder.EncodeToken(token); err != nil {
log.Fatal(err)
}
}

return nil
// must call flush, otherwise some elements will be missing
if err := encoder.Flush(); err != nil {
log.Fatal(err)
}

return buf.String()
}

func scaleImage(src image.Image, w, h int) image.Image {
Expand All @@ -167,3 +248,14 @@ func scaleImage(src image.Image, w, h int) image.Image {
}
return imaging.Resize(src, w, h, imaging.Lanczos)
}

// colorToHex6 converts a color.Color to a 6-digit hexadecimal string.
// If color is nil, the empty string is returned.
func colorToHex6(c color.Color) string {
if c == nil {
return ""
}

r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", uint8(r), uint8(g), uint8(b))
}
Loading

0 comments on commit 3c916f5

Please sign in to comment.