Files
RenewCMS/internal/domain/image/variantDomain.go
T

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
}