From f1b3b01bdf2feb9f1fdd4f67ac2efe8efcca2270 Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 19:57:00 +0300 Subject: [PATCH] feat(photos): add square medium variant for sharp grid thumbnails --- backend/internal/albums/service.go | 22 +++++- backend/internal/albums/sqlite_store.go | 8 +- backend/internal/albums/store.go | 12 ++- backend/internal/models/models.go | 8 +- backend/internal/photos/objects.go | 1 + backend/internal/photos/pipeline.go | 19 +++-- backend/internal/photos/service.go | 5 ++ backend/internal/photos/sqlite_store.go | 7 +- backend/internal/photos/store.go | 9 ++- backend/internal/photos/worker.go | 6 +- backend/internal/photos/worker_test.go | 10 +-- backend/internal/repo/contract/contract.go | 34 ++++++++- .../000011_photo_medium_variant.down.sql | 5 ++ .../000011_photo_medium_variant.up.sql | 11 +++ .../000003_photo_medium_variant.down.sql | 18 +++++ .../sqlite/000003_photo_medium_variant.up.sql | 24 ++++++ frontend/src/lib/components/PhotoGrid.svelte | 23 ++++++ frontend/src/lib/types.ts | 2 + frontend/src/routes/(app)/+page.svelte | 23 ++++++ .../changes/thumbnail-variants/.openspec.yaml | 2 + openspec/changes/thumbnail-variants/design.md | 73 +++++++++++++++++++ .../changes/thumbnail-variants/proposal.md | 47 ++++++++++++ .../thumbnail-variants/specs/albums/spec.md | 24 ++++++ .../specs/media-objects/spec.md | 19 +++++ .../thumbnail-variants/specs/photos/spec.md | 19 +++++ .../specs/ui-workspace/spec.md | 23 ++++++ openspec/changes/thumbnail-variants/tasks.md | 37 ++++++++++ 27 files changed, 459 insertions(+), 32 deletions(-) create mode 100644 backend/migrations/000011_photo_medium_variant.down.sql create mode 100644 backend/migrations/000011_photo_medium_variant.up.sql create mode 100644 backend/migrations/sqlite/000003_photo_medium_variant.down.sql create mode 100644 backend/migrations/sqlite/000003_photo_medium_variant.up.sql create mode 100644 openspec/changes/thumbnail-variants/.openspec.yaml create mode 100644 openspec/changes/thumbnail-variants/design.md create mode 100644 openspec/changes/thumbnail-variants/proposal.md create mode 100644 openspec/changes/thumbnail-variants/specs/albums/spec.md create mode 100644 openspec/changes/thumbnail-variants/specs/media-objects/spec.md create mode 100644 openspec/changes/thumbnail-variants/specs/photos/spec.md create mode 100644 openspec/changes/thumbnail-variants/specs/ui-workspace/spec.md create mode 100644 openspec/changes/thumbnail-variants/tasks.md diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 46883d0..c52c2d5 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -61,13 +61,27 @@ func (s *Service) List(ctx context.Context, userID string) ([]models.Album, erro return out, nil } -// attachCover fills CoverThumbURL from the signed thumbnail URL, when present. +// attachCover fills CoverThumbURL and CoverMediumURL from signed URLs. The +// medium URL falls back to the thumbnail while the medium variant is not ready, +// so a cover URL suitable for large display is always present when a cover is +// set. func (s *Service) attachCover(ctx context.Context, a *models.Album) { - if s.storage == nil || a == nil || a.CoverThumbKey == "" { + if s.storage == nil || a == nil { return } - if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil { - a.CoverThumbURL = url + if a.CoverThumbKey != "" { + if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil { + a.CoverThumbURL = url + } + } + key := a.CoverMediumKey + if key == "" { + key = a.CoverThumbKey + } + if key != "" { + if url, err := s.storage.URL(ctx, key); err == nil { + a.CoverMediumURL = url + } } } diff --git a/backend/internal/albums/sqlite_store.go b/backend/internal/albums/sqlite_store.go index 22d1654..9a98e92 100644 --- a/backend/internal/albums/sqlite_store.go +++ b/backend/internal/albums/sqlite_store.go @@ -46,20 +46,22 @@ func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) { } const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id, - a.created_at, a.updated_at, COALESCE(co.storage_key, ''), + a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id), (SELECT count(*) FROM album_members WHERE album_id = a.id)` const sqliteAlbumJoins = ` LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb' - LEFT JOIN objects co ON co.id = cpo.object_id` + LEFT JOIN objects co ON co.id = cpo.object_id + LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium' + LEFT JOIN objects com ON com.id = cpm.object_id` func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) { var a models.Album var cover sql.NullString var createdAt, updatedAt string if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt, - &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil { + &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount); err != nil { return nil, err } if cover.Valid { diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 40539b2..9dfefba 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -44,14 +44,16 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { var a models.Album err := s.pool.QueryRow(ctx, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, - COALESCE(co.storage_key, ''), + COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb' LEFT JOIN objects co ON co.id = cpo.object_id + LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium' + LEFT JOIN objects com ON com.id = cpm.object_id WHERE a.id = $1`, id). - Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount) + Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -64,13 +66,15 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) { rows, err := s.pool.Query(ctx, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, - COALESCE(co.storage_key, ''), + COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1 LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb' LEFT JOIN objects co ON co.id = cpo.object_id + LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium' + LEFT JOIN objects com ON com.id = cpm.object_id WHERE a.owner_id = $1 OR m.user_id IS NOT NULL ORDER BY a.created_at DESC`, userID) if err != nil { @@ -81,7 +85,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, out := []models.Album{} for rows.Next() { var a models.Album - if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil { + if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount); err != nil { return nil, err } out = append(out, a) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index c6835dc..e1c2045 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -32,8 +32,10 @@ type Album struct { Title string `json:"title"` Description string `json:"description"` CoverPhotoID *string `json:"cover_photo_id,omitempty"` - CoverThumbKey string `json:"-"` - CoverThumbURL string `json:"cover_thumb_url,omitempty"` + CoverThumbKey string `json:"-"` + CoverThumbURL string `json:"cover_thumb_url,omitempty"` + CoverMediumKey string `json:"-"` + CoverMediumURL string `json:"cover_medium_url,omitempty"` PhotosCount int `json:"photos_count"` MembersCount int `json:"members_count"` CreatedAt time.Time `json:"created_at"` @@ -67,6 +69,7 @@ type Photo struct { UploaderID string `json:"uploader_id"` StorageKey string `json:"-"` ThumbKey string `json:"-"` + MediumKey string `json:"-"` PreviewKey string `json:"-"` OriginalFilename string `json:"original_filename"` Size int64 `json:"size"` @@ -87,6 +90,7 @@ type Photo struct { CommentsCount int `json:"comments_count"` LikedByMe bool `json:"liked_by_me"` ThumbURL string `json:"thumb_url,omitempty"` + MediumURL string `json:"medium_url,omitempty"` PreviewURL string `json:"preview_url,omitempty"` OriginalURL string `json:"original_url,omitempty"` DownloadURL string `json:"download_url,omitempty"` diff --git a/backend/internal/photos/objects.go b/backend/internal/photos/objects.go index e0edaaf..2e3872b 100644 --- a/backend/internal/photos/objects.go +++ b/backend/internal/photos/objects.go @@ -20,6 +20,7 @@ const ( const ( RoleOriginal = "original" RoleThumb = "thumb" + RoleMedium = "medium" RolePreview = "preview" ) diff --git a/backend/internal/photos/pipeline.go b/backend/internal/photos/pipeline.go index 928315f..a2fdf85 100644 --- a/backend/internal/photos/pipeline.go +++ b/backend/internal/photos/pipeline.go @@ -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. diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 22bf691..7f587f6 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -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 diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go index e7958df..b6ef20f 100644 --- a/backend/internal/photos/sqlite_store.go +++ b/backend/internal/photos/sqlite_store.go @@ -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, diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 9f44e56..a377e8b 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -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, diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index ebaec6d..c96d9f3 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -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 diff --git a/backend/internal/photos/worker_test.go b/backend/internal/photos/worker_test.go index 0273a3e..fe2efb0 100644 --- a/backend/internal/photos/worker_test.go +++ b/backend/internal/photos/worker_test.go @@ -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 { diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index e257650..38b7f4d 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -382,6 +382,23 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) { t.Fatalf("cover not set: %+v", al.CoverPhotoID) } + // The album exposes the cover's medium variant so the overview can render + // it sharply. + coverMedium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectReady} + if err := r.Photos.CreateObject(ctx, coverMedium); err != nil { + t.Fatalf("create cover medium: %v", err) + } + if err := r.Photos.ActivateObject(ctx, coverMedium.ID); err != nil { + t.Fatalf("activate cover medium: %v", err) + } + if err := r.Photos.BindObject(ctx, p.ID, coverMedium.ID, photos.RoleMedium); err != nil { + t.Fatalf("bind cover medium: %v", err) + } + al, _ = r.Albums.GetByID(ctx, a.ID) + if al.CoverMediumKey != coverMedium.StorageKey { + t.Fatalf("cover medium key = %q want %q", al.CoverMediumKey, coverMedium.StorageKey) + } + // Clearing the cover only affects a matching photo id. r.Photos.ClearCoverIfMatches(ctx, a.ID, newID()) al, _ = r.Albums.GetByID(ctx, a.ID) @@ -468,6 +485,19 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { t.Fatalf("thumb refs = %d, want 1", refs) } + // The medium role is a first-class binding, so it must be accepted by the + // role constraint on every backend. + medium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} + if err := r.Photos.CreateObject(ctx, medium); err != nil { + t.Fatalf("create medium object: %v", err) + } + if err := r.Photos.ActivateObject(ctx, medium.ID); err != nil { + t.Fatalf("activate medium: %v", err) + } + if err := r.Photos.BindObject(ctx, p.ID, medium.ID, photos.RoleMedium); err != nil { + t.Fatalf("bind medium: %v", err) + } + // After the photo is gone, its objects have no bindings and are collectible. if err := r.Photos.HardDelete(ctx, p.ID); err != nil { t.Fatalf("hard delete: %v", err) @@ -480,8 +510,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { for _, o := range claimed { got[o.ID] = true } - if !got[orig.ID] || !got[thumb.ID] { - t.Fatalf("claimed = %+v, want original and thumb", got) + if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] { + t.Fatalf("claimed = %+v, want original, thumb and medium", got) } for _, o := range claimed { if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil { diff --git a/backend/migrations/000011_photo_medium_variant.down.sql b/backend/migrations/000011_photo_medium_variant.down.sql new file mode 100644 index 0000000..8edb9b8 --- /dev/null +++ b/backend/migrations/000011_photo_medium_variant.down.sql @@ -0,0 +1,5 @@ +-- Drop medium bindings before narrowing the role constraint again. +DELETE FROM photo_objects WHERE role = 'medium'; +ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check; +ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check + CHECK (role IN ('original', 'thumb', 'preview')); diff --git a/backend/migrations/000011_photo_medium_variant.up.sql b/backend/migrations/000011_photo_medium_variant.up.sql new file mode 100644 index 0000000..ee84926 --- /dev/null +++ b/backend/migrations/000011_photo_medium_variant.up.sql @@ -0,0 +1,11 @@ +-- The grid serves a square medium variant between thumb and preview. +ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check; +ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check + CHECK (role IN ('original', 'thumb', 'medium', 'preview')); + +-- Regenerate variants for existing photos so they gain the medium variant and a +-- square thumb. The worker rebinds roles; superseded variants are reclaimed by +-- the reference-counting collector. +INSERT INTO variant_jobs (photo_id) +SELECT id FROM photos +ON CONFLICT (photo_id) DO NOTHING; diff --git a/backend/migrations/sqlite/000003_photo_medium_variant.down.sql b/backend/migrations/sqlite/000003_photo_medium_variant.down.sql new file mode 100644 index 0000000..9325a5b --- /dev/null +++ b/backend/migrations/sqlite/000003_photo_medium_variant.down.sql @@ -0,0 +1,18 @@ +-- Drop medium bindings and restore the original three-role constraint. +DELETE FROM photo_objects WHERE role = 'medium'; + +CREATE TABLE photo_objects_old ( + photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT, + role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'preview')), + PRIMARY KEY (photo_id, role) +); + +INSERT INTO photo_objects_old (photo_id, object_id, role) +SELECT photo_id, object_id, role FROM photo_objects; + +DROP TABLE photo_objects; + +ALTER TABLE photo_objects_old RENAME TO photo_objects; + +CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id); diff --git a/backend/migrations/sqlite/000003_photo_medium_variant.up.sql b/backend/migrations/sqlite/000003_photo_medium_variant.up.sql new file mode 100644 index 0000000..ad50f31 --- /dev/null +++ b/backend/migrations/sqlite/000003_photo_medium_variant.up.sql @@ -0,0 +1,24 @@ +-- SQLite cannot alter a CHECK constraint, so recreate photo_objects with the +-- extended role set (medium added) and copy the existing bindings across. +CREATE TABLE photo_objects_new ( + photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT, + role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'medium', 'preview')), + PRIMARY KEY (photo_id, role) +); + +INSERT INTO photo_objects_new (photo_id, object_id, role) +SELECT photo_id, object_id, role FROM photo_objects; + +DROP TABLE photo_objects; + +ALTER TABLE photo_objects_new RENAME TO photo_objects; + +CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id); + +-- Regenerate variants for existing photos so they gain the medium variant and a +-- square thumb. +INSERT INTO variant_jobs (photo_id) +SELECT id FROM photos +WHERE 1 +ON CONFLICT (photo_id) DO NOTHING; diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte index 7668df6..f93206d 100644 --- a/frontend/src/lib/components/PhotoGrid.svelte +++ b/frontend/src/lib/components/PhotoGrid.svelte @@ -28,6 +28,26 @@ onCursorFocus }: Props = $props(); + // gap-2 in the grid template is 0.5rem. + const GAP_PX = 8; + let gridWidth = $state(0); + + // Offer every variant the photo actually has; the backend generates the + // medium variant asynchronously, so an incomplete set must still render. + function srcsetFor(photo: Photo): string { + const parts: string[] = []; + if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`); + if (photo.medium_url) parts.push(`${photo.medium_url} 800w`); + if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`); + return parts.join(', '); + } + + const sizes = $derived( + gridWidth > 0 + ? `${Math.max(1, Math.round((gridWidth - (density.cols - 1) * GAP_PX) / density.cols))}px` + : undefined + ); + const isSelected = (id: string) => selectedIds.includes(id); const isCursor = (i: number) => cursorIndex === i; const tabIndexFor = (i: number) => @@ -47,6 +67,7 @@ {:else}