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) }) }