feat(web): вкладка «Редактирование» с инструментом «Свет»

В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
This commit is contained in:
iu
2026-10-20 23:06:24 +03:00
parent afaaaab5f0
commit 0548b1ab5e
32 changed files with 1954 additions and 24 deletions
+6 -2
View File
@@ -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
+10 -2
View File
@@ -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 {
+2
View File
@@ -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)
+9
View File
@@ -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 {
+242
View File
@@ -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)
}
+89
View File
@@ -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
}
+111
View File
@@ -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
}
+136
View File
@@ -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
}
+159
View File
@@ -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()
}
+29
View File
@@ -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)
+17
View File
@@ -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)
}
+20
View File
@@ -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
}
@@ -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
}
+22 -5
View File
@@ -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)
}
+37 -7
View File
@@ -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)
}
+79
View File
@@ -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.