feat(photos): add square medium variant for sharp grid thumbnails

This commit is contained in:
iu
2026-10-11 19:57:00 +03:00
parent 581e479f03
commit f1b3b01bdf
27 changed files with 459 additions and 32 deletions
+1
View File
@@ -20,6 +20,7 @@ const (
const (
RoleOriginal = "original"
RoleThumb = "thumb"
RoleMedium = "medium"
RolePreview = "preview"
)
+13 -6
View File
@@ -121,24 +121,31 @@ func ExtractEXIFAttributes(data []byte) map[string]any {
return extractEXIF(data).Attributes
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
func buildVariants(data []byte) (thumb, medium, preview []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
// Grid variants are square crops so a square tile is never upscaled; the
// viewer preview keeps the aspect ratio.
thumbImg := imaging.Fill(img, 200, 200, imaging.Center, imaging.Lanczos)
mediumImg := imaging.Fill(img, 800, 800, imaging.Center, imaging.Lanczos)
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
opts := webp.Options{Quality: 82}
var thumbBuf bytes.Buffer
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
return nil, nil, err
return nil, nil, nil, err
}
var mediumBuf bytes.Buffer
if err := webp.Encode(&mediumBuf, mediumImg, opts); err != nil {
return nil, nil, nil, err
}
var previewBuf bytes.Buffer
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
return nil, nil, err
return nil, nil, nil, err
}
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
return thumbBuf.Bytes(), mediumBuf.Bytes(), previewBuf.Bytes(), nil
}
// checksum returns the hex-encoded SHA-256 of the given bytes.
+5
View File
@@ -133,6 +133,11 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
p.ThumbURL = url
}
}
if p.MediumKey != "" {
if url, err := s.storage.URL(ctx, p.MediumKey); err == nil {
p.MediumURL = url
}
}
if p.PreviewKey != "" {
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
p.PreviewURL = url
+5 -2
View File
@@ -22,6 +22,7 @@ func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(o_o.storage_key, ''),
COALESCE(o_t.storage_key, ''),
COALESCE(o_m.storage_key, ''),
COALESCE(o_p.storage_key, ''),
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -40,6 +41,8 @@ const sqlitePhotoJoins = `
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
@@ -50,7 +53,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
var lat, long sql.NullFloat64
var liked int
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
@@ -271,7 +274,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
var lat, long sql.NullFloat64
var liked int
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
+6 -3
View File
@@ -29,6 +29,7 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(o_o.storage_key, ''),
COALESCE(o_t.storage_key, ''),
COALESCE(o_m.storage_key, ''),
COALESCE(o_p.storage_key, ''),
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -46,6 +47,8 @@ const photoJoins = `
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
@@ -53,7 +56,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText, tagsText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
@@ -186,7 +189,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
var p models.Photo
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
@@ -299,7 +302,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
var p models.Photo
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
+5 -1
View File
@@ -125,7 +125,7 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
w.fail(ctx, photoID, "fetch original: "+err.Error())
return
}
thumb, preview, err := buildVariants(data)
thumb, medium, preview, err := buildVariants(data)
if err != nil {
w.fail(ctx, photoID, "build variants: "+err.Error())
return
@@ -138,6 +138,10 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
w.fail(ctx, photoID, "store thumb: "+err.Error())
return
}
if err := w.createVariant(ctx, siblings, RoleMedium, medium); err != nil {
w.fail(ctx, photoID, "store medium: "+err.Error())
return
}
if err := w.createVariant(ctx, siblings, RolePreview, preview); err != nil {
w.fail(ctx, photoID, "store preview: "+err.Error())
return
+5 -5
View File
@@ -59,13 +59,13 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
if err != nil {
t.Fatalf("get refreshed: %v", err)
}
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, refreshed.PreviewKey)
if refreshed.ThumbKey == "" || refreshed.MediumKey == "" || refreshed.PreviewKey == "" {
t.Fatalf("variants not bound: thumb=%q medium=%q preview=%q", refreshed.ThumbKey, refreshed.MediumKey, refreshed.PreviewKey)
}
if err := svc.attachURLs(ctx, refreshed); err != nil {
t.Fatalf("attach urls: %v", err)
}
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
if refreshed.ThumbURL == "" || refreshed.MediumURL == "" || refreshed.PreviewURL == "" {
t.Fatalf("variant urls not set")
}
@@ -78,8 +78,8 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
}
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
if err != nil || len(objectIDs) != 3 {
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
if err != nil || len(objectIDs) != 4 {
t.Fatalf("expected 4 bound objects, got %v err=%v", objectIDs, err)
}
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {