From 7199f8ed13cd8d33403723139850ffca08784789 Mon Sep 17 00:00:00 2001 From: Chris Sexton Date: Tue, 30 Nov 2021 10:28:34 -0500 Subject: [PATCH] wip wip embed image wip --- bot/interfaces.go | 2 + connectors/discord/discord.go | 9 ++- go.mod | 2 + go.sum | 6 ++ main.go | 2 + plugins/gifmeme/gif.go | 136 ++++++++++++++++++++++++++++++++++ plugins/gifmeme/gifmeme.go | 106 ++++++++++++++++++++++++++ plugins/gifmeme/web.go | 37 +++++++++ plugins/meme/meme.go | 128 +++++++++++++++++++------------- 9 files changed, 376 insertions(+), 52 deletions(-) create mode 100644 plugins/gifmeme/gif.go create mode 100644 plugins/gifmeme/gifmeme.go create mode 100644 plugins/gifmeme/web.go diff --git a/bot/interfaces.go b/bot/interfaces.go index 57ca2a8..002d581 100644 --- a/bot/interfaces.go +++ b/bot/interfaces.go @@ -51,6 +51,8 @@ type ImageAttachment struct { Height int } +type VideoAttachment ImageAttachment + type Request struct { Conn Connector Kind Kind diff --git a/connectors/discord/discord.go b/connectors/discord/discord.go index 6479f43..e0583ec 100644 --- a/connectors/discord/discord.go +++ b/connectors/discord/discord.go @@ -96,8 +96,15 @@ func (d *Discord) sendMessage(channel, message string, meMessage bool, args ...i for _, arg := range args { switch a := arg.(type) { + case bot.VideoAttachment: + embeds = &discordgo.MessageEmbed{} + embeds.Description = a.AltTxt + embeds.Video = &discordgo.MessageEmbedVideo{ + URL: a.URL, + Width: a.Width, + Height: a.Height, + } case bot.ImageAttachment: - //embeds.URL = a.URL embeds = &discordgo.MessageEmbed{} embeds.Description = a.AltTxt embeds.Image = &discordgo.MessageEmbedImage{ diff --git a/go.mod b/go.mod index c5be57f..5c223a1 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 // indirect github.com/PuerkitoBio/goquery v1.5.0 github.com/andybalholm/cascadia v1.1.0 // indirect + github.com/andybons/gogif v0.0.0-20140526152223-16d573594812 // indirect github.com/antchfx/htmlquery v1.2.0 // indirect github.com/antchfx/xmlquery v1.2.0 // indirect github.com/antchfx/xpath v1.1.1 // indirect @@ -30,6 +31,7 @@ require ( github.com/gonum/floats v0.0.0-20181209220543-c233463c7e82 // indirect github.com/gonum/internal v0.0.0-20181124074243-f884aa714029 // indirect github.com/google/uuid v1.1.1 + github.com/gorilla/handlers v1.5.1 // indirect github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 // indirect github.com/itchyny/gojq v0.12.3 github.com/james-bowman/nlp v0.0.0-20191016091239-d9dbfaff30c6 diff --git a/go.sum b/go.sum index 78a74b7..82dc92c 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3 github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybons/gogif v0.0.0-20140526152223-16d573594812 h1:WBBv0ka2SO7Ut4bpskb87E9cHNnJabqA6VoBTex0Jng= +github.com/andybons/gogif v0.0.0-20140526152223-16d573594812/go.mod h1:lkVwYUDYv/mJZK69J7BP7HRUhHEAone7OQHFBRnhQdQ= github.com/antchfx/htmlquery v1.2.0 h1:oKShnsGlnOHX6t4uj5OHgLKkABcJoqnXpqnscoi9Lpw= github.com/antchfx/htmlquery v1.2.0/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8= github.com/antchfx/xmlquery v1.2.0 h1:1nrzsSN5mFrlqFWSK9byiq/qXKE7O2vivYzhv1Ksnfw= @@ -41,6 +43,8 @@ github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vI github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw= github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90 h1:WXb3TSNmHp2vHoCroCIB1foO/yQ36swABL8aOVeDpgg= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE= @@ -71,6 +75,8 @@ github.com/gonum/internal v0.0.0-20181124074243-f884aa714029/go.mod h1:Pu4dmpkhS github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/main.go b/main.go index 5b30dae..12a4e41 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "github.com/velour/catbase/bot/msg" "github.com/velour/catbase/connectors/discord" + "github.com/velour/catbase/plugins/gifmeme" "github.com/velour/catbase/plugins/giphy" "github.com/velour/catbase/plugins/gpt3" "github.com/velour/catbase/plugins/last" @@ -162,6 +163,7 @@ func main() { b.AddPlugin(impossible.New(b)) b.AddPlugin(cli.New(b)) b.AddPlugin(aoc.New(b)) + b.AddPlugin(gifmeme.New(b)) b.AddPlugin(meme.New(b)) b.AddPlugin(achievements.New(b)) b.AddPlugin(sms.New(b)) diff --git a/plugins/gifmeme/gif.go b/plugins/gifmeme/gif.go new file mode 100644 index 0000000..384c2af --- /dev/null +++ b/plugins/gifmeme/gif.go @@ -0,0 +1,136 @@ +package gifmeme + +import ( + "bytes" + "github.com/andybons/gogif" + "github.com/rs/zerolog/log" + "github.com/velour/catbase/config" + "github.com/velour/catbase/plugins/meme" + "image" + "image/gif" + "net/http" + "time" +) + +const tpc = 5 +const nColors = 64 + +func getGif(c *config.Config, s meme.Specification) ([]byte, int, int, error) { + t0 := time.Now() + resp, err := http.Get(s.ImageURL) + if err != nil { + return nil, 0, 0, err + } + g, err := gif.DecodeAll(resp.Body) + if err != nil { + return nil, 0, 0, err + } + log.Debug().Msgf("Len before adding text: %d", len(g.Image)) + err = addText(c, g, s.Configs) + log.Debug().Msgf("Len after adding text: %d", len(g.Image)) + log.Debug().Msgf("%v elapsed adding text", time.Now().Sub(t0)) + if err != nil { + return nil, 0, 0, err + } + out := bytes.NewBuffer([]byte{}) + t1 := time.Now() + err = gif.EncodeAll(out, g) + if err != nil { + return nil, 0, 0, err + } + log.Debug().Msgf("%v elapsed encoding gif", time.Now().Sub(t1)) + w, h := g.Image[0].Bounds().Max.X, g.Image[0].Bounds().Max.Y + return out.Bytes(), w, h, nil +} + +func addText(c *config.Config, g *gif.GIF, text []meme.Text) error { + totalTime := calcTime(g) + framesNeeded, avgFrames := calcTextTime(text) + loops := 1 + + if totalTime < framesNeeded { + loops = framesNeeded / totalTime + totalTime = loops * totalTime + } else { + framesNeeded = totalTime + } + + log.Debug().Msgf("We need %d loops for %d total time with %d frames", + loops, totalTime, framesNeeded) + + frames := []*image.Paletted{} + delays := []int{} + + idx := 0 + + toicfg := meme.MkTextOnImageConfig(c) + + bounds := g.Image[0].Bounds() + maxSz := max(bounds.Max.X, bounds.Max.Y) + if toicfg.MaxSz > float64(maxSz) { + toicfg.MaxSz = float64(maxSz) + } + + currText := 0 + currTextFrames := 0 + + log.Debug().Msgf("Writing text configs %+v on image", text) + + log.Debug().Msgf("Starting text with %+v", text[currText]) + + for i := 0; i < framesNeeded; i++ { + img := g.Image[idx] + i, err := meme.TextOnImage(toicfg, img, []meme.Text{text[currText]}) + if err != nil { + log.Error().Err(err).Msg("error encoding gif") + return err + } + frames = append(frames, imgToPaletted(i)) + delays = append(delays, g.Delay[idx]) + idx = (idx + 1) % len(g.Image) + currTextFrames++ + if currTextFrames > avgFrames { + currText = (currText + 1) % len(text) + currTextFrames = 0 + log.Debug().Msgf("Switching text to %+v", text[currText]) + } + } + + g.Image = frames + g.Delay = delays + g.Disposal = nil + g.Config = image.Config{} + + return nil +} + +func calcTextTime(text []meme.Text) (int, int) { + total := 1 + for _, frame := range text { + total += tpc * len(frame.Text) + } + return total, total / len(text) +} + +func calcTime(g *gif.GIF) int { + total := 0 + for _, frameDelay := range g.Delay { + total += frameDelay + } + return total +} + +func imgToPaletted(i image.Image) *image.Paletted { + bounds := i.Bounds() + p := image.NewPaletted(bounds, nil) + quantizer := gogif.MedianCutQuantizer{NumColor: nColors} + quantizer.Quantize(p, bounds, i, image.Point{}) + return p +} + +func max(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/plugins/gifmeme/gifmeme.go b/plugins/gifmeme/gifmeme.go new file mode 100644 index 0000000..12019c4 --- /dev/null +++ b/plugins/gifmeme/gifmeme.go @@ -0,0 +1,106 @@ +package gifmeme + +import ( + "fmt" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" + "github.com/velour/catbase/bot" + "github.com/velour/catbase/config" + "github.com/velour/catbase/plugins/meme" + "net/url" + "path" + "regexp" + "strings" +) + +type gifMap map[string][]byte + +// Plugin creates gifs with text +type Plugin struct { + b bot.Bot + c *config.Config + db *sqlx.DB + h bot.HandlerTable + + gifs gifMap +} + +// New creates a new Plugin +func New(b bot.Bot) *Plugin { + p := &Plugin{ + b: b, + c: b.Config(), + db: b.DB(), + gifs: make(gifMap), + } + p.register() + p.registerWeb() + return p +} + +func (p *Plugin) register() { + p.h = bot.HandlerTable{ + { + Kind: bot.Message, IsCmd: true, + Regex: regexp.MustCompile(`(?i)^gifmeme (?P\S+) (?P.+)$`), + Handler: p.gifmemeCmd, + }, + } + p.b.RegisterTable(p, p.h) +} + +func (p *Plugin) gifmemeCmd(r bot.Request) bool { + gifs := p.c.GetMap("gifmeme.memes", map[string]string{ + "key": "https://media.giphy.com/media/3o6Zt4HU9uwXmXSAuI/giphy.gif", + }) + u, err := url.Parse(gifs[r.Values["gif"]]) + if checkErr(p.b, r, err) { + return true + } + texts := strings.Split(r.Values["text"], "||") + configs := []meme.Text{} + // do this till we have configs + for _, t := range texts { + configs = append(configs, meme.Text{ + XPerc: 0.5, + YPerc: 0.9, + Text: t, + Caps: true, + }) + } + s := meme.Specification{ + ImageURL: u.String(), + Configs: configs, + } + gif, w, h, err := getGif(p.c, s) + if checkErr(p.b, r, err) { + return true + } + log.Debug().Msgf("Saving gif %s, len: %d", s.ID(), len(gif)) + p.gifs[s.ID()] = gif + u = p.urlForGif(s) + p.b.Send(r.Conn, bot.Message, r.Msg.Channel, u.String()) + p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "", bot.ImageAttachment{ + URL: u.String(), + AltTxt: fmt.Sprintf("%v", texts), + Width: w, + Height: h, + }) + return true +} + +func (p *Plugin) urlForGif(s meme.Specification) *url.URL { + baseURL := p.c.Get("BaseURL", "") + u, _ := url.Parse(baseURL) + u.Path = path.Join(u.Path, "gifmeme", "api", "gif", s.ID()+".gif") + return u +} + +func checkErr(b bot.Bot, r bot.Request, err error) bool { + if err != nil { + msg := fmt.Sprintf("Error: %s", err) + b.Send(r.Conn, bot.Message, r.Msg.Channel, msg) + return true + } + return false +} diff --git a/plugins/gifmeme/web.go b/plugins/gifmeme/web.go new file mode 100644 index 0000000..2efce03 --- /dev/null +++ b/plugins/gifmeme/web.go @@ -0,0 +1,37 @@ +package gifmeme + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "github.com/go-chi/chi/v5" + "github.com/gorilla/handlers" + "github.com/rs/zerolog/log" + "net/http" +) + +func (p *Plugin) registerWeb() { + r := chi.NewRouter() + r.Handle("/api/gif/{id}.gif", handlers.CompressHandlerLevel(http.HandlerFunc(p.handleGif), gzip.BestCompression)) + p.b.RegisterWeb(r, "/gifmeme") +} + +func (p *Plugin) handleGif(w http.ResponseWriter, r *http.Request) { + //id := r.URL.Query().Get("id") + id := chi.URLParam(r, "id") + gif, ok := p.gifs[id] + keys := []string{} + for k, _ := range p.gifs { + keys = append(keys, k) + } + log.Debug().Msgf("Looking for %s in gifs: %v", id, keys) + if !ok { + w.WriteHeader(404) + e := struct { + error error + }{fmt.Errorf("%s not found", id)} + jsonErr, _ := json.Marshal(e) + w.Write(jsonErr) + } + w.Write(gif) +} \ No newline at end of file diff --git a/plugins/meme/meme.go b/plugins/meme/meme.go index 1d44aae..e7116ce 100644 --- a/plugins/meme/meme.go +++ b/plugins/meme/meme.go @@ -2,6 +2,7 @@ package meme import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "image" @@ -25,6 +26,8 @@ import ( "github.com/velour/catbase/config" ) +var fontSizes = []float64{48, 36, 24, 16, 12} + type MemePlugin struct { bot bot.Bot c *config.Config @@ -37,7 +40,7 @@ type cachedImage struct { repr []byte } -type memeText struct { +type Text struct { XPerc float64 `json:"x"` YPerc float64 `json:"y"` Text string `json:"t",omitempty` @@ -45,19 +48,25 @@ type memeText struct { Font string `json:"f",omitempty` } -type specification struct { +type Specification struct { ImageURL string StampURL string - Configs []memeText + Configs []Text } -func (s specification) toJSON() string { +func (s Specification) ID() string { + out, _ := json.Marshal(s) + b64 := base64.StdEncoding.EncodeToString(out) + return b64 +} + +func (s Specification) toJSON() string { out, _ := json.Marshal(s) return string(out) } -func SpecFromJSON(input []byte) (specification, error) { - out := specification{} +func SpecFromJSON(input []byte) (Specification, error) { + out := Specification{} err := json.Unmarshal(input, &out) return out, err } @@ -149,7 +158,7 @@ func (p *MemePlugin) sendMeme(c bot.Connector, channel, channelName, msgID strin log.Debug().Strs("parts", parts).Msgf("Meme:\n%+v", text) - var config []memeText + var config []Text message = strings.TrimPrefix(message, "`") message = strings.TrimSuffix(message, "`") @@ -199,7 +208,7 @@ func (p *MemePlugin) sendMeme(c bot.Connector, channel, channelName, msgID strin stampURL := p.stamp(c, format, from.ID) - spec := specification{ + spec := Specification{ ImageURL: imgURL, StampURL: stampURL, Configs: config, @@ -304,7 +313,7 @@ var defaultFormats = map[string]string{ "raptor": "https://imgflip.com/s/meme/Philosoraptor.jpg", } -func (p *MemePlugin) findFontSize(config []memeText, fontLocation string, w, h int, sizes []float64) float64 { +func findFontSize(config []Text, fontLocation string, w, h int, sizes []float64) float64 { fontSize := 12.0 m := gg.NewContext(w, h) @@ -341,8 +350,8 @@ func (p *MemePlugin) findFontSize(config []memeText, fontLocation string, w, h i return fontSize } -func defaultFormatConfig() []memeText { - return []memeText{ +func defaultFormatConfig() []Text { + return []Text{ {XPerc: 0.5, YPerc: 0.05, Caps: true}, {XPerc: 0.5, YPerc: 0.95, Caps: true}, } @@ -368,9 +377,7 @@ func (p *MemePlugin) checkMeme(imgURL string) (int, int, error) { return img.Bounds().Dx(), img.Bounds().Dy(), err } -func (p *MemePlugin) genMeme(spec specification) ([]byte, error) { - fontSizes := []float64{48, 36, 24, 16, 12} - +func (p *MemePlugin) genMeme(spec Specification) ([]byte, error) { jsonSpec := spec.toJSON() if cached, ok := p.images[jsonSpec]; ok { log.Debug().Msgf("Returning cached image for %s", jsonSpec) @@ -391,28 +398,7 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) { return nil, err } - r := img.Bounds() - w := r.Dx() - h := r.Dy() - - maxSz := p.c.GetFloat64("maxImgSz", 750.0) - - if w > h { - scale := maxSz / float64(w) - w = int(float64(w) * scale) - h = int(float64(h) * scale) - } else { - scale := maxSz / float64(h) - w = int(float64(w) * scale) - h = int(float64(h) * scale) - } - - log.Debug().Msgf("trynig to resize to %v, %v", w, h) - img = resize.Resize(uint(w), uint(h), img, resize.Lanczos3) - r = img.Bounds() - w = r.Dx() - h = r.Dy() - log.Debug().Msgf("resized to %v, %v", w, h) + img, err = TextOnImage(MkTextOnImageConfig(p.c), img, spec.Configs) if spec.StampURL != "" { img, err = p.applyStamp(img, spec.StampURL) @@ -424,13 +410,60 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) { } } + i := bytes.Buffer{} + png.Encode(&i, img) + p.images[jsonSpec] = &cachedImage{time.Now(), i.Bytes()} + + log.Debug().Msgf("Saved to %s\n", jsonSpec) + + return p.images[jsonSpec].repr, nil +} + +type TextOnImageConfig struct { + MaxSz float64 + Font string +} + +// MkTextOnImageConfig saves us some db lookups for gif creation +func MkTextOnImageConfig(c *config.Config) TextOnImageConfig { + return TextOnImageConfig{ + MaxSz: c.GetFloat64("maxImgSz", 750.0), + Font: c.Get("meme.font", "impact.ttf"), + } +} + +func TextOnImage(c TextOnImageConfig, img image.Image, configs []Text) (image.Image, error) { + r := img.Bounds() + w := r.Dx() + h := r.Dy() + + maxSz := c.MaxSz + + scale := 1.0 + if w > h { + scale = maxSz / float64(w) + w = int(float64(w) * scale) + h = int(float64(h) * scale) + } else { + scale = maxSz / float64(h) + w = int(float64(w) * scale) + h = int(float64(h) * scale) + } + + if scale > 1.1 || scale < 0.9 { + img = resize.Resize(uint(w), uint(h), img, resize.Lanczos3) + } + + r = img.Bounds() + w = r.Dx() + h = r.Dy() m := gg.NewContext(w, h) m.DrawImage(img, 0, 0) - defaultFont := p.c.Get("meme.font", "impact.ttf") + defaultFont := c.Font - for i, c := range spec.Configs { + for i, c := range configs { if c.Caps { - spec.Configs[i].Text = strings.ToUpper(c.Text) + configs[i].Text = strings.ToUpper(c.Text) } } @@ -443,12 +476,12 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) { if dx*dx+dy*dy >= strokeSize*strokeSize { continue } - for _, c := range spec.Configs { + for _, c := range configs { fontLocation := c.Font if fontLocation == "" { fontLocation = defaultFont } - fontSize := p.findFontSize(spec.Configs, fontLocation, w, h, fontSizes) + fontSize := findFontSize(configs, fontLocation, w, h, fontSizes) m.LoadFontFace(fontLocation, fontSize) x := float64(w)*c.XPerc + float64(dx) y := float64(h)*c.YPerc + float64(dy) @@ -459,25 +492,18 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) { // Apply white fill m.SetHexColor("#FFF") - for _, c := range spec.Configs { + for _, c := range configs { fontLocation := c.Font if fontLocation == "" { fontLocation = defaultFont } - fontSize := p.findFontSize(spec.Configs, fontLocation, w, h, fontSizes) + fontSize := findFontSize(configs, fontLocation, w, h, fontSizes) m.LoadFontFace(fontLocation, fontSize) x := float64(w) * c.XPerc y := float64(h) * c.YPerc m.DrawStringAnchored(c.Text, x, y, 0.5, 0.5) } - - i := bytes.Buffer{} - png.Encode(&i, m.Image()) - p.images[jsonSpec] = &cachedImage{time.Now(), i.Bytes()} - - log.Debug().Msgf("Saved to %s\n", jsonSpec) - - return p.images[jsonSpec].repr, nil + return m.Image(), nil } func (p *MemePlugin) applyStamp(img image.Image, bullyURL string) (image.Image, error) {