286 lines
7.6 KiB
Go
286 lines
7.6 KiB
Go
package image
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"math"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var ErrVariantNotFound = errors.New("variant not found")
|
|
var ErrVariantExists = errors.New("variant already exists")
|
|
var ErrVariantInvalid = errors.New("variant is not valid")
|
|
var ErrVariantFormatUnavailable = errors.New("variant format cannot be encoded")
|
|
var ErrUnknownVariant = errors.New("unknown variant")
|
|
var ErrUnsupportedParameter = errors.New("unsupported parameter")
|
|
var ErrSourceTooLarge = errors.New("source image is too large to generate this variant")
|
|
var ErrVariantSourceUnsupported = errors.New("variants are not generated for this format")
|
|
var ErrVariantSourceUnreadable = errors.New("the source image cannot be read")
|
|
|
|
type VariantFit string
|
|
|
|
const (
|
|
FitCover VariantFit = "cover"
|
|
FitContain VariantFit = "contain"
|
|
FitInside VariantFit = "inside"
|
|
)
|
|
|
|
type VariantFormat string
|
|
|
|
const (
|
|
FormatOriginal VariantFormat = "original"
|
|
FormatJPEG VariantFormat = "jpeg"
|
|
FormatPNG VariantFormat = "png"
|
|
FormatWebP VariantFormat = "webp"
|
|
)
|
|
|
|
const (
|
|
MinVariantNameLength = 2
|
|
MaxVariantNameLength = 32
|
|
MinVariantDimension = 1
|
|
MaxVariantDimension = 8000
|
|
MinVariantQuality = 1
|
|
MaxVariantQuality = 100
|
|
DefaultVariantQuality = 82
|
|
DefaultVariantFit = FitCover
|
|
DefaultVariantFormat = FormatOriginal
|
|
VariantQueryParameter = "v"
|
|
)
|
|
|
|
var variantNamePattern = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
|
|
|
|
var unsupportedQueryParameters = []string{"width", "height", "format", "quality"}
|
|
|
|
func VariantFits() []VariantFit {
|
|
return []VariantFit{FitCover, FitContain, FitInside}
|
|
}
|
|
|
|
func VariantFormats() []VariantFormat {
|
|
return []VariantFormat{FormatOriginal, FormatJPEG, FormatPNG, FormatWebP}
|
|
}
|
|
|
|
func UnsupportedQueryParameters() []string {
|
|
return append([]string(nil), unsupportedQueryParameters...)
|
|
}
|
|
|
|
func IsValidVariantName(name string) bool {
|
|
return variantNamePattern.MatchString(name)
|
|
}
|
|
|
|
type VariantSettings struct {
|
|
Width *int
|
|
Height *int
|
|
Fit VariantFit
|
|
Format VariantFormat
|
|
Quality *int
|
|
}
|
|
|
|
func (s VariantSettings) Normalized() (VariantSettings, error) {
|
|
normalized := s
|
|
if normalized.Fit == "" {
|
|
normalized.Fit = DefaultVariantFit
|
|
}
|
|
if normalized.Format == "" {
|
|
normalized.Format = DefaultVariantFormat
|
|
}
|
|
if !isKnownFit(normalized.Fit) || !isKnownFormat(normalized.Format) {
|
|
return VariantSettings{}, ErrVariantInvalid
|
|
}
|
|
if !hasValidDimensions(normalized.Width, normalized.Height) {
|
|
return VariantSettings{}, ErrVariantInvalid
|
|
}
|
|
quality, err := normalizedQuality(normalized.Format, normalized.Quality)
|
|
if err != nil {
|
|
return VariantSettings{}, err
|
|
}
|
|
normalized.Quality = quality
|
|
return normalized, nil
|
|
}
|
|
|
|
func (s VariantSettings) Fingerprint() string {
|
|
parts := []string{
|
|
"w=" + DimensionText(s.Width),
|
|
"h=" + DimensionText(s.Height),
|
|
"fit=" + string(s.Fit),
|
|
"format=" + string(s.Format),
|
|
"q=" + QualityText(s.Quality),
|
|
}
|
|
sum := sha256.Sum256([]byte(strings.Join(parts, ";")))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s VariantSettings) IsSameAs(other VariantSettings) bool {
|
|
return s.Fingerprint() == other.Fingerprint()
|
|
}
|
|
|
|
func isKnownFit(fit VariantFit) bool {
|
|
for _, known := range VariantFits() {
|
|
if fit == known {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isKnownFormat(format VariantFormat) bool {
|
|
for _, known := range VariantFormats() {
|
|
if format == known {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasValidDimensions(width *int, height *int) bool {
|
|
if width == nil && height == nil {
|
|
return false
|
|
}
|
|
return isValidDimension(width) && isValidDimension(height)
|
|
}
|
|
|
|
func isValidDimension(dimension *int) bool {
|
|
return dimension == nil || (*dimension >= MinVariantDimension && *dimension <= MaxVariantDimension)
|
|
}
|
|
|
|
func normalizedQuality(format VariantFormat, quality *int) (*int, error) {
|
|
if format == FormatPNG {
|
|
return nil, nil
|
|
}
|
|
if quality == nil {
|
|
defaultQuality := DefaultVariantQuality
|
|
return &defaultQuality, nil
|
|
}
|
|
if *quality < MinVariantQuality || *quality > MaxVariantQuality {
|
|
return nil, ErrVariantInvalid
|
|
}
|
|
kept := *quality
|
|
return &kept, nil
|
|
}
|
|
|
|
func DimensionText(dimension *int) string {
|
|
if dimension == nil {
|
|
return "auto"
|
|
}
|
|
return strconv.Itoa(*dimension)
|
|
}
|
|
|
|
func QualityText(quality *int) string {
|
|
if quality == nil {
|
|
return "none"
|
|
}
|
|
return strconv.Itoa(*quality)
|
|
}
|
|
|
|
type Variant struct {
|
|
Name string `json:"name"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
Fit VariantFit `json:"fit"`
|
|
Format VariantFormat `json:"format"`
|
|
Quality *int `json:"quality"`
|
|
GenerateOnUpload bool `json:"generate_on_upload"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func NewVariant(name string, settings VariantSettings, generateOnUpload bool, enabled bool, now time.Time) (Variant, error) {
|
|
if !IsValidVariantName(name) {
|
|
return Variant{}, ErrVariantInvalid
|
|
}
|
|
normalized, err := settings.Normalized()
|
|
if err != nil {
|
|
return Variant{}, err
|
|
}
|
|
variant := Variant{Name: name, GenerateOnUpload: generateOnUpload, Enabled: enabled, CreatedAt: now, UpdatedAt: now}
|
|
return variant.WithSettings(normalized), nil
|
|
}
|
|
|
|
func (v Variant) Settings() VariantSettings {
|
|
return VariantSettings{Width: v.Width, Height: v.Height, Fit: v.Fit, Format: v.Format, Quality: v.Quality}
|
|
}
|
|
|
|
func (v Variant) WithSettings(settings VariantSettings) Variant {
|
|
v.Width, v.Height, v.Fit, v.Format, v.Quality = settings.Width, settings.Height, settings.Fit, settings.Format, settings.Quality
|
|
return v
|
|
}
|
|
|
|
type VariantFile struct {
|
|
ID uint32
|
|
VariantName string
|
|
SourceName string
|
|
CachedName string
|
|
ContentType string
|
|
Size int64
|
|
Fingerprint string
|
|
}
|
|
|
|
type VariantFileTotals struct {
|
|
Files int
|
|
Bytes int64
|
|
}
|
|
|
|
func OutputSize(sourceWidth int, sourceHeight int, settings VariantSettings) (int, int) {
|
|
if sourceWidth < 1 || sourceHeight < 1 {
|
|
return 0, 0
|
|
}
|
|
if settings.Width == nil || settings.Height == nil {
|
|
return proportionalOutput(sourceWidth, sourceHeight, settings)
|
|
}
|
|
boxWidth, boxHeight := *settings.Width, *settings.Height
|
|
switch settings.Fit {
|
|
case FitContain:
|
|
return boxWidth, boxHeight
|
|
case FitInside:
|
|
return scaledOutput(sourceWidth, sourceHeight, math.Min(1, math.Min(ratio(boxWidth, sourceWidth), ratio(boxHeight, sourceHeight))))
|
|
default:
|
|
shrink := math.Min(1, math.Min(ratio(sourceWidth, boxWidth), ratio(sourceHeight, boxHeight)))
|
|
return scaledOutput(boxWidth, boxHeight, shrink)
|
|
}
|
|
}
|
|
|
|
func proportionalOutput(sourceWidth int, sourceHeight int, settings VariantSettings) (int, int) {
|
|
scale := 1.0
|
|
if settings.Width != nil {
|
|
scale = math.Min(scale, ratio(*settings.Width, sourceWidth))
|
|
}
|
|
if settings.Height != nil {
|
|
scale = math.Min(scale, ratio(*settings.Height, sourceHeight))
|
|
}
|
|
return scaledOutput(sourceWidth, sourceHeight, scale)
|
|
}
|
|
|
|
func scaledOutput(width int, height int, factor float64) (int, int) {
|
|
return scaledLength(width, factor), scaledLength(height, factor)
|
|
}
|
|
|
|
func scaledLength(length int, factor float64) int {
|
|
return max(1, int(math.Round(float64(length)*factor)))
|
|
}
|
|
|
|
func ratio(numerator int, denominator int) float64 {
|
|
return float64(numerator) / float64(denominator)
|
|
}
|
|
|
|
var sourceContentTypeFormats = map[string]VariantFormat{
|
|
"image/jpeg": FormatJPEG,
|
|
"image/png": FormatPNG,
|
|
"image/webp": FormatWebP,
|
|
"image/gif": FormatPNG,
|
|
}
|
|
|
|
func OutputFormat(format VariantFormat, sourceContentType string, encodable []VariantFormat) VariantFormat {
|
|
if format == FormatOriginal {
|
|
format = sourceContentTypeFormats[sourceContentType]
|
|
}
|
|
if slices.Contains(encodable, format) {
|
|
return format
|
|
}
|
|
return FormatPNG
|
|
}
|