feat: image variants engine

This commit is contained in:
fsylvain committed 2026-10-08 02:33:32 +02:00
1 parent f0845de643
commit f1d56ef50e
80 files changed
+7312 -84

No files matched your search

+114
View File
@@ -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
}
+222
View File
@@ -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
}
+54
View File
@@ -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
}