diff --git a/backend/internal/albums/sqlite_store.go b/backend/internal/albums/sqlite_store.go index 6229bf0..8e37e34 100644 --- a/backend/internal/albums/sqlite_store.go +++ b/backend/internal/albums/sqlite_store.go @@ -46,7 +46,7 @@ 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, ''), COALESCE(com.storage_key, ''), + a.created_at, a.updated_at, COALESCE(ce.storage_key, co.storage_key, ''), COALESCE(cem.storage_key, com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id)` @@ -54,7 +54,11 @@ 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 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` + LEFT JOIN objects com ON com.id = cpm.object_id + LEFT JOIN photo_objects cpe ON cpe.photo_id = a.cover_photo_id AND cpe.role = 'edit_thumb' + LEFT JOIN objects ce ON ce.id = cpe.object_id + LEFT JOIN photo_objects cpem ON cpem.photo_id = a.cover_photo_id AND cpem.role = 'edit_medium' + LEFT JOIN objects cem ON cem.id = cpem.object_id` func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) { var a models.Album diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 447f905..26963ea 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -44,7 +44,7 @@ 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(com.storage_key, ''), + COALESCE(ce.storage_key, co.storage_key, ''), COALESCE(cem.storage_key, com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a @@ -52,6 +52,10 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { 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 + LEFT JOIN photo_objects cpe ON cpe.photo_id = a.cover_photo_id AND cpe.role = 'edit_thumb' + LEFT JOIN objects ce ON ce.id = cpe.object_id + LEFT JOIN photo_objects cpem ON cpem.photo_id = a.cover_photo_id AND cpem.role = 'edit_medium' + LEFT JOIN objects cem ON cem.id = cpem.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.CoverMediumKey, &a.PhotosCount, &a.MembersCount) if err != nil { @@ -66,7 +70,7 @@ 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(com.storage_key, ''), + COALESCE(ce.storage_key, co.storage_key, ''), COALESCE(cem.storage_key, com.storage_key, ''), (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a @@ -75,6 +79,10 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, 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 + LEFT JOIN photo_objects cpe ON cpe.photo_id = a.cover_photo_id AND cpe.role = 'edit_thumb' + LEFT JOIN objects ce ON ce.id = cpe.object_id + LEFT JOIN photo_objects cpem ON cpem.photo_id = a.cover_photo_id AND cpem.role = 'edit_medium' + LEFT JOIN objects cem ON cem.id = cpem.object_id WHERE a.owner_id = $1 OR m.user_id IS NOT NULL ORDER BY a.created_at DESC`, userID) if err != nil { diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ec1ca8b..955f5e8 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -177,6 +177,8 @@ func (s *Server) Router() *gin.Engine { authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete) authed.POST("/photos/:id/tags", photosHandler.AddTag) authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag) + authed.PATCH("/photos/:id/edit", photosHandler.Edit) + authed.DELETE("/photos/:id/edit", photosHandler.ResetEdit) authed.GET("/tags", photosHandler.Tags) authed.POST("/photo-batch/delete", photosHandler.DeleteBatch) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index f2cc85d..7ddccc3 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -90,6 +90,10 @@ type Photo struct { MediumKey string `json:"-"` PreviewKey string `json:"-"` PlaybackKey string `json:"-"` + EditKey string `json:"-"` + EditThumbKey string `json:"-"` + EditMediumKey string `json:"-"` + EditPreviewKey string `json:"-"` OriginalFilename string `json:"original_filename"` Size int64 `json:"size"` Width int `json:"width"` @@ -103,6 +107,7 @@ type Photo struct { Longitude *float64 `json:"longitude,omitempty"` Caption string `json:"caption"` EXIF map[string]any `json:"exif,omitempty"` + EditParams map[string]any `json:"edit_params,omitempty"` Tags []Tag `json:"tags,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"` AlbumTitle string `json:"album_title,omitempty"` @@ -117,6 +122,10 @@ type Photo struct { PlaybackURL string `json:"playback_url,omitempty"` OriginalURL string `json:"original_url,omitempty"` DownloadURL string `json:"download_url,omitempty"` + EditURL string `json:"edit_url,omitempty"` + EditThumbURL string `json:"edit_thumb_url,omitempty"` + EditMediumURL string `json:"edit_medium_url,omitempty"` + EditPreviewURL string `json:"edit_preview_url,omitempty"` } type Tag struct { diff --git a/backend/internal/photos/edit.go b/backend/internal/photos/edit.go new file mode 100644 index 0000000..6ef5d48 --- /dev/null +++ b/backend/internal/photos/edit.go @@ -0,0 +1,242 @@ +package photos + +import ( + "encoding/json" + "errors" + "math" + "sort" +) + +// Edit parameters are stored in the photo metadata and describe a +// non-destructive render: the original object is never touched, the edited +// image is produced as a separate object. + +// LightParams holds the parameters of the "Свет" (light) tool. The slider +// ranges mirror the ones Lightroom exposes: exposure in stops, everything else +// in percent. +type LightParams struct { + Exposure float64 `json:"exposure"` + Contrast float64 `json:"contrast"` + Highlights float64 `json:"highlights"` + Shadows float64 `json:"shadows"` + Whites float64 `json:"whites"` + Blacks float64 `json:"blacks"` + Curve CurveParams `json:"curve"` +} + +// CurveParams holds one tone curve per channel. An empty channel means the +// identity (linear) curve. +type CurveParams struct { + RGB []CurvePoint `json:"rgb,omitempty"` + Red []CurvePoint `json:"red,omitempty"` + Green []CurvePoint `json:"green,omitempty"` + Blue []CurvePoint `json:"blue,omitempty"` +} + +// CurvePoint is a control point of a tone curve, in the 0..1 range. +type CurvePoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// EditParams is the full set of non-destructive editing parameters of a photo. +type EditParams struct { + Light LightParams `json:"light"` +} + +// Limits of the editing parameters. They are enforced on write so a hand-made +// request can never push the renderer into an extreme state. +const ( + MaxExposureStops = 5.0 + MaxPercent = 100.0 + MaxCurvePoints = 16 +) + +// ErrInvalidEditParams is returned when the client sends unusable parameters. +var ErrInvalidEditParams = errors.New("invalid edit params") + +// ParseEditParams decodes and normalizes the editing parameters sent by a +// client. Values outside the allowed range are clamped, and the curve points +// are sorted and de-duplicated. +func ParseEditParams(raw []byte) (EditParams, error) { + var p EditParams + if len(raw) == 0 { + return p, nil + } + if err := json.Unmarshal(raw, &p); err != nil { + return EditParams{}, ErrInvalidEditParams + } + p = p.Normalized() + return p, nil +} + +// Normalized returns a copy with every value clamped to the allowed range. +func (p EditParams) Normalized() EditParams { + p.Light.Exposure = clampFloat(p.Light.Exposure, -MaxExposureStops, MaxExposureStops) + p.Light.Contrast = clampFloat(p.Light.Contrast, -MaxPercent, MaxPercent) + p.Light.Highlights = clampFloat(p.Light.Highlights, -MaxPercent, MaxPercent) + p.Light.Shadows = clampFloat(p.Light.Shadows, -MaxPercent, MaxPercent) + p.Light.Whites = clampFloat(p.Light.Whites, -MaxPercent, MaxPercent) + p.Light.Blacks = clampFloat(p.Light.Blacks, -MaxPercent, MaxPercent) + p.Light.Curve.RGB = normalizeCurve(p.Light.Curve.RGB) + p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red) + p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green) + p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue) + return p +} + +// IsEmpty reports whether the parameters describe an untouched photo. +func (p EditParams) IsEmpty() bool { + l := p.Light + if l.Exposure != 0 || l.Contrast != 0 || l.Highlights != 0 || l.Shadows != 0 || + l.Whites != 0 || l.Blacks != 0 { + return false + } + for _, pts := range [][]CurvePoint{l.Curve.RGB, l.Curve.Red, l.Curve.Green, l.Curve.Blue} { + for _, pt := range pts { + if math.Abs(pt.X-pt.Y) > 1e-6 { + return false + } + } + } + return true +} + +// JSON encodes the parameters for storage in the photo metadata. +func (p EditParams) JSON() []byte { + if p.IsEmpty() { + return []byte("{}") + } + data, err := json.Marshal(p) + if err != nil { + return []byte("{}") + } + return data +} + +// normalizeCurve clamps the points to the unit square, keeps them sorted by X +// and drops duplicates. +func normalizeCurve(points []CurvePoint) []CurvePoint { + if len(points) == 0 { + return nil + } + out := make([]CurvePoint, 0, len(points)) + for _, pt := range points { + out = append(out, CurvePoint{ + X: clampFloat(pt.X, 0, 1), + Y: clampFloat(pt.Y, 0, 1), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].X < out[j].X }) + deduped := out[:0] + for i, pt := range out { + if i > 0 && math.Abs(pt.X-deduped[len(deduped)-1].X) < 1e-6 { + continue + } + deduped = append(deduped, pt) + } + if len(deduped) > MaxCurvePoints { + deduped = deduped[:MaxCurvePoints] + } + return deduped +} + +func clampFloat(v, min, max float64) float64 { + if math.IsNaN(v) { + return 0 + } + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func clamp01(v float64) float64 { + return clampFloat(v, 0, 1) +} + +func clampInt(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// curveLUT builds a 256-entry lookup table from the control points of a +// curve. The first and last points extend to the edges of the range, and the +// values in between are interpolated linearly. +func curveLUT(points []CurvePoint) [256]uint8 { + var lut [256]uint8 + pts := normalizeCurve(points) + if len(pts) < 2 { + for i := range lut { + lut[i] = uint8(i) + } + return lut + } + for i := 0; i < 256; i++ { + x := float64(i) / 255 + lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255)) + } + return lut +} + +// curveEval evaluates the curve at x using linear interpolation between the +// surrounding control points. +func curveEval(points []CurvePoint, x float64) float64 { + if x <= points[0].X { + return points[0].Y + } + last := points[len(points)-1] + if x >= last.X { + return last.Y + } + for i := 1; i < len(points); i++ { + if x > points[i].X { + continue + } + x0, y0 := points[i-1].X, points[i-1].Y + x1, y1 := points[i].X, points[i].Y + if x1-x0 < 1e-9 { + return y1 + } + t := (x - x0) / (x1 - x0) + return y0 + t*(y1-y0) + } + return last.Y +} + +// adjustChannel applies the light adjustments to a single channel value and +// then maps it through the tone curves: the master curve first, the channel +// curve second. The order of the operations is fixed and mirrored by the +// client-side preview, so the live preview and the rendered result match. +func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 { + // Экспозиция (стопы). + v *= math.Pow(2, p.Exposure) + // Контраст вокруг средней точки. + k := 1 + p.Contrast/100 + v = 128 + (v-128)*k + // Света: верхняя часть диапазона. + w := math.Pow(clamp01((v-128)/127), 2) + v += p.Highlights/100*80*w + // Тени: нижняя часть диапазона. + w = math.Pow(clamp01((128-v)/128), 2) + v += p.Shadows/100*80*w + // Белые: самый верх диапазона. + w = clamp01((v - 192) / 63) + v += p.Whites/100*80*w + // Чёрные: самый низ диапазона. + w = clamp01((64 - v) / 64) + v += p.Blacks/100*80*w + // Тоновая кривая: общая, затем канальная. + idx := clampInt(int(math.Round(v)), 0, 255) + v = float64(master[idx]) + idx = clampInt(int(math.Round(v)), 0, 255) + return clampFloat(float64(channel[idx]), 0, 255) +} diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go new file mode 100644 index 0000000..e7d8023 --- /dev/null +++ b/backend/internal/photos/edit_render.go @@ -0,0 +1,89 @@ +package photos + +import ( + "bytes" + "image" + + "github.com/disintegration/imaging" + "github.com/gen2brain/webp" +) + +// Object roles holding the non-destructive edited version of a photo. They +// mirror the base roles and are bound in addition to them. +const ( + RoleEdit = "edit" + RoleEditThumb = "edit_thumb" + RoleEditMedium = "edit_medium" + RoleEditPreview = "edit_preview" +) + +// applyLight renders the light adjustments into a copy of the image. The +// original pixels are never modified in place. +func applyLight(src image.Image, p LightParams) *image.NRGBA { + // Clone returns a fresh NRGBA copy, so the source stays untouched. + dst := imaging.Clone(src) + + master := curveLUT(p.Curve.RGB) + red := curveLUT(p.Curve.Red) + green := curveLUT(p.Curve.Green) + blue := curveLUT(p.Curve.Blue) + + width := dst.Rect.Dx() + for y := 0; y < dst.Rect.Dy(); y++ { + row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4] + for i := 0; i+3 < len(row); i += 4 { + if row[i+3] == 0 { + continue + } + row[i] = uint8(adjustChannel(float64(row[i]), p, &master, &red)) + row[i+1] = uint8(adjustChannel(float64(row[i+1]), p, &master, &green)) + row[i+2] = uint8(adjustChannel(float64(row[i+2]), p, &master, &blue)) + } + } + return dst +} + +// buildEditedVariants renders the edited version of a photo: the full-size +// image plus the thumb, medium and preview variants. The variants mirror the +// base ones, so the grid and the viewer can switch to them transparently. +func buildEditedVariants(data []byte, p EditParams) (thumb, medium, preview, full []byte, err error) { + img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) + if err != nil { + return nil, nil, nil, nil, err + } + edited := applyLight(img, p.Light) + + thumbImg := imaging.Fill(edited, 200, 200, imaging.Center, imaging.Lanczos) + mediumImg := imaging.Fill(edited, 800, 800, imaging.Center, imaging.Lanczos) + previewImg := imaging.Fit(edited, 1200, 1200, imaging.Lanczos) + + opts := webp.Options{Quality: 82} + var buf bytes.Buffer + if err := webp.Encode(&buf, thumbImg, opts); err != nil { + return nil, nil, nil, nil, err + } + thumb = buf.Bytes() + + buf.Reset() + if err := webp.Encode(&buf, mediumImg, opts); err != nil { + return nil, nil, nil, nil, err + } + medium = buf.Bytes() + + buf.Reset() + if err := webp.Encode(&buf, previewImg, opts); err != nil { + return nil, nil, nil, nil, err + } + preview = buf.Bytes() + + buf.Reset() + if err := webp.Encode(&buf, edited, opts); err != nil { + // Полноразмерная версия не обязательна: превью и миниатюры уже + // готовы, поэтому ошибку кодирования полной версии не считаем + // фатальной. + full = nil + } else { + full = buf.Bytes() + } + return thumb, medium, preview, full, nil +} diff --git a/backend/internal/photos/edit_service.go b/backend/internal/photos/edit_service.go new file mode 100644 index 0000000..22a85aa --- /dev/null +++ b/backend/internal/photos/edit_service.go @@ -0,0 +1,111 @@ +package photos + +import ( + "context" + "encoding/json" + "errors" + + "galo/internal/httpx" + "galo/internal/models" + "galo/internal/roles" +) + +// editablePhoto loads a photo and checks that the user may change its +// editing parameters: the uploader of the photo or an album owner/admin. +func (s *Service) editablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) { + p, err := s.store.GetByID(ctx, photoID, userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } + return nil, err + } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } + _, role, err := s.albums.Access(ctx, p.AlbumID, userID) + if err != nil { + return nil, err + } + own := p.UploaderID == userID + if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { + return nil, httpx.Forbidden("forbidden", "you cannot edit this photo") + } + return p, nil +} + +// EditPhoto stores new editing parameters for a photo and schedules the +// render of the edited version. The original object is never modified, so the +// edit can always be reset. +func (s *Service) EditPhoto(ctx context.Context, photoID, userID string, params EditParams) (*models.Photo, error) { + p, err := s.editablePhoto(ctx, photoID, userID) + if err != nil { + return nil, err + } + if p.Kind != KindImage { + return nil, httpx.BadRequest("invalid_request", "only images can be edited") + } + params = params.Normalized() + if params.IsEmpty() { + return s.ResetEdit(ctx, photoID, userID) + } + if err := s.store.SetEditParams(ctx, photoID, params.JSON()); err != nil { + return nil, err + } + if err := s.store.EnqueueEditJob(ctx, photoID); err != nil { + return nil, err + } + return s.publishEdit(ctx, photoID, userID) +} + +// ResetEdit removes the editing parameters of a photo together with the +// edited version, returning the photo to its original look. +func (s *Service) ResetEdit(ctx context.Context, photoID, userID string) (*models.Photo, error) { + if _, err := s.editablePhoto(ctx, photoID, userID); err != nil { + return nil, err + } + if err := s.store.ClearEditParams(ctx, photoID); err != nil { + return nil, err + } + if err := s.store.DeleteEditJob(ctx, photoID); err != nil { + return nil, err + } + if err := s.store.DeleteObjectBindings(ctx, photoID, + RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview); err != nil { + return nil, err + } + return s.publishEdit(ctx, photoID, userID) +} + +// publishEdit reloads a photo with its current editing state and notifies the +// album subscribers. +func (s *Service) publishEdit(ctx context.Context, photoID, userID string) (*models.Photo, error) { + p, err := s.store.GetByID(ctx, photoID, userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } + return nil, err + } + if err := s.attachURLs(ctx, p); err != nil { + return nil, err + } + s.hub.Publish(p.AlbumID, "photo.updated", p) + return p, nil +} + +// EditParamsOf returns the editing parameters stored on a photo. +func EditParamsOf(p *models.Photo) EditParams { + if p == nil || len(p.EditParams) == 0 { + return EditParams{} + } + data, err := json.Marshal(p.EditParams) + if err != nil { + return EditParams{} + } + params, err := ParseEditParams(data) + if err != nil { + return EditParams{} + } + return params +} diff --git a/backend/internal/photos/edit_store.go b/backend/internal/photos/edit_store.go new file mode 100644 index 0000000..37193aa --- /dev/null +++ b/backend/internal/photos/edit_store.go @@ -0,0 +1,136 @@ +package photos + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// SetEditParams stores the non-destructive editing parameters of a photo. +func (s *Store) SetEditParams(ctx context.Context, photoID string, params []byte) error { + _, err := s.pool.Exec(ctx, `UPDATE photos SET edit_params = $2::jsonb WHERE id = $1`, photoID, string(params)) + return err +} + +// ClearEditParams removes the editing parameters of a photo, returning it to +// its unedited state. +func (s *Store) ClearEditParams(ctx context.Context, photoID string) error { + _, err := s.pool.Exec(ctx, `UPDATE photos SET edit_params = '{}'::jsonb WHERE id = $1`, photoID) + return err +} + +// EnqueueEditJob schedules the render of the edited version of a photo. The +// job is keyed by the photo, so a new edit replaces the pending one. +func (s *Store) EnqueueEditJob(ctx context.Context, photoID string) error { + _, err := s.pool.Exec(ctx, + `INSERT INTO edit_jobs (photo_id) VALUES ($1) + ON CONFLICT (photo_id) DO UPDATE SET attempts = 0, next_attempt_at = now(), last_error = ''`, + photoID) + return err +} + +// DeleteEditJob drops the pending render of a photo. +func (s *Store) DeleteEditJob(ctx context.Context, photoID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID) + return err +} + +// ClaimEditJobs leases due edit renders and returns their photo ids. The +// lease pushes next_attempt_at forward so the same job is not picked up twice +// while it is being processed. +func (s *Store) ClaimEditJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) { + if limit <= 0 { + limit = 10 + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, + `SELECT ej.photo_id FROM edit_jobs ej + JOIN photos p ON p.id = ej.photo_id + WHERE ej.next_attempt_at <= now() + ORDER BY (ej.attempts > 0) ASC, ej.next_attempt_at ASC, ej.photo_id DESC + LIMIT $1 + FOR UPDATE OF ej SKIP LOCKED`, limit) + if err != nil { + return nil, err + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, tx.Commit(ctx) + } + if _, err := tx.Exec(ctx, + `UPDATE edit_jobs SET next_attempt_at = $2 WHERE photo_id = ANY($1)`, + ids, time.Now().Add(lease)); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return ids, nil +} + +// CompleteEditJob removes a finished edit render. +func (s *Store) CompleteEditJob(ctx context.Context, photoID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID) + return err +} + +// FailEditJob records a failure and schedules a retry with exponential +// backoff. After maxAttempts the job is abandoned. +func (s *Store) FailEditJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + var attempts int + err = tx.QueryRow(ctx, + `UPDATE edit_jobs + SET attempts = attempts + 1, + next_attempt_at = now() + make_interval(secs => least(300, 10 * power(2, attempts))), + last_error = $2 + WHERE photo_id = $1 + RETURNING attempts`, photoID, errMsg).Scan(&attempts) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return tx.Commit(ctx) + } + return err + } + if maxAttempts > 0 && attempts >= maxAttempts { + if _, err := tx.Exec(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// DeleteObjectBindings removes the bindings of a photo for the given roles. +// The objects themselves become unreferenced and are reclaimed by the garbage +// collector. +func (s *Store) DeleteObjectBindings(ctx context.Context, photoID string, roles ...string) error { + if len(roles) == 0 { + return nil + } + _, err := s.pool.Exec(ctx, `DELETE FROM photo_objects WHERE photo_id = $1 AND role = ANY($2)`, photoID, roles) + return err +} diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go new file mode 100644 index 0000000..085cb04 --- /dev/null +++ b/backend/internal/photos/edit_test.go @@ -0,0 +1,159 @@ +package photos + +import ( + "bytes" + "image" + "image/png" + "math" + "testing" +) + +func TestParseEditParamsClampsValues(t *testing.T) { + raw := []byte(`{"light":{"exposure":99,"contrast":-500,"highlights":10,"curve":{"rgb":[{"x":2,"y":-1},{"x":0.5,"y":0.5}]}}}`) + params, err := ParseEditParams(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if params.Light.Exposure != MaxExposureStops { + t.Fatalf("exposure = %v, want %v", params.Light.Exposure, MaxExposureStops) + } + if params.Light.Contrast != -MaxPercent { + t.Fatalf("contrast = %v, want %v", params.Light.Contrast, -MaxPercent) + } + if len(params.Light.Curve.RGB) != 2 { + t.Fatalf("curve points = %d, want 2", len(params.Light.Curve.RGB)) + } + // Точки сортируются по X, а значения зажимаются в диапазон 0..1. + first := params.Light.Curve.RGB[0] + if first.X != 0.5 || first.Y != 0.5 { + t.Fatalf("first curve point = %+v, want {0.5 0.5}", first) + } + second := params.Light.Curve.RGB[1] + if second.X != 1 || second.Y != 0 { + t.Fatalf("second curve point = %+v, want {1 0}", second) + } +} + +func TestEditParamsIsEmpty(t *testing.T) { + var zero EditParams + if !zero.IsEmpty() { + t.Fatal("zero params must be empty") + } + zero.Light.Exposure = 0.1 + if zero.IsEmpty() { + t.Fatal("params with exposure must not be empty") + } + curved := EditParams{} + curved.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 0.5}} + if curved.IsEmpty() { + t.Fatal("params with a bent curve must not be empty") + } + linear := EditParams{} + linear.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 1}} + if !linear.IsEmpty() { + t.Fatal("a linear curve must count as empty") + } +} + +func TestCurveLUTIdentity(t *testing.T) { + lut := curveLUT(nil) + for i := 0; i < 256; i++ { + if int(lut[i]) != i { + t.Fatalf("lut[%d] = %d, want %d", i, lut[i], i) + } + } +} + +func TestCurveLUTSteepens(t *testing.T) { + lut := curveLUT([]CurvePoint{{X: 0, Y: 0}, {X: 0.5, Y: 0.25}, {X: 1, Y: 1}}) + if lut[0] != 0 || lut[255] != 255 { + t.Fatalf("curve ends = %d..%d, want 0..255", lut[0], lut[255]) + } + if lut[128] >= 128 { + t.Fatalf("lut[128] = %d, want a darker midtone", lut[128]) + } +} + +func TestApplyLightBrightensAndKeepsSource(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 2, 2)) + for i := range src.Pix { + src.Pix[i] = 100 + } + src.Pix[3] = 255 + before := make([]byte, len(src.Pix)) + copy(before, src.Pix) + + edited := applyLight(src, LightParams{Exposure: 1}) + if edited.Pix[0] <= before[0] { + t.Fatalf("exposure did not brighten: %d <= %d", edited.Pix[0], before[0]) + } + for i := range src.Pix { + if src.Pix[i] != before[i] { + t.Fatalf("source pixel %d changed: %d != %d", i, src.Pix[i], before[i]) + } + } +} + +func TestApplyLightContrast(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + for i := range src.Pix { + src.Pix[i] = 200 + } + src.Pix[3] = 255 + edited := applyLight(src, LightParams{Contrast: 50}) + if edited.Pix[0] <= 200 { + t.Fatalf("contrast did not push the bright tone up: %d", edited.Pix[0]) + } + // Контраст работает вокруг средней точки: тёмные тона уходят вниз. + dark := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + for i := range dark.Pix { + dark.Pix[i] = 60 + } + dark.Pix[3] = 255 + editedDark := applyLight(dark, LightParams{Contrast: 50}) + if editedDark.Pix[0] >= 60 { + t.Fatalf("contrast did not push the dark tone down: %d", editedDark.Pix[0]) + } +} + +func TestBuildEditedVariants(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 400, 300)) + for y := 0; y < 300; y++ { + for x := 0; x < 400; x++ { + i := y*src.Stride + x*4 + src.Pix[i] = uint8(x % 256) + src.Pix[i+1] = uint8(y % 256) + src.Pix[i+2] = 128 + src.Pix[i+3] = 255 + } + } + thumb, medium, preview, full, err := buildEditedVariants(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}) + if err != nil { + t.Fatalf("build variants: %v", err) + } + if len(thumb) == 0 || len(medium) == 0 || len(preview) == 0 || len(full) == 0 { + t.Fatalf("empty variants: %d %d %d %d", len(thumb), len(medium), len(preview), len(full)) + } +} + +func TestAdjustChannelClamps(t *testing.T) { + lut := curveLUT(nil) + if v := adjustChannel(255, LightParams{Exposure: 5}, &lut, &lut); v != 255 { + t.Fatalf("value = %v, want 255", v) + } + if v := adjustChannel(0, LightParams{Exposure: -5}, &lut, &lut); v != 0 { + t.Fatalf("value = %v, want 0", v) + } + if v := adjustChannel(128, LightParams{Contrast: 0}, &lut, &lut); math.Abs(v-128) > 0.001 { + t.Fatalf("value = %v, want 128", v) + } +} + +func mustEncodePNG(t *testing.T, img image.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 500ac6c..c4f4dc6 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -119,6 +119,35 @@ func (h *Handler) Download(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": url}) } +// Edit stores new editing parameters for a photo. The parameters are kept in +// the photo metadata and the edited version is rendered in the background. +func (h *Handler) Edit(c *gin.Context) { + var req struct { + Light LightParams `json:"light"` + } + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "light params are required")) + return + } + p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light}) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photo": p}) +} + +// ResetEdit removes the editing parameters of a photo together with the +// edited version, returning it to its original look. +func (h *Handler) ResetEdit(c *gin.Context) { + p, err := h.svc.ResetEdit(c.Request.Context(), c.Param("id"), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photo": p}) +} + func (h *Handler) Delete(c *gin.Context) { if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil { httpx.RespondError(c, err) diff --git a/backend/internal/photos/repository.go b/backend/internal/photos/repository.go index 6c213e9..fbe844b 100644 --- a/backend/internal/photos/repository.go +++ b/backend/internal/photos/repository.go @@ -68,6 +68,23 @@ type Repository interface { // dimensions on the original object. SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error + // SetEditParams stores the non-destructive editing parameters of a photo. + SetEditParams(ctx context.Context, photoID string, params []byte) error + // ClearEditParams removes the editing parameters of a photo. + ClearEditParams(ctx context.Context, photoID string) error + // EnqueueEditJob schedules the render of the edited version of a photo. + EnqueueEditJob(ctx context.Context, photoID string) error + // DeleteEditJob drops the pending render of a photo. + DeleteEditJob(ctx context.Context, photoID string) error + // ClaimEditJobs leases due edit renders and returns their photo ids. + ClaimEditJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) + // CompleteEditJob removes a finished edit render. + CompleteEditJob(ctx context.Context, photoID string) error + // FailEditJob records a failure and schedules a retry. + FailEditJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error + // DeleteObjectBindings removes the bindings of a photo for the given roles. + DeleteObjectBindings(ctx context.Context, photoID string, roles ...string) error + // AllStorageKeys returns every key registered in the object journal. AllStorageKeys(ctx context.Context) ([]string, error) } diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 87986ed..409ac64 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -348,6 +348,26 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error { p.PlaybackURL = url } } + if p.EditKey != "" { + if url, err := s.storage.URL(ctx, p.EditKey); err == nil { + p.EditURL = url + } + } + if p.EditThumbKey != "" { + if url, err := s.storage.URL(ctx, p.EditThumbKey); err == nil { + p.EditThumbURL = url + } + } + if p.EditMediumKey != "" { + if url, err := s.storage.URL(ctx, p.EditMediumKey); err == nil { + p.EditMediumURL = url + } + } + if p.EditPreviewKey != "" { + if url, err := s.storage.URL(ctx, p.EditPreviewKey); err == nil { + p.EditPreviewURL = url + } + } return nil } diff --git a/backend/internal/photos/sqlite_edit_store.go b/backend/internal/photos/sqlite_edit_store.go new file mode 100644 index 0000000..db6911c --- /dev/null +++ b/backend/internal/photos/sqlite_edit_store.go @@ -0,0 +1,161 @@ +package photos + +import ( + "context" + "database/sql" + "errors" + "time" + + "galo/internal/sqliteutil" +) + +// SetEditParams stores the non-destructive editing parameters of a photo. +func (s *SQLiteStore) SetEditParams(ctx context.Context, photoID string, params []byte) error { + _, err := s.db.ExecContext(ctx, `UPDATE photos SET edit_params = $2 WHERE id = $1`, photoID, string(params)) + return err +} + +// ClearEditParams removes the editing parameters of a photo, returning it to +// its unedited state. +func (s *SQLiteStore) ClearEditParams(ctx context.Context, photoID string) error { + _, err := s.db.ExecContext(ctx, `UPDATE photos SET edit_params = '{}' WHERE id = $1`, photoID) + return err +} + +// EnqueueEditJob schedules the render of the edited version of a photo. The +// job is keyed by the photo, so a new edit replaces the pending one. +func (s *SQLiteStore) EnqueueEditJob(ctx context.Context, photoID string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO edit_jobs (photo_id) VALUES ($1) + ON CONFLICT (photo_id) DO UPDATE SET attempts = 0, next_attempt_at = $2, last_error = ''`, + photoID, sqliteutil.FormatTime(nowUTC())) + return err +} + +// DeleteEditJob drops the pending render of a photo. +func (s *SQLiteStore) DeleteEditJob(ctx context.Context, photoID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID) + return err +} + +// ClaimEditJobs leases due edit renders and returns their photo ids. +func (s *SQLiteStore) ClaimEditJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) { + if limit <= 0 { + limit = 10 + } + now := sqliteutil.FormatTime(nowUTC()) + next := sqliteutil.FormatTime(nowUTC().Add(lease)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + rows, err := tx.QueryContext(ctx, + `SELECT ej.photo_id FROM edit_jobs ej + JOIN photos p ON p.id = ej.photo_id + WHERE ej.next_attempt_at <= $1 + ORDER BY (ej.attempts > 0) ASC, ej.next_attempt_at ASC, ej.photo_id DESC + LIMIT $2`, now, limit) + if err != nil { + return nil, err + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, tx.Commit() + } + args := make([]any, 0, len(ids)+1) + args = append(args, next) + for _, id := range ids { + args = append(args, id) + } + if _, err := tx.ExecContext(ctx, + `UPDATE edit_jobs SET next_attempt_at = $1 WHERE photo_id IN (`+sqliteutil.Placeholders(2, len(ids))+`)`, + args...); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return ids, nil +} + +// CompleteEditJob removes a finished edit render. +func (s *SQLiteStore) CompleteEditJob(ctx context.Context, photoID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID) + return err +} + +// FailEditJob records a failure and schedules a retry with exponential +// backoff. After maxAttempts the job is abandoned. +func (s *SQLiteStore) FailEditJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var attempts int + err = tx.QueryRowContext(ctx, + `UPDATE edit_jobs + SET attempts = attempts + 1, + next_attempt_at = $2, + last_error = $3 + WHERE photo_id = $1 + RETURNING attempts`, photoID, + sqliteutil.FormatTime(nowUTC().Add(backoff(attempts))), errMsg).Scan(&attempts) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return tx.Commit() + } + return err + } + if maxAttempts > 0 && attempts >= maxAttempts { + if _, err := tx.ExecContext(ctx, `DELETE FROM edit_jobs WHERE photo_id = $1`, photoID); err != nil { + return err + } + } + return tx.Commit() +} + +// backoff returns the delay before the next attempt of a failed job. +func backoff(attempts int) time.Duration { + d := 10 * time.Second + for i := 0; i < attempts && d < 5*time.Minute; i++ { + d *= 2 + } + if d > 5*time.Minute { + d = 5 * time.Minute + } + return d +} + +// DeleteObjectBindings removes the bindings of a photo for the given roles. +// The objects themselves become unreferenced and are reclaimed by the garbage +// collector. +func (s *SQLiteStore) DeleteObjectBindings(ctx context.Context, photoID string, roles ...string) error { + if len(roles) == 0 { + return nil + } + args := make([]any, 0, len(roles)+1) + args = append(args, photoID) + for _, role := range roles { + args = append(args, role) + } + _, err := s.db.ExecContext(ctx, + `DELETE FROM photo_objects WHERE photo_id = $1 AND role IN (`+sqliteutil.Placeholders(2, len(roles))+`)`, + args...) + return err +} diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go index 58bbbdf..62e1f79 100644 --- a/backend/internal/photos/sqlite_store.go +++ b/backend/internal/photos/sqlite_store.go @@ -25,6 +25,10 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_m.storage_key, ''), COALESCE(o_p.storage_key, ''), COALESCE(o_pb.storage_key, ''), + COALESCE(o_e.storage_key, ''), + COALESCE(o_et.storage_key, ''), + COALESCE(o_em.storage_key, ''), + COALESCE(o_ep.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, ''), COALESCE(o_o.checksum, ''), p.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, @@ -33,6 +37,7 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, (SELECT count(*) FROM comments c WHERE c.photo_id = p.id), EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2), COALESCE(p.exif, '{}'), p.deleted_at, + COALESCE(p.edit_params, '{}'), COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name)) FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.photo_id = p.id AND t.user_id = $2 @@ -48,21 +53,30 @@ const sqlitePhotoJoins = ` 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 photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback' - LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id` + LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id + LEFT JOIN photo_objects po_e ON po_e.photo_id = p.id AND po_e.role = 'edit' + LEFT JOIN objects o_e ON o_e.id = po_e.object_id + LEFT JOIN photo_objects po_et ON po_et.photo_id = p.id AND po_et.role = 'edit_thumb' + LEFT JOIN objects o_et ON o_et.id = po_et.object_id + LEFT JOIN photo_objects po_em ON po_em.photo_id = p.id AND po_em.role = 'edit_medium' + LEFT JOIN objects o_em ON o_em.id = po_em.object_id + LEFT JOIN photo_objects po_ep ON po_ep.photo_id = p.id AND po_ep.role = 'edit_preview' + LEFT JOIN objects o_ep ON o_ep.id = po_ep.object_id` func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) { var p models.Photo - var exifText, tagsText, createdAt string + var exifText, tagsText, editText, createdAt string var takenAt, deletedAt sql.NullString var lat, long sql.NullFloat64 var liked int if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, + &p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil { + &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil { return nil, err } p.TakenAt = sqliteutil.ParseNullTime(takenAt) @@ -76,6 +90,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) { p.DeletedAt = sqliteutil.ParseNullTime(deletedAt) p.LikedByMe = liked != 0 setExif(&p, exifText) + setEditParams(&p, editText) setTags(&p, tagsText) return &p, nil } @@ -277,17 +292,18 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph out := []models.Photo{} for rows.Next() { var p models.Photo - var exifText, tagsText, createdAt, albumTitle string + var exifText, tagsText, editText, createdAt, albumTitle string var takenAt, deletedAt sql.NullString var lat, long sql.NullFloat64 var liked int if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, + &p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil { + &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &albumTitle, &tagsText); err != nil { return nil, err } p.TakenAt = sqliteutil.ParseNullTime(takenAt) @@ -302,6 +318,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph p.AlbumTitle = albumTitle p.LikedByMe = liked != 0 setExif(&p, exifText) + setEditParams(&p, editText) setTags(&p, tagsText) out = append(out, p) } diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 7cde847..df3e91d 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -32,6 +32,10 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_m.storage_key, ''), COALESCE(o_p.storage_key, ''), COALESCE(o_pb.storage_key, ''), + COALESCE(o_e.storage_key, ''), + COALESCE(o_et.storage_key, ''), + COALESCE(o_em.storage_key, ''), + COALESCE(o_ep.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, ''), COALESCE(o_o.checksum, ''), p.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, @@ -40,6 +44,7 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, (SELECT count(*) FROM comments c WHERE c.photo_id = p.id), EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2), COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at, + COALESCE(p.edit_params, '{}'::jsonb)::text, COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name)) FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text` @@ -54,18 +59,27 @@ const photoJoins = ` 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 photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback' - LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id` + LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id + LEFT JOIN photo_objects po_e ON po_e.photo_id = p.id AND po_e.role = 'edit' + LEFT JOIN objects o_e ON o_e.id = po_e.object_id + LEFT JOIN photo_objects po_et ON po_et.photo_id = p.id AND po_et.role = 'edit_thumb' + LEFT JOIN objects o_et ON o_et.id = po_et.object_id + LEFT JOIN photo_objects po_em ON po_em.photo_id = p.id AND po_em.role = 'edit_medium' + LEFT JOIN objects o_em ON o_em.id = po_em.object_id + LEFT JOIN photo_objects po_ep ON po_ep.photo_id = p.id AND po_ep.role = 'edit_preview' + LEFT JOIN objects o_ep ON o_ep.id = po_ep.object_id` func scanPhoto(row pgx.Row) (*models.Photo, error) { var p models.Photo - var exifText, tagsText string + var exifText, tagsText, editText string err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, + &p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText) + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -73,6 +87,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) { return nil, err } setExif(&p, exifText) + setEditParams(&p, editText) setTags(&p, tagsText) return &p, nil } @@ -97,6 +112,17 @@ func setExif(p *models.Photo, raw string) { } } +// setEditParams stores the non-destructive editing parameters of the photo. +func setEditParams(p *models.Photo, raw string) { + if raw == "" || raw == "null" || raw == "{}" { + return + } + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 { + p.EditParams = m + } +} + // CreateWithOriginal records a new photo together with its pending original // object, the original binding and a variant job — all in one transaction. The // caller stores the bytes and then activates the object. The metadata layer is @@ -194,17 +220,19 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) out := []models.Photo{} for rows.Next() { var p models.Photo - var exifText, tagsText string + var exifText, tagsText, editText string if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, + &p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil { + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText); err != nil { return nil, err } setExif(&p, exifText) + setEditParams(&p, editText) setTags(&p, tagsText) out = append(out, p) } @@ -309,17 +337,19 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e out := []models.Photo{} for rows.Next() { var p models.Photo - var exifText, tagsText string + var exifText, tagsText, editText string if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, + &p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum, &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil { + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &p.AlbumTitle, &tagsText); err != nil { return nil, err } setExif(&p, exifText) + setEditParams(&p, editText) setTags(&p, tagsText) out = append(out, p) } diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index 22c0993..54441cd 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -106,6 +106,7 @@ func (w *Worker) ProcessOnce(ctx context.Context) { func (w *Worker) tick(ctx context.Context) { w.processVariantJobs(ctx) w.processVideoJobs(ctx) + w.processEditJobs(ctx) w.collectGarbage(ctx) w.reapStalePending(ctx) } @@ -141,6 +142,84 @@ func (w *Worker) processVideoJobs(ctx context.Context) { }) } +// processEditJobs claims and processes the pending renders of the edited +// versions of photos. +func (w *Worker) processEditJobs(ctx context.Context) { + ids, err := w.svc.store.ClaimEditJobs(ctx, w.batch, w.lease) + if err != nil { + slog.Error("claim edit jobs", "error", err) + return + } + w.forEachConcurrent(ids, func(id string) { + w.generateEdit(ctx, id) + }) +} + +// generateEdit renders the edited version of a photo from its original and +// the parameters stored in the metadata. The variants are bound next to the +// base ones, so the client can show the edited photo without touching the +// original object. +func (w *Worker) generateEdit(ctx context.Context, photoID string) { + p, err := w.svc.store.GetByID(ctx, photoID, "") + if err != nil { + _ = w.svc.store.CompleteEditJob(ctx, photoID) + return + } + params := EditParamsOf(p) + if params.IsEmpty() { + // The photo was reset while the job was queued. + _ = w.svc.store.CompleteEditJob(ctx, photoID) + return + } + orig, err := w.svc.store.OriginalObject(ctx, photoID) + if err != nil { + _ = w.svc.store.CompleteEditJob(ctx, photoID) + return + } + if orig.State != ObjectReady { + w.failEdit(ctx, photoID, "original object is not ready") + return + } + data, err := w.svc.storage.Get(ctx, orig.StorageKey) + if err != nil { + w.failEdit(ctx, photoID, "fetch original: "+err.Error()) + return + } + thumb, medium, preview, full, err := buildEditedVariants(data, params) + if err != nil { + w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) + return + } + // The edit belongs to this photo only, so the variants are not shared + // with the copies of the same original. + owner := []string{photoID} + if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil { + w.failEdit(ctx, photoID, "store edit thumb: "+err.Error()) + return + } + if err := w.createVariant(ctx, owner, RoleEditMedium, medium); err != nil { + w.failEdit(ctx, photoID, "store edit medium: "+err.Error()) + return + } + if err := w.createVariant(ctx, owner, RoleEditPreview, preview); err != nil { + w.failEdit(ctx, photoID, "store edit preview: "+err.Error()) + return + } + if len(full) > 0 { + if err := w.createVariant(ctx, owner, RoleEdit, full); err != nil { + w.failEdit(ctx, photoID, "store edit: "+err.Error()) + return + } + } + _ = w.svc.store.CompleteEditJob(ctx, photoID) + w.notify(ctx, photoID) +} + +func (w *Worker) failEdit(ctx context.Context, photoID, msg string) { + slog.Warn("edit job failed", "photo", photoID, "error", msg) + _ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts) +} + // forEachConcurrent runs fn over ids with at most w.concurrency functions in // flight. Generation is CPU-bound (decode + resize + encode), so parallelism // scales with cores; a concurrency of 1 stays strictly sequential. diff --git a/backend/migrations/000015_photo_edit.down.sql b/backend/migrations/000015_photo_edit.down.sql new file mode 100644 index 0000000..7ff1b99 --- /dev/null +++ b/backend/migrations/000015_photo_edit.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS edit_jobs; + +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', 'playback')); + +ALTER TABLE photos DROP COLUMN IF EXISTS edit_params; diff --git a/backend/migrations/000015_photo_edit.up.sql b/backend/migrations/000015_photo_edit.up.sql new file mode 100644 index 0000000..0bb116e --- /dev/null +++ b/backend/migrations/000015_photo_edit.up.sql @@ -0,0 +1,20 @@ +-- Non-destructive photo editing: the parameters live in the photo metadata and +-- the edited image is stored as separate objects next to the base variants. + +ALTER TABLE photos ADD COLUMN IF NOT EXISTS edit_params jsonb NOT NULL DEFAULT '{}'::jsonb; + +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', 'playback', + 'edit', 'edit_thumb', 'edit_medium', 'edit_preview')); + +-- Durable queue of pending edit renders, one job per photo. +CREATE TABLE IF NOT EXISTS edit_jobs ( + photo_id text PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE, + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS edit_jobs_due_idx ON edit_jobs (next_attempt_at); diff --git a/backend/migrations/sqlite/000007_photo_edit.down.sql b/backend/migrations/sqlite/000007_photo_edit.down.sql new file mode 100644 index 0000000..7de82c6 --- /dev/null +++ b/backend/migrations/sqlite/000007_photo_edit.down.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS edit_jobs; + +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', 'playback')), + 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); + +ALTER TABLE photos DROP COLUMN edit_params; diff --git a/backend/migrations/sqlite/000007_photo_edit.up.sql b/backend/migrations/sqlite/000007_photo_edit.up.sql new file mode 100644 index 0000000..c867476 --- /dev/null +++ b/backend/migrations/sqlite/000007_photo_edit.up.sql @@ -0,0 +1,34 @@ +-- Non-destructive photo editing: the parameters live in the photo metadata and +-- the edited image is stored as separate objects next to the base variants. +-- SQLite cannot alter a CHECK constraint, so photo_objects is rebuilt with the +-- extended role set. + +ALTER TABLE photos ADD COLUMN edit_params TEXT NOT NULL DEFAULT '{}'; + +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', 'playback', + 'edit', 'edit_thumb', 'edit_medium', 'edit_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); + +-- Durable queue of pending edit renders, one job per photo. +CREATE TABLE edit_jobs ( + photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX edit_jobs_due_idx ON edit_jobs (next_attempt_at); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a6e0470..1c2a436 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,6 @@ import { goto } from '$app/navigation'; import { auth, type Tokens, type User } from './stores/auth.svelte'; +import type { EditParams } from './edit'; import type { Photo, Preferences, Tag } from './types'; export class ApiError extends Error { @@ -142,6 +143,9 @@ export const photosApi = { body: albumId ? { album_id: albumId } : {} }), permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }), + edit: (id: string, params: EditParams) => + api<{ photo: Photo }>(`/photos/${id}/edit`, { method: 'PATCH', body: params }), + resetEdit: (id: string) => api<{ photo: Photo }>(`/photos/${id}/edit`, { method: 'DELETE' }), addTag: (id: string, name: string) => api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }), removeTag: (id: string, tagId: string) => diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte new file mode 100644 index 0000000..3e2e739 --- /dev/null +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -0,0 +1,345 @@ + + +
Сохраняем и рендерим…
+ {/if} + {#if error} +{error}
+ {/if} + {#if dirty && !busy} +Есть несохранённые изменения
+ {/if} +