Files
sm-journal-plugin/classes/image_variants.go
Jakub Zych 45d66f6071 fix: tall originals no longer stop the image variants job
A width whose variant would be taller than the thumbnailer's 4096 px
edge limit is skipped with a warning and counted, like a width wider
than the original, so the job and journal:image-variants go on with the
next widths and images.
2026-11-04 23:12:43 +01:00

293 lines
10 KiB
Go

package classes
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/lagoon/attach/webp"
"gocloud.dev/blob"
"gocloud.dev/gcerrors"
"gorm.io/gorm"
)
// The image variants job and its configuration (2026-10-28 image formats,
// D-85, P33-43).
const (
// ImageVariantsKind is the conga job kind golem15.journal.image_variants.
ImageVariantsKind = "golem15.journal.image_variants"
// ImageWidthsKey is the config key of the variant widths, in pixels.
ImageWidthsKey = "golem15.journal.image_widths"
// ImageVariantsMode is the thumbnail mode of a variant: the exact size
// of the original's aspect ratio at the variant's width.
ImageVariantsMode = "exact"
// FeaturedImagesField is the system_files field of a post's featured
// images.
FeaturedImagesField = "featured_images"
// MinImageWidth and MaxImageWidth bound a configured width.
MinImageWidth = 16
MaxImageWidth = 4096
)
// variantsLog logs the image variants as component journal.
var variantsLog = diary.For("journal")
// DefaultImageWidths are the variant widths without configuration.
var DefaultImageWidths = []int{480, 800, 1200}
// ImageVariantsArgs are the arguments of the image variants job: the
// system_files ids of the featured images to make variants of.
type ImageVariantsArgs struct {
FileIDs []uint `json:"file_ids"`
}
// Kind is the job kind, golem15.journal.image_variants.
func (ImageVariantsArgs) Kind() string { return ImageVariantsKind }
// ImageWidths reads golem15.journal.image_widths: a list of widths or a
// comma-separated text (an environment override), each 16 to 4096, sorted
// and once each. An unset, empty or invalid value answers
// DefaultImageWidths.
func ImageWidths(cfg *compass.Config) []int {
if cfg == nil {
return slices.Clone(DefaultImageWidths)
}
raw, ok := cfg.Lookup(ImageWidthsKey)
if !ok || raw == nil {
return slices.Clone(DefaultImageWidths)
}
var parts []string
switch v := raw.(type) {
case string:
parts = strings.Split(v, ",")
case []any:
for _, item := range v {
parts = append(parts, fmt.Sprint(item))
}
case []int:
for _, n := range v {
parts = append(parts, strconv.Itoa(n))
}
default:
parts = []string{fmt.Sprint(v)}
}
var out []int
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
n, err := strconv.Atoi(p)
if err != nil || n < MinImageWidth || n > MaxImageWidth {
return slices.Clone(DefaultImageWidths)
}
out = append(out, n)
}
if len(out) == 0 {
return slices.Clone(DefaultImageWidths)
}
slices.Sort(out)
return slices.Compact(out)
}
// VariantsResult is what one run of GenerateImageVariants did.
type VariantsResult struct {
Files int
Written int
Skipped int
}
// maxHeaderBytes caps the bytes read for an original's size.
const maxHeaderBytes = 1 << 20
// sourceSize reads an original's size from its header; ok is false for a
// missing original, one that does not decode, or one over the thumbnailer's
// pixel limit (attach would store the broken-image picture for it).
func sourceSize(ctx context.Context, bucket *blob.Bucket, f attach.File) (w, h int, ok bool, err error) {
r, err := bucket.NewReader(ctx, attach.BlobKey(f.DiskName), nil)
if gcerrors.Code(err) == gcerrors.NotFound {
return 0, 0, false, nil
}
if err != nil {
return 0, 0, false, err
}
raw, err := io.ReadAll(io.LimitReader(r, maxHeaderBytes))
closeErr := r.Close()
if err != nil {
return 0, 0, false, err
}
if closeErr != nil {
return 0, 0, false, closeErr
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return 0, 0, false, nil
}
if int64(cfg.Width)*int64(cfg.Height) > attach.MaxImagePixels {
return 0, 0, false, nil
}
return cfg.Width, cfg.Height, true, nil
}
// GenerateImageVariants writes the variants of the files ids (D-85): for
// each width of widths no larger than the original's, the thumbnail at that
// width (attach.File.ThumbKey, mode exact, the original's aspect ratio) and
// its WebP copy (webp.Variant), recorded in golem15_journal_image_variants.
// A width that already has its row is skipped, so a second run writes
// nothing. A missing, undecodable or oversized original is skipped and
// logged. A storage or database error is returned (the job retries).
func GenerateImageVariants(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, widths []int, ids []uint) (VariantsResult, error) {
var out VariantsResult
if db == nil || bucket == nil {
return out, errors.New("journal: image variants need the database and the uploads bucket")
}
if len(ids) == 0 {
return out, nil
}
var files []attach.File
if err := db.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&files).Error; err != nil {
return out, err
}
for _, f := range files {
out.Files++
var have []int
if err := db.WithContext(ctx).Model(&models.ImageVariant{}).Where("file_id = ?", f.ID).Pluck("width", &have).Error; err != nil {
return out, err
}
var todo []int
for _, w := range widths {
if !slices.Contains(have, w) {
todo = append(todo, w)
}
}
if len(todo) == 0 {
continue
}
sw, sh, ok, err := sourceSize(ctx, bucket, f)
if err != nil {
return out, fmt.Errorf("journal: read original %d: %w", f.ID, err)
}
if !ok {
variantsLog.Warn("journal: no image variants for an unusable original", "file", f.ID)
out.Skipped++
continue
}
for _, w := range todo {
if w > sw {
out.Skipped++
continue
}
h := max(1, int((int64(sh)*int64(w)+int64(sw)/2)/int64(sw)))
if h > MaxImageWidth {
// A tall original (an infographic): the variant would leave
// the thumbnailer's edge limit, so this width is skipped like
// one wider than the original, and the job goes on.
variantsLog.Warn("journal: no image variant taller than the edge limit", "file", f.ID, "width", w, "height", h, "max", MaxImageWidth)
out.Skipped++
continue
}
key, err := f.ThumbKey(ctx, bucket, w, h, ImageVariantsMode)
if err != nil {
return out, fmt.Errorf("journal: thumbnail %d at %d: %w", f.ID, w, err)
}
webpKey, err := webp.Variant(ctx, bucket, &f, w, h, ImageVariantsMode)
if err != nil {
return out, fmt.Errorf("journal: webp %d at %d: %w", f.ID, w, err)
}
row := models.ImageVariant{FileID: f.ID, Width: w, Height: h, Key: key, WebPKey: webpKey, SourceWidth: sw, SourceHeight: sh, CreatedAt: time.Now().UTC()}
res := db.WithContext(ctx).Exec(`INSERT INTO golem15_journal_image_variants (file_id, width, height, key, webp_key, source_width, source_height, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (file_id, width) DO NOTHING`, row.FileID, row.Width, row.Height, row.Key, row.WebPKey, row.SourceWidth, row.SourceHeight, row.CreatedAt)
if res.Error != nil {
return out, res.Error
}
out.Written += int(res.RowsAffected)
}
}
return out, nil
}
// featuredFiles is the featured images of the posts, or of every post when
// postIDs is nil; missing is true to keep only the images without any
// variant row, or without a row for a width no larger than their original.
func featuredFiles(db *gorm.DB, postIDs []uint, widths []int, missing bool) ([]uint, error) {
q := db.Model(&attach.File{}).Where("attachment_type = ? AND field = ?", (models.Post{}).MorphName(), FeaturedImagesField)
if postIDs != nil {
keys := make([]string, 0, len(postIDs))
for _, id := range postIDs {
keys = append(keys, strconv.FormatUint(uint64(id), 10))
}
q = q.Where("attachment_id IN ?", keys)
}
if missing {
list := make([]string, 0, len(widths))
for _, w := range widths {
list = append(list, strconv.Itoa(w))
}
q = q.Where(`NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
OR EXISTS (SELECT 1 FROM unnest(?::int[]) AS w(width)
WHERE w.width <= (SELECT MAX(v.source_width) FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
AND NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id AND v.width = w.width))`, "{"+strings.Join(list, ",")+"}")
}
var ids []uint
err := q.Order("id ASC").Pluck("id", &ids).Error
return ids, err
}
// FeaturedImageIDs lists the featured images of every post (all) or only
// those missing a variant of a configured width no larger than their
// original (or without any variant yet).
func FeaturedImageIDs(db *gorm.DB, cfg *compass.Config, all bool) ([]uint, error) {
return featuredFiles(db, nil, ImageWidths(cfg), !all)
}
// DispatchImageVariants queues the image variants job for the files ids.
// The upload or save that called it has committed; a queue that cannot
// take the job is logged and never fails that request (D-85).
func DispatchImageVariants(ctx context.Context, app *backpack.App, ids []uint) {
if app == nil || len(ids) == 0 {
return
}
m, err := conga.From(app)
if err == nil {
_, err = m.Dispatch(ctx, nil, ImageVariantsArgs{FileIDs: ids}, conga.DispatchOpts{Label: "Journal image variants"})
}
if err != nil {
variantsLog.Warn("journal: image variants job not queued", "files", len(ids), "error", err)
}
}
// DispatchPostImageVariantsAfterCommit queues the image variants job, once
// tx commits, for the post's featured images that lack a variant of some
// width (the admin save: the uploads it binds commit with it).
func DispatchPostImageVariantsAfterCommit(ctx context.Context, tx *gorm.DB, app *backpack.App, postID uint) {
if app == nil || postID == 0 {
return
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return
}
ids, err := featuredFiles(db.WithContext(ctx), []uint{postID}, ImageWidths(app.Config), true)
if err != nil {
variantsLog.Warn("journal: image variants of a saved post", "post", postID, "error", err)
return
}
DispatchImageVariants(ctx, app, ids)
})
}