feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -2,6 +2,7 @@ package audit
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -28,3 +29,34 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
|
||||
changes := []Change{}
|
||||
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
|
||||
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
|
||||
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
|
||||
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
|
||||
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendChange(changes []Change, field string, before string, after string) []Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
return append(changes, TextChange(field, before, after))
|
||||
}
|
||||
|
||||
func VariantParams(settings image.VariantSettings) map[string]any {
|
||||
params := map[string]any{ParamFit: string(settings.Fit), ParamFormat: string(settings.Format)}
|
||||
if settings.Width != nil {
|
||||
params[ParamWidth] = *settings.Width
|
||||
}
|
||||
if settings.Height != nil {
|
||||
params[ParamHeight] = *settings.Height
|
||||
}
|
||||
if settings.Quality != nil {
|
||||
params[ParamQuality] = *settings.Quality
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -76,6 +76,12 @@ const (
|
||||
ActionMediaDelete = "media.delete"
|
||||
ActionMediaMerge = "media.merge"
|
||||
ActionMediaPurge = "media.purge"
|
||||
ActionVariantCreate = "media.variant_create"
|
||||
ActionVariantUpdate = "media.variant_update"
|
||||
ActionVariantDelete = "media.variant_delete"
|
||||
ActionVariantEnable = "media.variant_enable"
|
||||
ActionVariantDisable = "media.variant_disable"
|
||||
ActionVariantPurge = "media.variant_purge"
|
||||
ActionUserRegister = "user.register"
|
||||
ActionUserVerify = "user.verify"
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
@@ -94,6 +100,7 @@ const (
|
||||
TargetUser = "user"
|
||||
TargetArticle = "article"
|
||||
TargetImage = "image"
|
||||
TargetVariant = "image_variant"
|
||||
TargetInvite = "invite"
|
||||
TargetRelease = "release"
|
||||
)
|
||||
@@ -112,12 +119,24 @@ const (
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
FieldWidth = "width"
|
||||
FieldHeight = "height"
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -9,3 +9,7 @@ func NewTarget(targetType string, id uint32, name string) Target {
|
||||
func ReleaseTarget(tag string) Target {
|
||||
return Target{Type: TargetRelease, ID: tag, Name: tag}
|
||||
}
|
||||
|
||||
func VariantTarget(name string) Target {
|
||||
return Target{Type: TargetVariant, ID: name, Name: name}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var ErrNotAGif = errors.New("not a gif")
|
||||
|
||||
const (
|
||||
gifSignaturePrefix = "GIF"
|
||||
gifHeaderLength = 6
|
||||
gifScreenDescriptorSize = 7
|
||||
gifImageDescriptorSize = 9
|
||||
gifExtensionIntroducer = 0x21
|
||||
gifImageSeparator = 0x2C
|
||||
gifTrailer = 0x3B
|
||||
gifColorTableFlag = 0x80
|
||||
gifColorTableSizeMask = 0x07
|
||||
gifColorTableEntryBytes = 3
|
||||
gifFramesBeforeAnimation = 2
|
||||
)
|
||||
|
||||
func IsAnimatedGIF(source io.Reader) (bool, error) {
|
||||
reader := bufio.NewReader(source)
|
||||
if err := skipGifPreamble(reader); err != nil {
|
||||
return false, err
|
||||
}
|
||||
frames := 0
|
||||
for {
|
||||
marker, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch marker {
|
||||
case gifTrailer:
|
||||
return false, nil
|
||||
case gifExtensionIntroducer:
|
||||
err = skipGifExtension(reader)
|
||||
case gifImageSeparator:
|
||||
frames++
|
||||
if frames >= gifFramesBeforeAnimation {
|
||||
return true, nil
|
||||
}
|
||||
err = skipGifImage(reader)
|
||||
default:
|
||||
return false, ErrNotAGif
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipGifPreamble(reader *bufio.Reader) error {
|
||||
header := make([]byte, gifHeaderLength)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(header[:len(gifSignaturePrefix)]) != gifSignaturePrefix {
|
||||
return ErrNotAGif
|
||||
}
|
||||
screen := make([]byte, gifScreenDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, screen); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifColorTable(reader, screen[4])
|
||||
}
|
||||
|
||||
func skipGifColorTable(reader *bufio.Reader, packed byte) error {
|
||||
if packed&gifColorTableFlag == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := 1 << (int(packed&gifColorTableSizeMask) + 1)
|
||||
_, err := reader.Discard(entries * gifColorTableEntryBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func skipGifExtension(reader *bufio.Reader) error {
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifImage(reader *bufio.Reader) error {
|
||||
descriptor := make([]byte, gifImageDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, descriptor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := skipGifColorTable(reader, descriptor[8]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifSubBlocks(reader *bufio.Reader) error {
|
||||
for {
|
||||
size, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := reader.Discard(int(size)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type Repository interface {
|
||||
Rename(id uint32, filename string) (Image, error)
|
||||
ListDuplicates() ([]Image, error)
|
||||
Counts() (Counts, error)
|
||||
ExistsByStoredName(storedName string) (bool, error)
|
||||
ListMissingMetadata() ([]Image, error)
|
||||
UpdateMetadata(id uint32, metadata Metadata) error
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"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")
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package image
|
||||
|
||||
import "io"
|
||||
|
||||
type VariantRepository interface {
|
||||
List() ([]Variant, error)
|
||||
Get(name string) (Variant, error)
|
||||
Create(variant Variant) (Variant, error)
|
||||
Update(variant Variant) (Variant, error)
|
||||
Delete(name string) error
|
||||
}
|
||||
|
||||
type VariantFileRepository interface {
|
||||
Find(variantName string, sourceName string) (VariantFile, bool, error)
|
||||
Save(file VariantFile) (VariantFile, error)
|
||||
ListByVariant(variantName string) ([]VariantFile, error)
|
||||
ListBySource(sourceName string) ([]VariantFile, error)
|
||||
Delete(id uint32) error
|
||||
Totals() (map[string]VariantFileTotals, error)
|
||||
}
|
||||
|
||||
type RenderedVariant struct {
|
||||
Content []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type VariantRenderer interface {
|
||||
DeclaredSize(source io.Reader) (width int, height int, err error)
|
||||
Render(source io.Reader, settings VariantSettings) (RenderedVariant, error)
|
||||
EncodableFormats() []VariantFormat
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const etagDigestLength = 32
|
||||
|
||||
type VariantRequestError struct {
|
||||
cause error
|
||||
Detail string
|
||||
}
|
||||
|
||||
func NewUnknownVariantError(name string) error {
|
||||
return VariantRequestError{cause: ErrUnknownVariant, Detail: name}
|
||||
}
|
||||
|
||||
func NewUnsupportedParameterError(parameter string) error {
|
||||
return VariantRequestError{cause: ErrUnsupportedParameter, Detail: parameter}
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Error() string {
|
||||
return e.cause.Error() + ": " + e.Detail
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func VariantRequestDetail(err error) string {
|
||||
var requestError VariantRequestError
|
||||
if errors.As(err, &requestError) {
|
||||
return requestError.Detail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func VariantETag(settings VariantSettings, sourceName string) string {
|
||||
digest := sha256.Sum256([]byte(settings.Fingerprint() + "|" + sourceName))
|
||||
return `"` + hex.EncodeToString(digest[:])[:etagDigestLength] + `"`
|
||||
}
|
||||
|
||||
func ETagMatches(ifNoneMatch string, etag string) bool {
|
||||
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
|
||||
if trimmed == "*" || trimmed == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user