Refactor image processing for more flexibility. Add support for resizing images if they exceed the discord 8Mb limit.

This commit is contained in:
Justin Hawkins 2022-11-01 13:06:16 +10:30
parent 326807b395
commit ada43b176b
9 changed files with 299 additions and 118 deletions

View File

@ -3,6 +3,8 @@
"daulog",
"Debugf",
"inconsolata",
"Infof",
"Markedup",
"skratchdot"
]
}

View File

@ -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

188
image/image.go Normal file
View File

@ -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)
}
}

View File

@ -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)

64
image/watermark.go Normal file
View File

@ -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
}

View File

@ -1,4 +0,0 @@
package imageprocess
type Processor struct {
}

View File

@ -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(&currentId, 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)
imageData, err := u.Image.ReadCloser()
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
}
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

View File

@ -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"`

View File

@ -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")