feat(photos): add square medium variant for sharp grid thumbnails
This commit is contained in:
@@ -61,15 +61,29 @@ func (s *Service) List(ctx context.Context, userID string) ([]models.Album, erro
|
|||||||
return out, nil
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
if a.CoverThumbKey != "" {
|
||||||
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
|
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
|
||||||
a.CoverThumbURL = url
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||||
a, err := s.store.GetByID(ctx, albumID)
|
a, err := s.store.GetByID(ctx, albumID)
|
||||||
|
|||||||
@@ -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,
|
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 photos WHERE album_id = a.id),
|
||||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
|
||||||
|
|
||||||
const sqliteAlbumJoins = `
|
const sqliteAlbumJoins = `
|
||||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
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) {
|
func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
|
||||||
var a models.Album
|
var a models.Album
|
||||||
var cover sql.NullString
|
var cover sql.NullString
|
||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if cover.Valid {
|
if cover.Valid {
|
||||||
|
|||||||
@@ -44,14 +44,16 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
|||||||
var a models.Album
|
var a models.Album
|
||||||
err := s.pool.QueryRow(ctx,
|
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,
|
`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 photos WHERE album_id = a.id),
|
||||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
FROM albums a
|
FROM albums a
|
||||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
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
|
||||||
WHERE a.id = $1`, 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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
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) {
|
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
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,
|
`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 photos WHERE album_id = a.id),
|
||||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
FROM albums a
|
FROM albums a
|
||||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
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 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
|
||||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||||
ORDER BY a.created_at DESC`, userID)
|
ORDER BY a.created_at DESC`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,7 +85,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
|||||||
out := []models.Album{}
|
out := []models.Album{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Album
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type Album struct {
|
|||||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||||
CoverThumbKey string `json:"-"`
|
CoverThumbKey string `json:"-"`
|
||||||
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||||
|
CoverMediumKey string `json:"-"`
|
||||||
|
CoverMediumURL string `json:"cover_medium_url,omitempty"`
|
||||||
PhotosCount int `json:"photos_count"`
|
PhotosCount int `json:"photos_count"`
|
||||||
MembersCount int `json:"members_count"`
|
MembersCount int `json:"members_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -67,6 +69,7 @@ type Photo struct {
|
|||||||
UploaderID string `json:"uploader_id"`
|
UploaderID string `json:"uploader_id"`
|
||||||
StorageKey string `json:"-"`
|
StorageKey string `json:"-"`
|
||||||
ThumbKey string `json:"-"`
|
ThumbKey string `json:"-"`
|
||||||
|
MediumKey string `json:"-"`
|
||||||
PreviewKey string `json:"-"`
|
PreviewKey string `json:"-"`
|
||||||
OriginalFilename string `json:"original_filename"`
|
OriginalFilename string `json:"original_filename"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@@ -87,6 +90,7 @@ type Photo struct {
|
|||||||
CommentsCount int `json:"comments_count"`
|
CommentsCount int `json:"comments_count"`
|
||||||
LikedByMe bool `json:"liked_by_me"`
|
LikedByMe bool `json:"liked_by_me"`
|
||||||
ThumbURL string `json:"thumb_url,omitempty"`
|
ThumbURL string `json:"thumb_url,omitempty"`
|
||||||
|
MediumURL string `json:"medium_url,omitempty"`
|
||||||
PreviewURL string `json:"preview_url,omitempty"`
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
OriginalURL string `json:"original_url,omitempty"`
|
OriginalURL string `json:"original_url,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
RoleOriginal = "original"
|
RoleOriginal = "original"
|
||||||
RoleThumb = "thumb"
|
RoleThumb = "thumb"
|
||||||
|
RoleMedium = "medium"
|
||||||
RolePreview = "preview"
|
RolePreview = "preview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -121,24 +121,31 @@ func ExtractEXIFAttributes(data []byte) map[string]any {
|
|||||||
return extractEXIF(data).Attributes
|
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))
|
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||||
if err != nil {
|
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)
|
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
||||||
|
|
||||||
opts := webp.Options{Quality: 82}
|
opts := webp.Options{Quality: 82}
|
||||||
var thumbBuf bytes.Buffer
|
var thumbBuf bytes.Buffer
|
||||||
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
|
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
|
var previewBuf bytes.Buffer
|
||||||
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
|
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.
|
// checksum returns the hex-encoded SHA-256 of the given bytes.
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
|||||||
p.ThumbURL = url
|
p.ThumbURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.MediumKey != "" {
|
||||||
|
if url, err := s.storage.URL(ctx, p.MediumKey); err == nil {
|
||||||
|
p.MediumURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.PreviewKey != "" {
|
if p.PreviewKey != "" {
|
||||||
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
|
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
|
||||||
p.PreviewURL = url
|
p.PreviewURL = url
|
||||||
|
|||||||
@@ -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,
|
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||||
COALESCE(o_o.storage_key, ''),
|
COALESCE(o_o.storage_key, ''),
|
||||||
COALESCE(o_t.storage_key, ''),
|
COALESCE(o_t.storage_key, ''),
|
||||||
|
COALESCE(o_m.storage_key, ''),
|
||||||
COALESCE(o_p.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.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,
|
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 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 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 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 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`
|
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 lat, long sql.NullFloat64
|
||||||
var liked int
|
var liked int
|
||||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
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,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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 lat, long sql.NullFloat64
|
||||||
var liked int
|
var liked int
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
|
|||||||
@@ -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,
|
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||||
COALESCE(o_o.storage_key, ''),
|
COALESCE(o_o.storage_key, ''),
|
||||||
COALESCE(o_t.storage_key, ''),
|
COALESCE(o_t.storage_key, ''),
|
||||||
|
COALESCE(o_m.storage_key, ''),
|
||||||
COALESCE(o_p.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.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,
|
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 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 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 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 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`
|
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 p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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 p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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 p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
|
|||||||
w.fail(ctx, photoID, "fetch original: "+err.Error())
|
w.fail(ctx, photoID, "fetch original: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
thumb, preview, err := buildVariants(data)
|
thumb, medium, preview, err := buildVariants(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.fail(ctx, photoID, "build variants: "+err.Error())
|
w.fail(ctx, photoID, "build variants: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -138,6 +138,10 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
|
|||||||
w.fail(ctx, photoID, "store thumb: "+err.Error())
|
w.fail(ctx, photoID, "store thumb: "+err.Error())
|
||||||
return
|
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 {
|
if err := w.createVariant(ctx, siblings, RolePreview, preview); err != nil {
|
||||||
w.fail(ctx, photoID, "store preview: "+err.Error())
|
w.fail(ctx, photoID, "store preview: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get refreshed: %v", err)
|
t.Fatalf("get refreshed: %v", err)
|
||||||
}
|
}
|
||||||
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
|
if refreshed.ThumbKey == "" || refreshed.MediumKey == "" || refreshed.PreviewKey == "" {
|
||||||
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, 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 {
|
if err := svc.attachURLs(ctx, refreshed); err != nil {
|
||||||
t.Fatalf("attach urls: %v", err)
|
t.Fatalf("attach urls: %v", err)
|
||||||
}
|
}
|
||||||
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
|
if refreshed.ThumbURL == "" || refreshed.MediumURL == "" || refreshed.PreviewURL == "" {
|
||||||
t.Fatalf("variant urls not set")
|
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)
|
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
|
||||||
if err != nil || len(objectIDs) != 3 {
|
if err != nil || len(objectIDs) != 4 {
|
||||||
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
|
t.Fatalf("expected 4 bound objects, got %v err=%v", objectIDs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
|
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
|
||||||
|
|||||||
@@ -382,6 +382,23 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
t.Fatalf("cover not set: %+v", al.CoverPhotoID)
|
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.
|
// Clearing the cover only affects a matching photo id.
|
||||||
r.Photos.ClearCoverIfMatches(ctx, a.ID, newID())
|
r.Photos.ClearCoverIfMatches(ctx, a.ID, newID())
|
||||||
al, _ = r.Albums.GetByID(ctx, a.ID)
|
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)
|
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.
|
// After the photo is gone, its objects have no bindings and are collectible.
|
||||||
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
||||||
t.Fatalf("hard delete: %v", err)
|
t.Fatalf("hard delete: %v", err)
|
||||||
@@ -480,8 +510,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
for _, o := range claimed {
|
for _, o := range claimed {
|
||||||
got[o.ID] = true
|
got[o.ID] = true
|
||||||
}
|
}
|
||||||
if !got[orig.ID] || !got[thumb.ID] {
|
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] {
|
||||||
t.Fatalf("claimed = %+v, want original and thumb", got)
|
t.Fatalf("claimed = %+v, want original, thumb and medium", got)
|
||||||
}
|
}
|
||||||
for _, o := range claimed {
|
for _, o := range claimed {
|
||||||
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
||||||
|
|||||||
@@ -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'));
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -28,6 +28,26 @@
|
|||||||
onCursorFocus
|
onCursorFocus
|
||||||
}: Props = $props();
|
}: 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 isSelected = (id: string) => selectedIds.includes(id);
|
||||||
const isCursor = (i: number) => cursorIndex === i;
|
const isCursor = (i: number) => cursorIndex === i;
|
||||||
const tabIndexFor = (i: number) =>
|
const tabIndexFor = (i: number) =>
|
||||||
@@ -47,6 +67,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul
|
<ul
|
||||||
class="grid gap-2"
|
class="grid gap-2"
|
||||||
|
bind:clientWidth={gridWidth}
|
||||||
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
>
|
>
|
||||||
{#each photos as photo, i (photo.id)}
|
{#each photos as photo, i (photo.id)}
|
||||||
@@ -76,6 +97,8 @@
|
|||||||
{#if photo.thumb_url}
|
{#if photo.thumb_url}
|
||||||
<img
|
<img
|
||||||
src={photo.thumb_url}
|
src={photo.thumb_url}
|
||||||
|
srcset={srcsetFor(photo)}
|
||||||
|
{sizes}
|
||||||
alt={photo.original_filename}
|
alt={photo.original_filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface Album {
|
|||||||
description: string;
|
description: string;
|
||||||
cover_photo_id?: string | null;
|
cover_photo_id?: string | null;
|
||||||
cover_thumb_url?: string;
|
cover_thumb_url?: string;
|
||||||
|
cover_medium_url?: string;
|
||||||
photos_count?: number;
|
photos_count?: number;
|
||||||
members_count?: number;
|
members_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -64,6 +65,7 @@ export interface Photo {
|
|||||||
comments_count: number;
|
comments_count: number;
|
||||||
liked_by_me: boolean;
|
liked_by_me: boolean;
|
||||||
thumb_url?: string;
|
thumb_url?: string;
|
||||||
|
medium_url?: string;
|
||||||
preview_url?: string;
|
preview_url?: string;
|
||||||
original_url?: string;
|
original_url?: string;
|
||||||
download_url?: string;
|
download_url?: string;
|
||||||
|
|||||||
@@ -12,6 +12,26 @@
|
|||||||
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// gap-3 in the grid template is 0.75rem.
|
||||||
|
const GAP_PX = 12;
|
||||||
|
let gridWidth = $state(0);
|
||||||
|
|
||||||
|
// The medium cover URL falls back to the thumbnail until the medium variant
|
||||||
|
// is generated, so dedupe identical sources.
|
||||||
|
function coverSrcset(album: Album): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (album.cover_thumb_url) parts.push(`${album.cover_thumb_url} 200w`);
|
||||||
|
if (album.cover_medium_url && album.cover_medium_url !== album.cover_thumb_url)
|
||||||
|
parts.push(`${album.cover_medium_url} 800w`);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverSizes = $derived(
|
||||||
|
gridWidth > 0
|
||||||
|
? `${Math.max(1, Math.round((gridWidth - (density.cols - 1) * GAP_PX) / density.cols))}px`
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
|
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -53,6 +73,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ul
|
<ul
|
||||||
class="grid gap-3"
|
class="grid gap-3"
|
||||||
|
bind:clientWidth={gridWidth}
|
||||||
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
>
|
>
|
||||||
{#each albums.list as album (album.id)}
|
{#each albums.list as album (album.id)}
|
||||||
@@ -68,6 +89,8 @@
|
|||||||
{#if album.cover_thumb_url}
|
{#if album.cover_thumb_url}
|
||||||
<img
|
<img
|
||||||
src={album.cover_thumb_url}
|
src={album.cover_thumb_url}
|
||||||
|
srcset={coverSrcset(album)}
|
||||||
|
sizes={coverSizes}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. `proposal.md` для мотивации. Технические факты, формирующие подход:
|
||||||
|
|
||||||
|
- Генерация вариантов — `photos/pipeline.go:buildVariants`: `thumb = Fit(200,200)`,
|
||||||
|
`preview = Fit(1200,1200)`, WebP q82. Оба кропа вписывают по длинной стороне.
|
||||||
|
- Метаданные имеют две реализации (Postgres и SQLite) с общей контрактной проверкой
|
||||||
|
`internal/repo/contract`; SQL дублируется (`photos/store.go`, `photos/sqlite_store.go`).
|
||||||
|
- Роли заданы жёстко: `photo_objects.role CHECK (role IN ('original','thumb','preview'))`
|
||||||
|
(`migrations/000008`, `migrations/sqlite/000001`).
|
||||||
|
- Варианты генерирует фоновый воркер из очереди `variant_jobs`; лишние объекты удаляются
|
||||||
|
сборщиком мусора по отсутствию ссылок (`photos/worker.go`).
|
||||||
|
- Фронтенд-сетка берёт только `thumb_url` (`PhotoGrid.svelte:78`); обложки — только
|
||||||
|
`cover_thumb_url` (`albums/store.go` джойнит `role='thumb'`).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Сетка фото и обзор альбомов показывают изображение без апскейла при любом выбранном размере.
|
||||||
|
- Единое поведение на Postgres и SQLite; контрактные тесты покрывают новую роль.
|
||||||
|
- Бэкфилл существующих фото без ручных операций.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Динамоческое ресайзирование на лету / image-proxy.
|
||||||
|
- Иные форматы (AVIF) или согласование форматов.
|
||||||
|
- Изменение `preview` (просмотрщик) и оригинала.
|
||||||
|
- Замер точной плотности DPR-пикселей и «идеальный» выбор варианта — допускается запас.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Набор вариантов.** `thumb = Fill(200,200)` (квадрат), `medium = Fill(800,800)` (квадрат),
|
||||||
|
`preview = Fit(1200,1200)`. Причина: плитка сетки квадратная (`aspect-square object-cover`),
|
||||||
|
квадратный кроп исключает лишний апскейл короткой стороны; просмотрщик не квадратный, поэтому
|
||||||
|
`preview` остаётся вписанным. Альтернатива — оставить `thumb` вписанным: отклонена, короткая
|
||||||
|
сторона выходит < 200 и мылит уже мелкие плитки.
|
||||||
|
- **Роль `medium` в `photo_objects`.** Роли маппятся 1:1 на поля API и на привязки; расширяем
|
||||||
|
ограничение значением `medium`. Альтернатива — отдельная таблица вариантов: избыточна для
|
||||||
|
одного размера.
|
||||||
|
- **`srcset` + `sizes`.** Ширины `200w/800w/1200w`; `sizes` берётся из фактической ширины сетки
|
||||||
|
(`bind:clientWidth` на `<ul>`, делённой на число колонок). Альтернатива — оценка по `100vw`:
|
||||||
|
неточна из-за рейла и правой панели, ведёт к перекачке 1200.
|
||||||
|
- **Бэкфилл через очередь.** Миграция делает `INSERT ... SELECT id FROM photos` в
|
||||||
|
`variant_jobs`; воркер пересоздаёт роли и перепривязывает, старые объекты уходят в GC по
|
||||||
|
refcount. Альтернатива — воркер, пропускающий уже готовые роли: требует версионирования
|
||||||
|
вариантов; альтернатива — отдельный `cmd/backfill`: лишний код. Выбран самый простой путь.
|
||||||
|
- **Обложка.** Джойн medium-варианта обложки с откатом на `thumb`, если medium ещё не готов;
|
||||||
|
отдельного поля «preview обложки» не вводим.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Перегенерация `preview` впустую при бэкфилле] → воркер работает батчами и параллельно
|
||||||
|
(настраивается); лишние объекты удаляются сборщиком; разовая стоимость.
|
||||||
|
- [Рост хранилища на один объект 800px на фото] → WebP q82 скромного размера; приемлемо.
|
||||||
|
- [Неточный `sizes`] → запас в пользу резкости; при отсутствии `medium_url` вариант просто не
|
||||||
|
попадает в `srcset`, сетка падает на доступные (thumb/preview).
|
||||||
|
- [SQLite не умеет `ALTER CHECK`] → миграция пересоздаёт `photo_objects`; на больших таблицах
|
||||||
|
копирование строк. Приемлемо для текущего масштаба, выполняется в окне обслуживания.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Применить миграцию: расширить `CHECK` роли, налить `variant_jobs` для существующих фото.
|
||||||
|
2. Выкатить код: воркер генерирует `medium` и квадратный `thumb`; API отдаёт новые поля.
|
||||||
|
3. Воркер разбирает очередь; сетка показывает `srcset` с откатом на доступные варианты.
|
||||||
|
4. Откат: сначала код (API перестаёт отдавать medium, воркер не генерирует); затем down-миграция
|
||||||
|
восстанавливает `CHECK`, предварительно удалив привязки `medium`.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Сетка миниатюр часто отображает изображение крупнее, чем единственный вариант `thumb` (200px),
|
||||||
|
потому что между миниатюрой 200px и превью 1200px нет промежуточного размера. При увеличении
|
||||||
|
плиток (малое число колонок) и на экранах с высокой плотностью пикселей фото расплывается;
|
||||||
|
обложки альбомов в обзоре и вовсе отдают только 200px.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Добавить четвёртый вариант `medium` — квадратный кроп 800×800, WebP — и роль `medium` в
|
||||||
|
журнале объектов. `thumb` перевести на квадратный кроп 200×200; `preview` остаётся вписанным
|
||||||
|
1200×1200 для просмотрщика.
|
||||||
|
- API фото SHALL отдавать `medium_url` рядом с `thumb_url`/`preview_url`; данные обложки
|
||||||
|
альбома SHALL отдавать URL более крупного варианта обложки.
|
||||||
|
- Сетка фото и обзор альбомов SHALL выбирать вариант под фактический размер плитки через
|
||||||
|
`srcset` (200/800/1200) и `sizes`, а не всегда тянуть 200px.
|
||||||
|
- Миграция расширяет ограничение роли `photo_objects` значением `medium` (Postgres и SQLite) и
|
||||||
|
ставит в очередь перегенерацию вариантов для уже загруженных фото.
|
||||||
|
- Из-за смены кропа у `thumb` существующие миниатюры перегенерируются (квадратными) вместе с
|
||||||
|
добавлением `medium`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `photos`: требование «Варианты изображений» — четыре варианта, квадратный кроп grid-вариантов.
|
||||||
|
- `media-objects`: требование «Журнал объектов» — роль `medium` в наборе ролей.
|
||||||
|
- `albums`: требование «Отображение обложки альбома» — URL обложки крупнее миниатюры.
|
||||||
|
- `ui-workspace`: новое требование — сетка и обзор альбомов подбирают вариант изображения под
|
||||||
|
размер плитки.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: `photos/{pipeline,worker,objects,models,service,store,sqlite_store}.go`,
|
||||||
|
`albums/{models,store,sqlite_store,service}.go`, миграции Postgres и SQLite, бэкфилл
|
||||||
|
`variant_jobs`, контрактные тесты `repo/contract`.
|
||||||
|
- Frontend: `PhotoGrid.svelte` и обзор альбомов (`(app)/+page.svelte`).
|
||||||
|
- API: новые поля `medium_url` (фото) и URL medium-обложки (альбом); существующие клиенты
|
||||||
|
продолжают работать (аддитивно).
|
||||||
|
- Хранилище: +1 производный объект (800px WebP) на фото; preview у существующих фото
|
||||||
|
перегенерируется впустую и удаляется сборщиком мусора по ссылкам.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отображение обложки альбома
|
||||||
|
|
||||||
|
Данные альбома SHALL включать URL обложки, когда обложка задана, чтобы клиенты могли
|
||||||
|
показать её в списке, рейле и карточке; URL SHALL указывать на вариант, достаточный для
|
||||||
|
крупного отображения, с откатом на миниатюру, пока крупный вариант не готов.
|
||||||
|
|
||||||
|
#### Scenario: Обложка задана
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
|
||||||
|
- **THEN** ответ содержит URL обложки
|
||||||
|
|
||||||
|
#### Scenario: Обложка отсутствует
|
||||||
|
|
||||||
|
- **WHEN** у альбома нет обложки или фото обложки удалено
|
||||||
|
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
|
||||||
|
|
||||||
|
#### Scenario: Крупный вариант ещё не готов
|
||||||
|
|
||||||
|
- **WHEN** у обложки готов только вариант-миниатюра
|
||||||
|
- **THEN** ответ содержит URL миниатюры обложки
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Журнал объектов
|
||||||
|
|
||||||
|
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
||||||
|
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
||||||
|
ролям original, thumb, medium и preview.
|
||||||
|
|
||||||
|
#### Scenario: Привязка ролей
|
||||||
|
|
||||||
|
- **WHEN** фото загружено и обработано
|
||||||
|
- **THEN** фото ссылается на объект-оригинал и, когда они готовы, на объекты thumb, medium и preview
|
||||||
|
|
||||||
|
#### Scenario: Оригинал без вариантов
|
||||||
|
|
||||||
|
- **WHEN** варианты ещё не сгенерированы
|
||||||
|
- **THEN** фото доступно с оригиналом, а thumb, medium и preview отсутствуют
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Варианты изображений
|
||||||
|
|
||||||
|
Система SHALL генерировать четыре варианта: thumb (200×200, квадратный кроп), medium
|
||||||
|
(800×800, квадратный кроп), preview (1200×1200, вписанный) и оригинал, в WebP, и извлекать
|
||||||
|
EXIF (дата, координаты, размеры).
|
||||||
|
|
||||||
|
#### Scenario: Генерация вариантов
|
||||||
|
|
||||||
|
- **WHEN** фото загружено
|
||||||
|
- **THEN** система асинхронно генерирует thumb, medium и preview и заполняет EXIF-поля
|
||||||
|
|
||||||
|
#### Scenario: Форма вариантов сетки
|
||||||
|
|
||||||
|
- **WHEN** генерируются thumb и medium
|
||||||
|
- **THEN** они обрезаны до квадрата, а preview вписан с сохранением пропорций
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Резкость миниатюр
|
||||||
|
|
||||||
|
Сетка фото и обзор альбомов SHALL подбирать вариант изображения под фактический размер
|
||||||
|
плитки, чтобы миниатюры не отображались крупнее доступного разрешения.
|
||||||
|
|
||||||
|
#### Scenario: Крупные плитки
|
||||||
|
|
||||||
|
- **WHEN** пользователь увеличивает миниатюры до крупных плиток
|
||||||
|
- **THEN** показывается вариант изображения, достаточный для их размера, без расплывания
|
||||||
|
|
||||||
|
#### Scenario: Мелкие плитки
|
||||||
|
|
||||||
|
- **WHEN** миниатюры показаны мелкими плитками
|
||||||
|
- **THEN** показывается компактный вариант, чтобы не загружать крупное изображение
|
||||||
|
|
||||||
|
#### Scenario: Вариант ещё не готов
|
||||||
|
|
||||||
|
- **WHEN** для фото доступен только оригинал или только миниатюра
|
||||||
|
- **THEN** показывается доступный вариант, и сетка отображается корректно
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Варианты и роли
|
||||||
|
|
||||||
|
- [x] 1.1 `backend/internal/photos/pipeline.go`: `thumb` → `imaging.Fill(200,200)`, добавить `medium = imaging.Fill(800,800)`; `buildVariants` возвращает thumb, medium, preview — проверка: обновлённый тест генерации подтверждает квадратную форму thumb/medium и наличие preview.
|
||||||
|
- [x] 1.2 `backend/internal/photos/objects.go`: `RoleMedium = "medium"`; `backend/internal/models/models.go`: `MediumKey`, `MediumURL` — проверка: `cd backend && go build ./...`.
|
||||||
|
|
||||||
|
## 2. Миграции и бэкфилл
|
||||||
|
|
||||||
|
- [x] 2.1 Postgres `backend/migrations/000011_*`: расширить `CHECK` роли `photo_objects` значением `medium`; налить `variant_jobs` для всех фото — проверка: `make migrate` применяется без ошибок на dev-БД.
|
||||||
|
- [x] 2.2 SQLite `backend/migrations/sqlite/000003_*`: пересоздать `photo_objects` с новым `CHECK` (перенос строк) + бэкфилл `variant_jobs` — проверка: `cd backend && go test ./internal/db/...`.
|
||||||
|
|
||||||
|
## 3. Метаданные (Postgres и SQLite)
|
||||||
|
|
||||||
|
- [x] 3.1 Postgres `photos/store.go` + `photos/service.go`: JOIN/scan роли `medium`, `attachURLs` заполняет `MediumURL` — проверка: `make test` (с `TEST_DATABASE_URL`).
|
||||||
|
- [x] 3.2 SQLite `photos/sqlite_store.go`: JOIN/scan роли `medium` — проверка: `make test`.
|
||||||
|
- [x] 3.3 `albums` (models, `store.go`, `sqlite_store.go`, `service.go`): URL medium-обложки с откатом на `thumb` — проверка: `make test`.
|
||||||
|
- [x] 3.4 `backend/internal/repo/contract`: тесты привязки роли `medium` и URL обложки для обоих бэкендов — проверка: `make test` (и с `TEST_DATABASE_URL`).
|
||||||
|
|
||||||
|
## 4. Генерация вариантов
|
||||||
|
|
||||||
|
- [x] 4.1 `backend/internal/photos/worker.go`: создавать и привязывать `medium` (порядок thumb → medium → preview); обновить `worker_test.go` (4 привязанных объекта, `MediumURL`) — проверка: `make test`.
|
||||||
|
|
||||||
|
## 5. Фронтенд
|
||||||
|
|
||||||
|
- [x] 5.1 `frontend/src/lib/types.ts`: добавить `medium_url`, `cover_medium_url` — проверка: `cd frontend && npm run check`.
|
||||||
|
- [x] 5.2 `frontend/src/lib/components/PhotoGrid.svelte`: `srcset`/`sizes` по фактической ширине сетки (`bind:clientWidth`), откат на доступные варианты — проверка: `npm run dev` — крупные плитки резкие, мелкие тянут компактный вариант.
|
||||||
|
- [x] 5.3 Обзор альбомов `frontend/src/routes/(app)/+page.svelte`: `srcset` обложки с откатом на миниатюру — проверка: `npm run dev` — обложки не мылят при крупном размере.
|
||||||
|
|
||||||
|
## 6. Интеграционная проверка
|
||||||
|
|
||||||
|
- [x] 6.1 `make test` и `cd frontend && npm run check && npm run build` проходят; вручную: резкость на всех размерах, обложки в обзоре, бэкфилл существующих фото не ломает сетку.
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Заархивировать change после выполнения требований ревью проекта (`/opsx-archive`).
|
||||||
|
- Проверить результат после архивации.
|
||||||
Reference in New Issue
Block a user