feat: image variants job and variants in the post API

- golem15_journal_image_variants (migration 202610280002): a thumbnail
  per width of golem15.journal.image_widths (480, 800, 1200 by default,
  never wider than the original) and its WebP copy, with the original's
  size
- conga job golem15.journal.image_variants (three attempts), queued after
  a featured image upload and after an admin save of a post whose images
  lack a width; the upload request never encodes
- journal:image-variants [--all] backfills
- featured_images[].variants {width, height, url, webp_url, source_width,
  source_height} on the post answers (additive key)
This commit is contained in:
Jakub Zych
2026-11-04 17:05:16 +01:00
parent ee3cb263dc
commit 4f5bd190e5
13 changed files with 784 additions and 5 deletions

View File

@@ -468,6 +468,8 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
stored.AttachmentID = idStr
stored.Field = "featured_images"
purgeSiteCache(r, app, post)
// The variants are written by a job, never in this request (D-85).
classes.DispatchImageVariants(r.Context(), app, []uint{stored.ID})
wire.WriteJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"id": stored.ID,
@@ -535,6 +537,10 @@ func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
// The variants' blobs share the thumbnail prefix of BlobKeys.
if err := db.WithContext(r.Context()).Where("file_id = ?", file.ID).Delete(&models.ImageVariant{}).Error; err != nil {
pluginLog.Warn("journal: delete image variants", "file", file.ID, "error", err)
}
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
_ = attach.DeleteKeys(r.Context(), bucket, keys)
}
@@ -761,13 +767,48 @@ func featuredImages(db *gorm.DB, p *models.Post) []map[string]any {
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
return []map[string]any{}
}
variants := imageVariants(db, files)
out := make([]map[string]any, 0, len(files))
for i := range files {
list := variants[files[i].ID]
if list == nil {
list = []map[string]any{}
}
out = append(out, map[string]any{
"id": files[i].ID,
"url": files[i].URL(),
"file_name": files[i].FileName,
"file_size": files[i].FileSize,
"variants": list,
})
}
return out
}
// imageVariants lists the variants of files in one query, by file and
// width (D-85): {width, height, url, webp_url} and, on each, the
// original's source_width and source_height.
func imageVariants(db *gorm.DB, files []attach.File) map[uint][]map[string]any {
out := map[uint][]map[string]any{}
if len(files) == 0 {
return out
}
ids := make([]uint, 0, len(files))
for _, f := range files {
ids = append(ids, f.ID)
}
var rows []models.ImageVariant
if err := db.Where("file_id IN ?", ids).Order("file_id ASC, width ASC").Find(&rows).Error; err != nil {
return out
}
for _, v := range rows {
out[v.FileID] = append(out[v.FileID], map[string]any{
"width": v.Width,
"height": v.Height,
"url": attach.PublicURL(v.Key),
"webp_url": attach.PublicURL(v.WebPKey),
"source_width": v.SourceWidth,
"source_height": v.SourceHeight,
})
}
return out