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.
@@ -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;
@@ -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);
@@ -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;
@@ -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);
+4
View File
@@ -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) =>
@@ -0,0 +1,345 @@
<script lang="ts">
import { photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import {
CHANNEL_COLORS,
CHANNEL_LABELS,
CURVE_CHANNELS,
LIGHT_SLIDERS,
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams,
type CurveChannel,
type CurvePoint,
type EditParams
} from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
photo: Photo;
canEdit: boolean;
onUpdated: (photo: Photo) => void;
}
let { photo, canEdit, onUpdated }: Props = $props();
let params = $state<EditParams>(emptyEditParams());
let busy = $state(false);
let error = $state('');
let collapsed = $state(false);
let channel = $state<CurveChannel>('rgb');
const saved = $derived(readEditParams(photo));
const hasEdits = $derived(!isEmptyEditParams(saved));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// При смене фото параметры берутся из метаданных.
let lastPhotoId = '';
$effect(() => {
const id = photo.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
params = readEditParams(photo);
error = '';
});
async function save(next: EditParams) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.edit(photo.id, next);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
params = { ...params, light: { ...params.light, [key]: value } };
}
function onSliderCommit() {
if (!canEdit) return;
const next = normalizeEditParams(params);
params = next;
if (isEmptyEditParams(next)) {
reset();
return;
}
void save(next);
}
function setChannel(next: CurveChannel) {
channel = next;
}
function channelPoints(ch: CurveChannel): CurvePoint[] {
return params.light.curve?.[ch] ?? [];
}
function curvePath(ch: CurveChannel): string {
const pts = channelPoints(ch);
if (pts.length < 2) return 'M0,100 L100,0';
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x * 100},${(1 - p.y) * 100}`).join(' ');
}
let dragging = -1;
function pointFromEvent(ev: PointerEvent): CurvePoint {
const svg = (ev.currentTarget as SVGElement).ownerSVGElement ?? (ev.currentTarget as SVGElement);
const rect = svg.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = 1 - (ev.clientY - rect.top) / rect.height;
return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
}
function onPointDown(ev: PointerEvent, index: number) {
if (!canEdit) return;
ev.preventDefault();
ev.stopPropagation();
dragging = index;
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
/** Клик по полю кривой добавляет точку. */
function onCurveDown(ev: PointerEvent) {
if (!canEdit) return;
const target = ev.target as Element | null;
if (target && target.tagName.toLowerCase() === 'circle') return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (pts.length >= 16) return;
const point = pointFromEvent(ev);
let index = pts.findIndex((p) => p.x > point.x);
if (index < 0) index = pts.length;
pts.splice(index, 0, point);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
dragging = index;
}
function onPointMove(ev: PointerEvent) {
if (dragging < 0 || !canEdit) return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (dragging >= pts.length) return;
pts[dragging] = pointFromEvent(ev);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
}
function onPointUp() {
if (dragging < 0) return;
dragging = -1;
onSliderCommit();
}
/** Точку кривой можно двигать и с клавиатуры. */
function onPointKey(ev: KeyboardEvent, index: number) {
if (!canEdit) return;
const step = ev.shiftKey ? 0.1 : 0.02;
let dx = 0;
let dy = 0;
if (ev.key === 'ArrowLeft') dx = -step;
else if (ev.key === 'ArrowRight') dx = step;
else if (ev.key === 'ArrowUp') dy = step;
else if (ev.key === 'ArrowDown') dy = -step;
else return;
ev.preventDefault();
const ch = channel;
const pts = [...channelPoints(ch)];
if (index >= pts.length) return;
pts[index] = {
x: Math.min(1, Math.max(0, pts[index].x + dx)),
y: Math.min(1, Math.max(0, pts[index].y + dy))
};
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
onSliderCommit();
}
function resetCurve() {
if (!canEdit) return;
const ch = channel;
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
};
onSliderCommit();
}
async function reset() {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.resetEdit(photo.id);
params = readEditParams(res.photo);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
// Сброс доступен, когда у фото есть правки.
async function toggleReset() {
if (!hasEdits) return;
await reset();
}
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
const valueClass = 'text-text-muted text-xs tabular-nums';
function formatValue(value: number): string {
const rounded = Math.round(value * 10) / 10;
if (rounded === 0) return '0';
return rounded > 0 ? `+${rounded}` : `${rounded}`;
}
</script>
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!collapsed}
onclick={() => (collapsed = !collapsed)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {collapsed ? '-rotate-90' : ''}"
/>
Свет
</button>
<button
type="button"
class="border-border text-text-muted hover:text-text inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs disabled:opacity-40"
aria-pressed={hasEdits}
disabled={!canEdit || !hasEdits || busy}
title="Сбросить правки: удалить параметры и отредактированную версию"
onclick={toggleReset}
>
Сброс
</button>
</div>
{#if !collapsed}
<div class="mt-3 space-y-4">
<div class="space-y-3">
{#each LIGHT_SLIDERS as slider (slider.key)}
<div>
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for="light-{slider.key}">{slider.label}</label>
<span class={valueClass}>{formatValue(params.light[slider.key])}</span>
</div>
<input
id="light-{slider.key}"
type="range"
class={sliderClass}
min={slider.min}
max={slider.max}
step={slider.step}
value={params.light[slider.key]}
disabled={!canEdit}
oninput={(e) => onSliderInput(slider.key, Number(e.currentTarget.value))}
onchange={onSliderCommit}
/>
</div>
{/each}
</div>
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<span class="text-text text-xs font-semibold">Кривая</span>
<div class="flex items-center gap-1.5">
{#each CURVE_CHANNELS as ch (ch)}
<button
type="button"
class="size-4 rounded-full border-2 disabled:opacity-40"
style="border-color: {CHANNEL_COLORS[ch]}; background: {ch === channel
? CHANNEL_COLORS[ch]
: 'transparent'}"
aria-label="Канал {CHANNEL_LABELS[ch]}"
title="Канал {CHANNEL_LABELS[ch]}"
aria-pressed={ch === channel}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{/each}
</div>
</div>
<svg
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
viewBox="0 0 100 100"
role="application"
aria-label="Тоновая кривая"
onpointerdown={onCurveDown}
onpointermove={onPointMove}
onpointerup={onPointUp}
onpointerleave={onPointUp}
>
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
{#each [25, 50, 75] as step (step)}
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
{/each}
<path d={curvePath(channel)} fill="none" stroke={CHANNEL_COLORS[channel]} stroke-width="1.2" />
{#each channelPoints(channel) as point, index (index)}
<circle
cx={point.x * 100}
cy={(1 - point.y) * 100}
r="2.4"
class="cursor-grab"
fill="var(--bg)"
stroke={CHANNEL_COLORS[channel]}
stroke-width="1"
role="slider"
tabindex="0"
aria-label="Точка кривой {index + 1}"
aria-valuemin="0"
aria-valuemax="1"
aria-valuenow={point.x}
onpointerdown={(e) => onPointDown(e, index)}
onkeydown={(e) => onPointKey(e, index)}
></circle>
{/each}
</svg>
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-text-muted text-xs">Точечная кривая · линейная</span>
<button
type="button"
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
disabled={!canEdit || !channelPoints(channel).length}
onclick={resetCurve}
>
Сбросить кривую
</button>
</div>
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем и рендерим…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
{#if dirty && !busy}
<p class="text-text-subtle text-xs">Есть несохранённые изменения</p>
{/if}
</div>
{/if}
</div>
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { grid } from '$lib/stores/grid.svelte';
import { photoMediumUrl, photoPreviewUrl, photoThumbUrl } from '$lib/edit';
import { isPanoTag } from '$lib/pano';
import type { Photo } from '$lib/types';
@@ -38,11 +39,15 @@
// Offer every variant the photo actually has; the backend generates the
// medium variant asynchronously, so an incomplete set must still render.
// When the photo is edited, the edited variants are shown.
function srcsetFor(photo: Photo): string {
const parts: string[] = [];
if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`);
if (photo.medium_url) parts.push(`${photo.medium_url} 800w`);
if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`);
const thumb = photoThumbUrl(photo);
const medium = photoMediumUrl(photo);
const preview = photoPreviewUrl(photo);
if (thumb) parts.push(`${thumb} 200w`);
if (medium) parts.push(`${medium} 800w`);
if (preview) parts.push(`${preview} 1200w`);
return parts.join(', ');
}
@@ -106,9 +111,9 @@
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
{#if photoThumbUrl(photo)}
<img
src={photo.thumb_url}
src={photoThumbUrl(photo)}
srcset={srcsetFor(photo)}
{sizes}
alt={photo.original_filename}
@@ -5,6 +5,7 @@
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoEditPanel from './PhotoEditPanel.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
@@ -28,12 +29,18 @@
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
// Правки может вносить и загрузивший фото, если у него есть право загрузки.
const canEditPhoto = $derived(
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer')
);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let tab = $state<'info' | 'edit'>('info');
let pendingDelete = $state(false);
let deleting = $state(false);
@@ -65,6 +72,7 @@
showAll = false;
tagQuery = '';
tagOpen = false;
tab = 'info';
});
let tagQuery = $state('');
@@ -207,9 +215,46 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
const tabClass =
'text-text-muted hover:text-text flex-1 border-b-2 border-transparent px-3 py-2 text-sm font-medium transition-colors aria-selected:border-primary aria-selected:text-text';
</script>
<div>
<div
class="border-border bg-chrome sticky top-0 z-10 flex border-b"
role="tablist"
aria-label="Разделы фото"
>
<button
type="button"
role="tab"
id="photo-tab-info"
aria-controls="photo-panel-info"
aria-selected={tab === 'info'}
class={tabClass}
onclick={() => (tab = 'info')}
>
Сведения
</button>
<button
type="button"
role="tab"
id="photo-tab-edit"
aria-controls="photo-panel-edit"
aria-selected={tab === 'edit'}
class={tabClass}
onclick={() => (tab = 'edit')}
>
Редактирование
</button>
</div>
{#if tab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
</div>
{:else}
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
@@ -444,6 +489,8 @@
<Comments photoId={photo.id} />
</PanelSection>
</div>
{/if}
</div>
{#if settingsOpen}
<PhotoInfoSettingsDialog
+15 -2
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
@@ -55,10 +56,19 @@
let startL = 0;
let startT = 0;
// Отредактированная версия фото показывается вместо исходной.
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
(fresh ? photoPreviewUrl(fresh) : undefined) ??
(current ? photoPreviewUrl(current) : undefined) ??
fresh?.original_url ??
current?.original_url ??
''
);
const fullSrc = $derived(
(fresh ? photoFullUrl(fresh) : undefined) ??
(current ? photoFullUrl(current) : undefined) ??
''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
@@ -79,6 +89,9 @@
$effect(() => {
const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null;
zoomed = false;
scale = 1;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams
} from './edit';
import type { Photo } from './types';
describe('параметры правок', () => {
it('зажимает значения в допустимые диапазоны', () => {
const params = normalizeEditParams({
light: { ...emptyEditParams().light, exposure: 99, contrast: -500 }
});
expect(params.light.exposure).toBe(5);
expect(params.light.contrast).toBe(-100);
});
it('считает пустыми нулевые параметры и линейную кривую', () => {
expect(isEmptyEditParams(emptyEditParams())).toBe(true);
const linear = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }
}
});
expect(isEmptyEditParams(linear)).toBe(true);
const bent = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] }
}
});
expect(isEmptyEditParams(bent)).toBe(false);
});
it('читает параметры из метаданных фото', () => {
const photo = {
edit_params: { light: { exposure: 0.5, curve: { rgb: [{ x: 0, y: 0 }] } } }
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.light.contrast).toBe(0);
});
it('сортирует точки кривой и убирает дубликаты', () => {
const params = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0.8, y: 0.9 }, { x: 0.2, y: 0.1 }, { x: 0.8, y: 0.7 }] }
}
});
const points = params.light.curve.rgb ?? [];
expect(points.length).toBe(2);
expect(points[0].x).toBeCloseTo(0.2);
expect(points[1].x).toBeCloseTo(0.8);
});
});
+185
View File
@@ -0,0 +1,185 @@
import type { Photo } from './types';
/**
* Неразрушающее редактирование фотографии.
*
* Параметры правок хранятся в метаданных фото, а отрендеренная версия
* приходит с сервера отдельным объектом. Расчёт кадров и рендер выполняет
* сервер (backend/internal/photos/edit.go).
*/
export interface CurvePoint {
x: number;
y: number;
}
export interface CurveParams {
rgb?: CurvePoint[];
red?: CurvePoint[];
green?: CurvePoint[];
blue?: CurvePoint[];
}
export interface LightParams {
exposure: number;
contrast: number;
highlights: number;
shadows: number;
whites: number;
blacks: number;
curve: CurveParams;
}
export interface EditParams {
light: LightParams;
}
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
export const CHANNEL_LABELS: Record<CurveChannel, string> = {
rgb: 'RGB',
red: 'R',
green: 'G',
blue: 'B'
};
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
rgb: 'var(--text)',
red: '#ef4444',
green: '#22c55e',
blue: '#3b82f6'
};
export interface LightSlider {
key: Exclude<keyof LightParams, 'curve'>;
label: string;
min: number;
max: number;
step: number;
}
export const LIGHT_SLIDERS: LightSlider[] = [
{ key: 'exposure', label: 'Экспозиция', min: -5, max: 5, step: 0.1 },
{ key: 'contrast', label: 'Контраст', min: -100, max: 100, step: 1 },
{ key: 'highlights', label: 'Света', min: -100, max: 100, step: 1 },
{ key: 'shadows', label: 'Тени', min: -100, max: 100, step: 1 },
{ key: 'whites', label: 'Белые', min: -100, max: 100, step: 1 },
{ key: 'blacks', label: 'Чёрные', min: -100, max: 100, step: 1 }
];
export const MAX_EXPOSURE_STOPS = 5;
export const MAX_PERCENT = 100;
export const MAX_CURVE_POINTS = 16;
export function emptyLight(): LightParams {
return {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
curve: {}
};
}
export function emptyEditParams(): EditParams {
return { light: emptyLight() };
}
function clamp(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return 0;
return Math.min(max, Math.max(min, v));
}
function clamp01(v: number): number {
return clamp(v, 0, 1);
}
/** Нормализует кривую: точки зажимаются в 0..1, сортируются по X, дубликаты убираются. */
export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined {
if (!points?.length) return undefined;
const sorted = points
.map((p) => ({ x: clamp(p.x, 0, 1), y: clamp(p.y, 0, 1) }))
.sort((a, b) => a.x - b.x);
const out: CurvePoint[] = [];
for (const p of sorted) {
if (out.length && Math.abs(p.x - out[out.length - 1].x) < 1e-6) continue;
out.push(p);
}
return out.length ? out.slice(0, MAX_CURVE_POINTS) : undefined;
}
/** Приводит параметры к допустимым диапазонам. */
export function normalizeEditParams(params: EditParams): EditParams {
const light = params?.light ?? emptyLight();
return {
light: {
exposure: clamp(light.exposure, -MAX_EXPOSURE_STOPS, MAX_EXPOSURE_STOPS),
contrast: clamp(light.contrast, -MAX_PERCENT, MAX_PERCENT),
highlights: clamp(light.highlights, -MAX_PERCENT, MAX_PERCENT),
shadows: clamp(light.shadows, -MAX_PERCENT, MAX_PERCENT),
whites: clamp(light.whites, -MAX_PERCENT, MAX_PERCENT),
blacks: clamp(light.blacks, -MAX_PERCENT, MAX_PERCENT),
curve: {
rgb: normalizeCurve(light.curve?.rgb),
red: normalizeCurve(light.curve?.red),
green: normalizeCurve(light.curve?.green),
blue: normalizeCurve(light.curve?.blue)
}
}
};
}
/** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): boolean {
const light = params.light;
if (
light.exposure !== 0 ||
light.contrast !== 0 ||
light.highlights !== 0 ||
light.shadows !== 0 ||
light.whites !== 0 ||
light.blacks !== 0
) {
return false;
}
for (const channel of CURVE_CHANNELS) {
for (const point of light.curve?.[channel] ?? []) {
if (Math.abs(point.x - point.y) > 1e-6) return false;
}
}
return true;
}
/** Читает параметры правок из метаданных фото. */
export function readEditParams(photo: Photo | null | undefined): EditParams {
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined;
if (!raw?.light) return emptyEditParams();
return normalizeEditParams({
light: {
...emptyLight(),
...raw.light,
curve: raw.light.curve ?? {}
}
});
}
/** URL отредактированной версии фото, если она есть. */
export function photoThumbUrl(photo: Photo): string | undefined {
return photo.edit_thumb_url ?? photo.thumb_url;
}
export function photoMediumUrl(photo: Photo): string | undefined {
return photo.edit_medium_url ?? photo.medium_url;
}
export function photoPreviewUrl(photo: Photo): string | undefined {
return photo.edit_preview_url ?? photo.preview_url;
}
/** Полноразмерная версия: отредактированная, если она отрендерена. */
export function photoFullUrl(photo: Photo): string | undefined {
return photo.edit_url ?? photo.original_url;
}
+5
View File
@@ -59,6 +59,7 @@ export interface Photo {
longitude?: number | null;
caption: string;
exif?: Record<string, unknown>;
edit_params?: Record<string, unknown>;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
@@ -73,6 +74,10 @@ export interface Photo {
playback_url?: string;
original_url?: string;
download_url?: string;
edit_url?: string;
edit_thumb_url?: string;
edit_medium_url?: string;
edit_preview_url?: string;
}
export interface Comment {
@@ -145,6 +145,11 @@
// insert an unseen photo here, or the grid would reflow.
const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p));
// Обновлённое фото (например, отрендеренные правки) должно
// попасть и в правую панель.
if (selection.albumId === albumId && selection.has(photo.id)) {
selection.replace(photo);
}
break;
}
case 'like.added': {
@@ -9,6 +9,8 @@
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
import { trash } from '$lib/stores/trash.svelte';
import { viewerReturn } from '$lib/stores/viewer-return';
import { auth } from '$lib/stores/auth.svelte';
import { SSEClient } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
@@ -55,6 +57,27 @@
}
});
// Обновления альбома: отрендеренные правки приходят отдельным событием.
let client: SSEClient | null = null;
$effect(() => {
const id = albumId;
if (!auth.access) return;
client = new SSEClient({
path: `/api/sse/albums/${id}`,
onEvent: (ev) => {
if (ev.type !== 'photo.updated') return;
const updated = ev.data as unknown as Photo;
photos = photos.map((p) => (p.id === updated.id ? updated : p));
}
});
client.connect(auth.access);
return () => {
client?.close();
client = null;
};
});
function close() {
goto(`/albums/${albumId}`);
}
+28
View File
@@ -281,3 +281,31 @@ SHALL быть доступно и для фото без альбома (нап
- **WHEN** клиент запрашивает список фото альбома
- **THEN** контрольные суммы входят в данные полученных фото
### Requirement: Неразрушающее редактирование фотографии
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок
SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как
отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с
исходными вариантами. Исходный объект SHALL оставаться неизменным, а скачивание SHALL
отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию.
Правки SHALL применять только к изображениям.
#### Scenario: Сохранение правок
- **WHEN** пользователь сохраняет правки фото
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
#### Scenario: Неразрушачность
- **WHEN** фото отредактировано
- **THEN** исходный объект и его варианты остаются неизменными
#### Scenario: Сброс правок
- **WHEN** пользователь сбрасывает правки
- **THEN** параметры и отредактированная версия удаляются, а фото снова показывается в исходном виде
#### Scenario: Права на правки
- **WHEN** правки сохраняет пользователь без прав на это фото
- **THEN** система отклоняет запрос
#### Scenario: Редактирование видео
- **WHEN** пользователь пытается отредактировать видео
- **THEN** система отклоняет запрос
+19
View File
@@ -775,3 +775,22 @@ SHALL очищаться. При отсутствии сети приложен
#### Scenario: Индикатор офлайна
- **WHEN** браузер сообщает об отсутствии сети
- **THEN** верхняя панель показывает индикатор «Офлайн»
### Requirement: Вкладка «Редактирование»
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
с выбором канала. Правки SHALL сохраняться без отдельной кнопки, а переключатель
«Сброс» SHALL удалять параметры правок и отредактированную версию фото. Сетка и просмотр
SHALL показывать отредактированную версию фото, когда она отрендерена.
#### Scenario: Вкладки панели
- **WHEN** открыта правая панель фото
- **THEN** видны вкладки «Сведения» и «Редактирование»
#### Scenario: Сохранение правок
- **WHEN** пользователь отпускает ползунок
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
#### Scenario: Сброс правок
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду