From ada43b176be23207ed949021c15fe3c34c413f75 Mon Sep 17 00:00:00 2001 From: Justin Hawkins Date: Tue, 1 Nov 2022 13:06:16 +1030 Subject: [PATCH] Refactor image processing for more flexibility. Add support for resizing images if they exceed the discord 8Mb limit. --- .vscode/settings.json | 2 + CHANGELOG.md | 5 + image/image.go | 188 +++++++++++++++++++++++++++++++ {imageprocess => image}/thumb.go | 29 +++-- image/watermark.go | 64 +++++++++++ imageprocess/imageprocess.go | 4 - upload/upload.go | 109 +++--------------- version/version.go | 2 +- web/server.go | 14 +-- 9 files changed, 299 insertions(+), 118 deletions(-) create mode 100644 image/image.go rename {imageprocess => image}/thumb.go (59%) create mode 100644 image/watermark.go delete mode 100644 imageprocess/imageprocess.go diff --git a/.vscode/settings.json b/.vscode/settings.json index b2b2786..15d63c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,8 @@ "daulog", "Debugf", "inconsolata", + "Infof", + "Markedup", "skratchdot" ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e30228e..141891b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v0.13.0] - 2022-11-01 + +- Resize images if needed to fit in discord 8Mb upload limits + + ## [v0.12.4] - 2022-09-15 - Document that watcher intervals are in seconds diff --git a/image/image.go b/image/image.go new file mode 100644 index 0000000..2afe752 --- /dev/null +++ b/image/image.go @@ -0,0 +1,188 @@ +// Package image is responsible for thumbnailing, resizing and watermarking +// images. +package image + +import ( + "fmt" + i "image" + "image/jpeg" + "image/png" + "io" + "os" + + daulog "github.com/tardisx/discord-auto-upload/log" + + "golang.org/x/image/draw" +) + +// the filenames below are ordered in a specific way +// In the simplest case we only need the original filename. +// In more complex cases, we might have other files, created +// temporarily. These all need to be cleaned up. +// We upload the "final" file, depending on what actions have +// been taken. + +type Store struct { + OriginalFilename string + OriginalFormat string // jpeg, png + ModifiedFilename string // if the user applied modifications + ResizedFilename string // if the file had to be resized to be uploaded + WatermarkedFilename string + MaxBytes int + Watermark bool +} + +// ReadCloser returns an io.ReadCloser providing the imagedata +// with the manglings that have been requested +func (s *Store) ReadCloser() (io.ReadCloser, error) { + // determine format + s.determineFormat() + + // check if we will fit the number of bytes, resize if necessary + err := s.resizeToUnder(int64(s.MaxBytes)) + if err != nil { + return nil, err + } + + // the conundrum here is that the watermarking could modify the file size again, maybe going over + // the MaxBytes size. That would mostly be about jpeg compression levels I guess... + if s.Watermark { + s.applyWatermark() + } + + // return the reader + f, err := os.Open(s.uploadSourceFilename()) + if err != nil { + return nil, err + } + + return f, nil +} + +func (s *Store) determineFormat() error { + file, err := os.Open(s.OriginalFilename) + if err != nil { + panic(fmt.Errorf("could not open file: %s", err)) + } + defer file.Close() + + _, format, err := i.Decode(file) + if err != nil { + panic(fmt.Errorf("could not decode file: %s", err)) + } + s.OriginalFormat = format + return nil +} + +// resizeToUnder resizes the image, if necessary +func (s *Store) resizeToUnder(size int64) error { + fileToResize := s.uploadSourceFilename() + fi, err := os.Stat(s.uploadSourceFilename()) + if err != nil { + return err + } + currentSize := fi.Size() + if currentSize <= size { + return nil // nothing needs to be done + } + + daulog.Infof("%s is %d bytes, need to resize to fit in %d", fileToResize, currentSize, size) + + file, err := os.Open(fileToResize) + if err != nil { + panic(fmt.Errorf("could not open file: %s", err)) + } + defer file.Close() + + im, _, err := i.Decode(file) + if err != nil { + panic(fmt.Errorf("could not decode file: %s", err)) + } + + // if the size is 10% too big, we reduce X and Y by 10% - this is overkill but should + // get us across the line in most cases + fraction := float64(currentSize) / float64(size) // say 1.1 for 10% + newXY := i.Point{ + X: int(float64(im.Bounds().Max.X) / fraction), + Y: int(float64(im.Bounds().Max.Y) / fraction), + } + + daulog.Infof("fraction is %f, will resize to %dx%d", fraction, newXY.X, newXY.Y) + + dst := i.NewRGBA(i.Rect(0, 0, newXY.X, newXY.Y)) + draw.BiLinear.Scale(dst, dst.Rect, im, im.Bounds(), draw.Over, nil) + + resizedFile, err := os.CreateTemp("", "dau_resize_file_*") + if err != nil { + return err + } + + if s.OriginalFormat == "png" { + err = png.Encode(resizedFile, dst) + if err != nil { + return err + } + } else if s.OriginalFormat == "jpeg" { + err = jpeg.Encode(resizedFile, dst, nil) + if err != nil { + return err + } + + } else { + panic("unknown format " + s.OriginalFormat) + } + + s.ResizedFilename = resizedFile.Name() + resizedFile.Close() + + fi, err = os.Stat(s.uploadSourceFilename()) + if err != nil { + return err + } + newSize := fi.Size() + if newSize <= size { + daulog.Infof("File resized, now %d", newSize) + return nil // nothing needs to be done + } else { + return fmt.Errorf("failed to resize: was %d, now %d, needed %d", currentSize, newSize, size) + } + +} + +// uploadSourceFilename gives us the filename, which might be a watermarked, resized +// or markedup version, depending on what has happened to this file. +func (s Store) uploadSourceFilename() string { + if s.WatermarkedFilename != "" { + return s.WatermarkedFilename + } + if s.ResizedFilename != "" { + return s.ResizedFilename + } + if s.ModifiedFilename != "" { + return s.ModifiedFilename + } + return s.OriginalFilename +} + +// UploadFilename provides a name to be assigned to the upload on Discord +func (s Store) UploadFilename() string { + return "image." + s.OriginalFormat +} + +// Cleanup removes all the temporary files that we might have created +func (s Store) Cleanup() { + daulog.Infof("cleaning temporary files %#v", s) + + if s.ModifiedFilename != "" { + daulog.Infof("removing %s", s.ModifiedFilename) + os.Remove(s.ModifiedFilename) + } + if s.ResizedFilename != "" { + daulog.Infof("removing %s", s.ResizedFilename) + os.Remove(s.ResizedFilename) + } + if s.WatermarkedFilename != "" { + daulog.Infof("removing %s", s.WatermarkedFilename) + os.Remove(s.WatermarkedFilename) + } +} diff --git a/imageprocess/thumb.go b/image/thumb.go similarity index 59% rename from imageprocess/thumb.go rename to image/thumb.go index d369960..76b3bd0 100644 --- a/imageprocess/thumb.go +++ b/image/thumb.go @@ -1,14 +1,13 @@ -package imageprocess +package image import ( "fmt" - "image" + i "image" "image/png" "io" "log" "os" - "github.com/tardisx/discord-auto-upload/upload" "golang.org/x/image/draw" ) @@ -17,15 +16,21 @@ const ( thumbnailMaxY = 128 ) -func (ip *Processor) ThumbPNG(ul *upload.Upload, which string, w io.Writer) error { +type ThumbType = string + +const ThumbTypeOriginal = "orig" +const ThumbTypeMarkedUp = "markedup" + +// ThumbPNG writes a thumbnail out to an io.Writer +func (ip *Store) ThumbPNG(t ThumbType, w io.Writer) error { var filename string - if which == "orig" { - filename = ul.OriginalFilename - } else if which == "markedup" { - filename = ul.MarkedUpFilename + if t == ThumbTypeOriginal { + filename = ip.OriginalFilename + } else if t == ThumbTypeMarkedUp { + filename = ip.ModifiedFilename } else { - log.Fatal("was passed incorrect 'which' arg") + log.Fatal("was passed incorrect 'type' arg") } file, err := os.Open(filename) @@ -33,12 +38,12 @@ func (ip *Processor) ThumbPNG(ul *upload.Upload, which string, w io.Writer) erro return fmt.Errorf("could not open file: %s", err) } defer file.Close() - im, _, err := image.Decode(file) + im, _, err := i.Decode(file) if err != nil { return fmt.Errorf("could not decode file: %s", err) } - newXY := image.Point{} + newXY := i.Point{} if im.Bounds().Max.X/thumbnailMaxX > im.Bounds().Max.Y/thumbnailMaxY { newXY.X = thumbnailMaxX newXY.Y = im.Bounds().Max.Y / (im.Bounds().Max.X / thumbnailMaxX) @@ -47,7 +52,7 @@ func (ip *Processor) ThumbPNG(ul *upload.Upload, which string, w io.Writer) erro newXY.X = im.Bounds().Max.X / (im.Bounds().Max.Y / thumbnailMaxY) } - dst := image.NewRGBA(image.Rect(0, 0, newXY.X, newXY.Y)) + dst := i.NewRGBA(i.Rect(0, 0, newXY.X, newXY.Y)) draw.BiLinear.Scale(dst, dst.Rect, im, im.Bounds(), draw.Over, nil) png.Encode(w, dst) diff --git a/image/watermark.go b/image/watermark.go new file mode 100644 index 0000000..83f0c58 --- /dev/null +++ b/image/watermark.go @@ -0,0 +1,64 @@ +package image + +import ( + "fmt" + i "image" + "image/jpeg" + "image/png" + "os" + + daulog "github.com/tardisx/discord-auto-upload/log" + + "github.com/fogleman/gg" + "golang.org/x/image/font/inconsolata" +) + +// applyWatermark applies the watermark to the image +func (s *Store) applyWatermark() error { + + in, err := os.Open(s.uploadSourceFilename()) + + defer in.Close() + + im, _, err := i.Decode(in) + if err != nil { + daulog.Errorf("Cannot decode image: %v - skipping watermarking", err) + return fmt.Errorf("cannot decode image: %w", err) + } + bounds := im.Bounds() + // var S float64 = float64(bounds.Max.X) + + dc := gg.NewContext(bounds.Max.X, bounds.Max.Y) + dc.Clear() + dc.SetRGB(0, 0, 0) + + dc.SetFontFace(inconsolata.Regular8x16) + + dc.DrawImage(im, 0, 0) + + dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0) + dc.SetRGB(0, 0, 0) + dc.Fill() + + dc.SetRGB(1, 1, 1) + + dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0) + + waterMarkedFile, err := os.CreateTemp("", "dau_watermark_file_*") + + if err != nil { + return err + } + defer waterMarkedFile.Close() + + if s.OriginalFormat == "png" { + png.Encode(waterMarkedFile, dc.Image()) + } else if s.OriginalFormat == "jpeg" { + jpeg.Encode(waterMarkedFile, dc.Image(), nil) + } else { + panic("Cannot handle " + s.OriginalFormat) + } + + s.WatermarkedFilename = waterMarkedFile.Name() + return nil +} diff --git a/imageprocess/imageprocess.go b/imageprocess/imageprocess.go deleted file mode 100644 index 5e63321..0000000 --- a/imageprocess/imageprocess.go +++ /dev/null @@ -1,4 +0,0 @@ -package imageprocess - -type Processor struct { -} diff --git a/upload/upload.go b/upload/upload.go index b78c7b1..420d9b0 100644 --- a/upload/upload.go +++ b/upload/upload.go @@ -7,22 +7,19 @@ import ( "encoding/json" "errors" "fmt" - "image" "io" "io/ioutil" "log" "mime/multipart" "net/http" - "os" - "path/filepath" "sync" "sync/atomic" "time" - "github.com/fogleman/gg" "github.com/tardisx/discord-auto-upload/config" + "github.com/tardisx/discord-auto-upload/image" + daulog "github.com/tardisx/discord-auto-upload/log" - "golang.org/x/image/font/inconsolata" ) type State string @@ -51,20 +48,14 @@ type Upload struct { Id int32 `json:"id"` UploadedAt time.Time `json:"uploaded_at"` - OriginalFilename string `json:"original_file"` // path on the local disk - MarkedUpFilename string `json:"markedup_file"` // a temporary file, if the user did some markup + Image *image.Store webhookURL string - watermark bool // should watermark - usernameOverride string Url string `json:"url"` // url on the discord CDN - Width int `json:"width"` - Height int `json:"height"` - State State `json:"state"` Client HTTPClient `json:"-"` @@ -82,11 +73,13 @@ func (u *Uploader) AddFile(file string, conf config.Watcher) { atomic.AddInt32(¤tId, 1) thisUpload := Upload{ Id: currentId, - OriginalFilename: file, - watermark: !conf.NoWatermark, + UploadedAt: time.Time{}, + Image: &image.Store{OriginalFilename: file, Watermark: !conf.NoWatermark, MaxBytes: 8_000_000}, webhookURL: conf.WebHookURL, usernameOverride: conf.Username, + Url: "", State: StateQueued, + Client: nil, } // if the user wants uploads to be held for editing etc, // set it to Pending instead @@ -123,16 +116,8 @@ func (u *Uploader) UploadById(id int32) *Upload { return nil } -func (u *Upload) RemoveMarkupTempFile() { - if len(u.MarkedUpFilename) > 0 { - os.Remove(u.MarkedUpFilename) - } -} - func (u *Upload) processUpload() error { - daulog.Infof("Uploading: %s", u.OriginalFilename) - - baseFilename := filepath.Base(u.OriginalFilename) + daulog.Infof("Uploading: %s", u.Image) if u.webhookURL == "" { daulog.Error("WebHookURL is not configured - cannot upload!") @@ -164,41 +149,14 @@ func (u *Upload) processUpload() error { for retriesRemaining > 0 { // open an io.ReadCloser for the file we intend to upload - var filedata *os.File var err error - if len(u.MarkedUpFilename) > 0 { - filedata, err = os.Open(u.MarkedUpFilename) - if err != nil { - daulog.Errorf("Error opening marked up file: %s", err) - retriesRemaining-- - sleepForRetries(retriesRemaining) - continue - } - } else { - filedata, err = os.Open(u.OriginalFilename) - if err != nil { - daulog.Errorf("Error opening original file: %s", err) - retriesRemaining-- - sleepForRetries(retriesRemaining) - continue - } + + imageData, err := u.Image.ReadCloser() + if err != nil { + panic(err) } - var imageData io.Reader - if u.watermark { - daulog.Info("Watermarking image") - imageData, err = u.applyWatermark(filedata) - if err != nil { - daulog.Errorf("Error watermarking: %s", err) - retriesRemaining-- - sleepForRetries(retriesRemaining) - continue - } - } else { - imageData = filedata - } - - request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", baseFilename, imageData) + request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", u.Image.UploadFilename(), imageData) if err != nil { daulog.Errorf("error creating upload request: %s", err) return fmt.Errorf("could not create upload request: %s", err) @@ -274,16 +232,15 @@ func (u *Upload) processUpload() error { u.Url = a.URL u.State = StateComplete - u.Width = a.Width - u.Height = a.Height + u.UploadedAt = time.Now() break } } - // remove any marked up file - u.RemoveMarkupTempFile() + // remove any temporary files + u.Image.Cleanup() if retriesRemaining == 0 { daulog.Error("Failed to upload, even after all retries") @@ -320,40 +277,6 @@ func newfileUploadRequest(uri string, params map[string]string, paramName string return req, err } -// applyWatermark applies the watermark to the image -func (u *Upload) applyWatermark(in *os.File) (io.Reader, error) { - - defer in.Close() - - im, _, err := image.Decode(in) - if err != nil { - daulog.Errorf("Cannot decode image: %v - skipping watermarking", err) - return nil, errors.New("cannot decode image") - } - bounds := im.Bounds() - // var S float64 = float64(bounds.Max.X) - - dc := gg.NewContext(bounds.Max.X, bounds.Max.Y) - dc.Clear() - dc.SetRGB(0, 0, 0) - - dc.SetFontFace(inconsolata.Regular8x16) - - dc.DrawImage(im, 0, 0) - - dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0) - dc.SetRGB(0, 0, 0) - dc.Fill() - - dc.SetRGB(1, 1, 1) - - dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0) - - b := bytes.Buffer{} - dc.EncodePNG(&b) - return &b, nil -} - func sleepForRetries(retry int) { if retry == 0 { return diff --git a/version/version.go b/version/version.go index a4bab73..e058a19 100644 --- a/version/version.go +++ b/version/version.go @@ -13,7 +13,7 @@ import ( "golang.org/x/mod/semver" ) -const CurrentVersion string = "v0.12.4" +const CurrentVersion string = "v0.13.0-alpha.1" type GithubRelease struct { HTMLURL string `json:"html_url"` diff --git a/web/server.go b/web/server.go index 2e2b25e..bed077a 100644 --- a/web/server.go +++ b/web/server.go @@ -19,7 +19,7 @@ import ( "github.com/gorilla/mux" "github.com/tardisx/discord-auto-upload/config" - "github.com/tardisx/discord-auto-upload/imageprocess" + "github.com/tardisx/discord-auto-upload/image" daulog "github.com/tardisx/discord-auto-upload/log" "github.com/tardisx/discord-auto-upload/upload" "github.com/tardisx/discord-auto-upload/version" @@ -178,7 +178,6 @@ func (ws *WebService) getUploads(w http.ResponseWriter, r *http.Request) { func (ws *WebService) imageThumb(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") - processor := imageprocess.Processor{} vars := mux.Vars(r) id, err := strconv.ParseInt(vars["id"], 10, 32) @@ -192,7 +191,7 @@ func (ws *WebService) imageThumb(w http.ResponseWriter, r *http.Request) { returnJSONError(w, "bad id") return } - err = processor.ThumbPNG(ul, "orig", w) + err = ul.Image.ThumbPNG(image.ThumbTypeOriginal, w) if err != nil { returnJSONError(w, "could not create thumb") return @@ -202,7 +201,6 @@ func (ws *WebService) imageThumb(w http.ResponseWriter, r *http.Request) { func (ws *WebService) imageMarkedupThumb(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") - processor := imageprocess.Processor{} vars := mux.Vars(r) id, err := strconv.ParseInt(vars["id"], 10, 32) @@ -216,7 +214,7 @@ func (ws *WebService) imageMarkedupThumb(w http.ResponseWriter, r *http.Request) returnJSONError(w, "bad id") return } - err = processor.ThumbPNG(ul, "markedup", w) + err = ul.Image.ThumbPNG(image.ThumbTypeMarkedUp, w) if err != nil { returnJSONError(w, "could not create thumb") return @@ -238,7 +236,7 @@ func (ws *WebService) image(w http.ResponseWriter, r *http.Request) { return } - img, err := os.Open(ul.OriginalFilename) + img, err := os.Open(ul.Image.OriginalFilename) if err != nil { returnJSONError(w, "could not open image file") return @@ -276,7 +274,7 @@ func (ws *WebService) modifyUpload(w http.ResponseWriter, r *http.Request) { return } else if change == "skip" { anUpload.State = upload.StateSkipped - anUpload.RemoveMarkupTempFile() + anUpload.Image.Cleanup() res := StartUploadResponse{Success: true, Message: "upload skipped"} resString, _ := json.Marshal(res) w.Write(resString) @@ -311,7 +309,7 @@ func (ws *WebService) modifyUpload(w http.ResponseWriter, r *http.Request) { } tempfile.Close() - anUpload.MarkedUpFilename = tempfile.Name() + anUpload.Image.ModifiedFilename = tempfile.Name() } else { returnJSONError(w, "bad change type")