feat: image variants per image
This commit is contained in:
1 parent
f567866161
commit
951fef18ba
92 files changed
+8695
-413
No files matched your search
@@ -82,6 +82,8 @@ const (
|
||||
ActionVariantEnable = "media.variant_enable"
|
||||
ActionVariantDisable = "media.variant_disable"
|
||||
ActionVariantPurge = "media.variant_purge"
|
||||
ActionVariantGenerateAll = "media.variant_generate_all"
|
||||
ActionVariantPurgeImage = "media.variant_purge_image"
|
||||
ActionUserRegister = "user.register"
|
||||
ActionUserVerify = "user.verify"
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
@@ -121,6 +123,9 @@ const (
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamGenerated = "generated"
|
||||
ParamFailed = "failed"
|
||||
ParamCauses = "causes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
|
||||
@@ -99,10 +99,11 @@ func StoredName(imagePath string) string {
|
||||
type Sort string
|
||||
|
||||
const (
|
||||
SortNewest Sort = "newest"
|
||||
SortOldest Sort = "oldest"
|
||||
SortName Sort = "name"
|
||||
SortLargest Sort = "largest"
|
||||
SortNewest Sort = "newest"
|
||||
SortOldest Sort = "oldest"
|
||||
SortName Sort = "name"
|
||||
SortLargest Sort = "largest"
|
||||
SortTotalSize Sort = "total_size"
|
||||
)
|
||||
|
||||
type Filter string
|
||||
@@ -113,13 +114,22 @@ const (
|
||||
FilterDuplicates Filter = "duplicates"
|
||||
)
|
||||
|
||||
type VariantsFilter string
|
||||
|
||||
const (
|
||||
VariantsAny VariantsFilter = ""
|
||||
VariantsWith VariantsFilter = "with"
|
||||
VariantsWithout VariantsFilter = "without"
|
||||
)
|
||||
|
||||
type ListQuery struct {
|
||||
Search string
|
||||
Hash string
|
||||
Filter Filter
|
||||
Sort Sort
|
||||
Limit int
|
||||
Cursor string
|
||||
Search string
|
||||
Hash string
|
||||
Filter Filter
|
||||
Variants VariantsFilter
|
||||
Sort Sort
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func NewListQuery() ListQuery {
|
||||
@@ -133,7 +143,12 @@ func (q ListQuery) Validate() error {
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
switch q.Sort {
|
||||
case SortNewest, SortOldest, SortName, SortLargest:
|
||||
case SortNewest, SortOldest, SortName, SortLargest, SortTotalSize:
|
||||
default:
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
switch q.Variants {
|
||||
case VariantsAny, VariantsWith, VariantsWithout:
|
||||
default:
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
@@ -175,6 +190,19 @@ func SortImages(images []Image, order Sort) {
|
||||
})
|
||||
}
|
||||
|
||||
func SortImagesByTotalSize(images []Image, totals map[string]VariantFileTotals) {
|
||||
totalOf := func(img Image) int64 {
|
||||
return img.Size + totals[StoredName(img.Path)].Bytes
|
||||
}
|
||||
sort.SliceStable(images, func(i, j int) bool {
|
||||
a, b := images[i], images[j]
|
||||
if totalOf(a) != totalOf(b) {
|
||||
return totalOf(a) > totalOf(b)
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
All int
|
||||
DuplicateGroups int
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +19,8 @@ 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
|
||||
|
||||
@@ -220,3 +224,62 @@ 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
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type VariantFileRepository interface {
|
||||
ListBySource(sourceName string) ([]VariantFile, error)
|
||||
Delete(id uint32) error
|
||||
Totals() (map[string]VariantFileTotals, error)
|
||||
TotalsBySource() (map[string]VariantFileTotals, error)
|
||||
}
|
||||
|
||||
type RenderedVariant struct {
|
||||
|
||||
@@ -13,43 +13,26 @@ type layout struct {
|
||||
}
|
||||
|
||||
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
|
||||
outputWidth, outputHeight := domain.OutputSize(source.X, source.Y, settings)
|
||||
output := image.Pt(outputWidth, outputHeight)
|
||||
if settings.Width == nil || settings.Height == nil {
|
||||
return proportionalLayout(source, settings)
|
||||
return wholeSourceLayout(source, output)
|
||||
}
|
||||
box := image.Pt(*settings.Width, *settings.Height)
|
||||
switch settings.Fit {
|
||||
case domain.FitContain:
|
||||
return containLayout(source, box)
|
||||
return containLayout(source, output)
|
||||
case domain.FitInside:
|
||||
return insideLayout(source, box)
|
||||
return wholeSourceLayout(source, output)
|
||||
default:
|
||||
return coverLayout(source, box)
|
||||
return coverLayout(source, output)
|
||||
}
|
||||
}
|
||||
|
||||
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
|
||||
scale := 1.0
|
||||
if settings.Width != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Width, source.X))
|
||||
}
|
||||
if settings.Height != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Height, source.Y))
|
||||
}
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func insideLayout(source image.Point, box image.Point) layout {
|
||||
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func wholeSourceLayout(source image.Point, output image.Point) layout {
|
||||
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func coverLayout(source image.Point, box image.Point) layout {
|
||||
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
|
||||
output := scaled(box, shrink)
|
||||
func coverLayout(source image.Point, output image.Point) layout {
|
||||
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,26 @@ func (v *VariantFileRepository) Delete(id uint32) error {
|
||||
return v.db.Delete(&entity.ImageVariantFile{}, id).Error
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) TotalsBySource() (map[string]domain.VariantFileTotals, error) {
|
||||
var rows []struct {
|
||||
SourceName string
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
err := v.db.Model(&entity.ImageVariantFile{}).
|
||||
Select("source_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
|
||||
Group("source_name").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals := make(map[string]domain.VariantFileTotals, len(rows))
|
||||
for _, row := range rows {
|
||||
totals[row.SourceName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
|
||||
var rows []struct {
|
||||
VariantName string
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type GenerateAllResult struct {
|
||||
Generated int
|
||||
Failed int
|
||||
Failures []VariantFailure
|
||||
Items []ImageVariantView
|
||||
}
|
||||
|
||||
type GenerateAllImageVariantsUseCase struct {
|
||||
images image.Repository
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
renderer image.VariantRenderer
|
||||
generator *ServeVariantUseCase
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewGenerateAllImageVariantsUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer, generator *ServeVariantUseCase, recorder audit.Recorder) *GenerateAllImageVariantsUseCase {
|
||||
return &GenerateAllImageVariantsUseCase{images, variants, files, renderer, generator, recorder}
|
||||
}
|
||||
|
||||
func (u *GenerateAllImageVariantsUseCase) Generate(ctx context.Context, id uint32) (GenerateAllResult, error) {
|
||||
img, err := u.images.Get(id)
|
||||
if err != nil {
|
||||
return GenerateAllResult{}, err
|
||||
}
|
||||
if isVectorSource(img) {
|
||||
return GenerateAllResult{}, image.ErrVariantSourceUnsupported
|
||||
}
|
||||
enabled, err := enabledVariants(u.variants)
|
||||
if err != nil {
|
||||
return GenerateAllResult{}, err
|
||||
}
|
||||
storedName := image.StoredName(img.Path)
|
||||
rows, err := currentFiles(u.files, storedName)
|
||||
if err != nil {
|
||||
return GenerateAllResult{}, err
|
||||
}
|
||||
|
||||
result := GenerateAllResult{Failures: []VariantFailure{}}
|
||||
var firstFailure error
|
||||
for _, variant := range enabled {
|
||||
if isCurrent(variant, rows) {
|
||||
continue
|
||||
}
|
||||
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
|
||||
result.Failed++
|
||||
result.Failures = append(result.Failures, VariantFailure{Name: variant.Name, Cause: generationCause(err)})
|
||||
if firstFailure == nil {
|
||||
firstFailure = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
result.Generated++
|
||||
}
|
||||
|
||||
refreshed, err := currentFiles(u.files, storedName)
|
||||
if err != nil {
|
||||
return GenerateAllResult{}, err
|
||||
}
|
||||
result.Items = viewsOf(enabled, img, refreshed, u.renderer.EncodableFormats())
|
||||
u.record(ctx, img, result, firstFailure)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *GenerateAllImageVariantsUseCase) record(ctx context.Context, img image.Image, result GenerateAllResult, firstFailure error) {
|
||||
if result.Generated == 0 && result.Failed == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionVariantGenerateAll, audit.CategoryMedia)
|
||||
event.Target = imageTarget(img)
|
||||
event.Params = map[string]any{
|
||||
audit.ParamGenerated: result.Generated,
|
||||
audit.ParamFailed: result.Failed,
|
||||
audit.ParamCauses: distinctCauses(result.Failures),
|
||||
}
|
||||
if result.Generated == 0 {
|
||||
failFrom(ctx, u.recorder, event, reasonFor(firstFailure))
|
||||
return
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func distinctCauses(failures []VariantFailure) []string {
|
||||
causes := []string{}
|
||||
for _, failure := range failures {
|
||||
if !slices.Contains(causes, failure.Cause) {
|
||||
causes = append(causes, failure.Cause)
|
||||
}
|
||||
}
|
||||
return causes
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type GenerateImageVariantUseCase struct {
|
||||
images image.Repository
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
renderer image.VariantRenderer
|
||||
generator *ServeVariantUseCase
|
||||
}
|
||||
|
||||
func NewGenerateImageVariantUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer, generator *ServeVariantUseCase) *GenerateImageVariantUseCase {
|
||||
return &GenerateImageVariantUseCase{images, variants, files, renderer, generator}
|
||||
}
|
||||
|
||||
func (u *GenerateImageVariantUseCase) Generate(ctx context.Context, id uint32, name string) (ImageVariantView, error) {
|
||||
img, err := u.images.Get(id)
|
||||
if err != nil {
|
||||
return ImageVariantView{}, err
|
||||
}
|
||||
variant, err := u.variants.Get(name)
|
||||
if err != nil {
|
||||
return ImageVariantView{}, err
|
||||
}
|
||||
if !variant.Enabled || variant.Name != name {
|
||||
return ImageVariantView{}, image.ErrVariantNotFound
|
||||
}
|
||||
if isVectorSource(img) {
|
||||
return ImageVariantView{}, image.ErrVariantSourceUnsupported
|
||||
}
|
||||
storedName := image.StoredName(img.Path)
|
||||
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
|
||||
return ImageVariantView{}, err
|
||||
}
|
||||
rows, err := currentFiles(u.files, storedName)
|
||||
if err != nil {
|
||||
return ImageVariantView{}, err
|
||||
}
|
||||
return viewOf(variant, img, rows, u.renderer.EncodableFormats()), nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"log"
|
||||
)
|
||||
|
||||
type GenerateVariantsOnUploadUseCase struct {
|
||||
variants image.VariantRepository
|
||||
generator *ServeVariantUseCase
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewGenerateVariantsOnUploadUseCase(variants image.VariantRepository, generator *ServeVariantUseCase, logger *log.Logger) *GenerateVariantsOnUploadUseCase {
|
||||
return &GenerateVariantsOnUploadUseCase{variants, generator, logger}
|
||||
}
|
||||
|
||||
func (u *GenerateVariantsOnUploadUseCase) Generate(ctx context.Context, img image.Image) {
|
||||
if isVectorSource(img) {
|
||||
return
|
||||
}
|
||||
enabled, err := enabledVariants(u.variants)
|
||||
if err != nil {
|
||||
u.logger.Printf("listing variants to generate on upload failed: image=%d: %v", img.ID, err)
|
||||
return
|
||||
}
|
||||
storedName := image.StoredName(img.Path)
|
||||
for _, variant := range enabled {
|
||||
if !variant.GenerateOnUpload {
|
||||
continue
|
||||
}
|
||||
if _, err := u.generator.Generate(ctx, variant, storedName); err != nil {
|
||||
u.logger.Printf("generating variant on upload failed: variant=%s image=%d: %v", variant.Name, img.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type ListImageVariantsUseCase struct {
|
||||
images image.Repository
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
renderer image.VariantRenderer
|
||||
}
|
||||
|
||||
func NewListImageVariantsUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, renderer image.VariantRenderer) *ListImageVariantsUseCase {
|
||||
return &ListImageVariantsUseCase{images, variants, files, renderer}
|
||||
}
|
||||
|
||||
func (u *ListImageVariantsUseCase) List(id uint32) (ImageVariantList, error) {
|
||||
img, err := u.images.Get(id)
|
||||
if err != nil {
|
||||
return ImageVariantList{}, err
|
||||
}
|
||||
if isVectorSource(img) {
|
||||
return ImageVariantList{Unsupported: unsupportedSVG, Items: []ImageVariantView{}}, nil
|
||||
}
|
||||
enabled, err := enabledVariants(u.variants)
|
||||
if err != nil {
|
||||
return ImageVariantList{}, err
|
||||
}
|
||||
rows, err := currentFiles(u.files, image.StoredName(img.Path))
|
||||
if err != nil {
|
||||
return ImageVariantList{}, err
|
||||
}
|
||||
return ImageVariantList{Items: viewsOf(enabled, img, rows, u.renderer.EncodableFormats())}, nil
|
||||
}
|
||||
@@ -16,6 +16,9 @@ type MediaItem struct {
|
||||
UploadedBy string
|
||||
UsedIn int
|
||||
Duplicate bool
|
||||
|
||||
VariantCount int
|
||||
VariantSize int64
|
||||
}
|
||||
|
||||
type MediaCounts struct {
|
||||
@@ -34,10 +37,11 @@ type ListMediaUseCase struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
userRepository user.Repository
|
||||
variantFiles image.VariantFileRepository
|
||||
}
|
||||
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository, articleRepository, userRepository}
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository, articleRepository, userRepository, variantFiles}
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
@@ -61,16 +65,20 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
if err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
variantTotals, err := l.variantFiles.TotalsBySource()
|
||||
if err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
|
||||
images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
|
||||
if err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
image.SortImages(images, query.Sort)
|
||||
sortImages(images, query.Sort, variantTotals)
|
||||
|
||||
matching := make([]image.Image, 0, len(images))
|
||||
for _, img := range images {
|
||||
if matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
|
||||
if matchesVariants(query.Variants, variantTotals[image.StoredName(img.Path)].Files) && matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
|
||||
matching = append(matching, img)
|
||||
}
|
||||
}
|
||||
@@ -84,11 +92,14 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
if err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
own := variantTotals[image.StoredName(img.Path)]
|
||||
items = append(items, MediaItem{
|
||||
Image: img,
|
||||
UploadedBy: uploadedBy,
|
||||
UsedIn: usage(img),
|
||||
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
|
||||
Image: img,
|
||||
UploadedBy: uploadedBy,
|
||||
UsedIn: usage(img),
|
||||
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
|
||||
VariantCount: own.Files,
|
||||
VariantSize: own.Bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,7 +129,16 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
duplicate = len(sameContent) > 1
|
||||
}
|
||||
|
||||
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate}, nil
|
||||
rows, err := l.variantFiles.ListBySource(image.StoredName(img.Path))
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
var variantSize int64
|
||||
for _, row := range rows {
|
||||
variantSize += row.Size
|
||||
}
|
||||
|
||||
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||
@@ -168,6 +188,25 @@ func (l *ListMediaUseCase) counts(usage func(image.Image) int) (MediaCounts, err
|
||||
return MediaCounts{All: libraryCounts.All, Unused: unused, Duplicates: libraryCounts.DuplicateGroups}, nil
|
||||
}
|
||||
|
||||
func sortImages(images []image.Image, order image.Sort, totals map[string]image.VariantFileTotals) {
|
||||
if order == image.SortTotalSize {
|
||||
image.SortImagesByTotalSize(images, totals)
|
||||
return
|
||||
}
|
||||
image.SortImages(images, order)
|
||||
}
|
||||
|
||||
func matchesVariants(filter image.VariantsFilter, variantFiles int) bool {
|
||||
switch filter {
|
||||
case image.VariantsWith:
|
||||
return variantFiles > 0
|
||||
case image.VariantsWithout:
|
||||
return variantFiles == 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
|
||||
switch filter {
|
||||
case image.FilterUnused:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type PurgeImageVariantUseCase struct {
|
||||
images image.Repository
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
cache variantCache
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewPurgeImageVariantUseCase(images image.Repository, variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeImageVariantUseCase {
|
||||
return &PurgeImageVariantUseCase{images, variants, files, variantCache{files, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *PurgeImageVariantUseCase) Purge(ctx context.Context, id uint32, name string) (VariantPurgeResult, error) {
|
||||
img, err := u.images.Get(id)
|
||||
if err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
variant, err := u.variants.Get(name)
|
||||
if err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
if variant.Name != name {
|
||||
return VariantPurgeResult{}, image.ErrVariantNotFound
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantPurgeImage, audit.CategoryMedia)
|
||||
event.Target = imageTarget(img)
|
||||
row, found, err := u.files.Find(name, image.StoredName(img.Path))
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return VariantPurgeResult{}, nil
|
||||
}
|
||||
purged, err := u.cache.purge([]image.VariantFile{row})
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
event.Params = variantPurgeParams(purged)
|
||||
u.recorder.Record(ctx, event)
|
||||
return purged, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
@@ -263,7 +264,7 @@ func (u *ServeVariantUseCase) render(ctx context.Context, settings image.Variant
|
||||
func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error {
|
||||
width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w: %v", image.ErrVariantSourceUnreadable, err)
|
||||
}
|
||||
if int64(width)*int64(height) > u.maxSourcePixels {
|
||||
return image.ErrSourceTooLarge
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
func (u *ServeVariantUseCase) Generate(ctx context.Context, variant image.Variant, storedName string) (image.VariantFile, error) {
|
||||
source, err := u.storage.Open(storedName)
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
defer source.Content.Close()
|
||||
|
||||
if servedAsIs(storedName, source.Content) {
|
||||
return image.VariantFile{}, image.ErrVariantSourceUnsupported
|
||||
}
|
||||
if source.Size > u.maxSourceBytes {
|
||||
return image.VariantFile{}, image.ErrSourceTooLarge
|
||||
}
|
||||
|
||||
settings := variant.Settings()
|
||||
fingerprint := settings.Fingerprint()
|
||||
served, existing, hit, err := u.lookupCached(variant.Name, storedName, fingerprint, image.VariantETag(settings, storedName))
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
if hit {
|
||||
_ = served.Content.Close()
|
||||
return existing, nil
|
||||
}
|
||||
return u.generated(ctx, variant, storedName, fingerprint, existing)
|
||||
}
|
||||
@@ -29,6 +29,9 @@ var failureReasons = []failureReason{
|
||||
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
|
||||
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
|
||||
{image.ErrMergeHashMismatch, "The images are not identical copies."},
|
||||
{image.ErrSourceTooLarge, "The source image is too large to generate this variant."},
|
||||
{image.ErrVariantSourceUnsupported, "Variants aren't generated for this format."},
|
||||
{image.ErrVariantSourceUnreadable, "The source image can't be read, so this variant can't be generated."},
|
||||
{image.ErrVariantExists, "A variant with that name already exists."},
|
||||
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
|
||||
{purge.ErrPurgeBusy, "A purge is already running."},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
svgContentType = "image/svg+xml"
|
||||
unsupportedSVG = "svg"
|
||||
causeTooLarge = "too_large"
|
||||
causeUnsupported = "unsupported_format"
|
||||
causeFailed = "failed"
|
||||
variantURLQuery = "?" + image.VariantQueryParameter + "="
|
||||
)
|
||||
|
||||
type ImageVariantView struct {
|
||||
Variant image.Variant
|
||||
OutputWidth int
|
||||
OutputHeight int
|
||||
OutputFormat image.VariantFormat
|
||||
Generated bool
|
||||
Size int64
|
||||
URL string
|
||||
}
|
||||
|
||||
type ImageVariantList struct {
|
||||
Unsupported string
|
||||
Items []ImageVariantView
|
||||
}
|
||||
|
||||
type VariantFailure struct {
|
||||
Name string
|
||||
Cause string
|
||||
}
|
||||
|
||||
func isVectorSource(img image.Image) bool {
|
||||
return img.ContentType == svgContentType
|
||||
}
|
||||
|
||||
func enabledVariants(variants image.VariantRepository) ([]image.Variant, error) {
|
||||
listed, err := variants.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := make([]image.Variant, 0, len(listed))
|
||||
for _, variant := range listed {
|
||||
if variant.Enabled {
|
||||
enabled = append(enabled, variant)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(enabled, func(a, b image.Variant) int { return strings.Compare(a.Name, b.Name) })
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func currentFiles(files image.VariantFileRepository, storedName string) (map[string]image.VariantFile, error) {
|
||||
rows, err := files.ListBySource(storedName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byVariant := make(map[string]image.VariantFile, len(rows))
|
||||
for _, row := range rows {
|
||||
byVariant[row.VariantName] = row
|
||||
}
|
||||
return byVariant, nil
|
||||
}
|
||||
|
||||
func isCurrent(variant image.Variant, rows map[string]image.VariantFile) bool {
|
||||
row, found := rows[variant.Name]
|
||||
return found && row.Fingerprint == variant.Settings().Fingerprint()
|
||||
}
|
||||
|
||||
func viewOf(variant image.Variant, img image.Image, rows map[string]image.VariantFile, encodable []image.VariantFormat) ImageVariantView {
|
||||
width, height := image.OutputSize(img.Width, img.Height, variant.Settings())
|
||||
view := ImageVariantView{
|
||||
Variant: variant,
|
||||
OutputWidth: width,
|
||||
OutputHeight: height,
|
||||
OutputFormat: image.OutputFormat(variant.Format, img.ContentType, encodable),
|
||||
URL: image.PublicPath(image.StoredName(img.Path)) + variantURLQuery + variant.Name,
|
||||
}
|
||||
if isCurrent(variant, rows) {
|
||||
view.Generated = true
|
||||
view.Size = rows[variant.Name].Size
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func viewsOf(variants []image.Variant, img image.Image, rows map[string]image.VariantFile, encodable []image.VariantFormat) []ImageVariantView {
|
||||
views := make([]ImageVariantView, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
views = append(views, viewOf(variant, img, rows, encodable))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func generationCause(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, image.ErrSourceTooLarge):
|
||||
return causeTooLarge
|
||||
case errors.Is(err, image.ErrVariantSourceUnreadable):
|
||||
return causeUnsupported
|
||||
default:
|
||||
return causeFailed
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user