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
@@ -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