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, 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 photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id)` (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 photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id LEFT JOIN objects co ON co.id = cpo.object_id
LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium' LEFT JOIN 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) { func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
var a models.Album 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 var a models.Album
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''), COALESCE(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 photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id) (SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a FROM albums a
@@ -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 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 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
WHERE a.id = $1`, id). WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount) Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount)
if err != nil { if err != nil {
@@ -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) { func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.pool.Query(ctx, rows, err := s.pool.Query(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''), COALESCE(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 photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id) (SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a FROM albums a
@@ -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 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 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
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID) ORDER BY a.created_at DESC`, userID)
if err != nil { if err != nil {
+2
View File
@@ -177,6 +177,8 @@ func (s *Server) Router() *gin.Engine {
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete) authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photos/:id/tags", photosHandler.AddTag) authed.POST("/photos/:id/tags", photosHandler.AddTag)
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag) 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.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch) authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
+9
View File
@@ -90,6 +90,10 @@ type Photo struct {
MediumKey string `json:"-"` MediumKey string `json:"-"`
PreviewKey string `json:"-"` PreviewKey string `json:"-"`
PlaybackKey string `json:"-"` PlaybackKey string `json:"-"`
EditKey string `json:"-"`
EditThumbKey string `json:"-"`
EditMediumKey string `json:"-"`
EditPreviewKey string `json:"-"`
OriginalFilename string `json:"original_filename"` OriginalFilename string `json:"original_filename"`
Size int64 `json:"size"` Size int64 `json:"size"`
Width int `json:"width"` Width int `json:"width"`
@@ -103,6 +107,7 @@ type Photo struct {
Longitude *float64 `json:"longitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"` Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"` EXIF map[string]any `json:"exif,omitempty"`
EditParams map[string]any `json:"edit_params,omitempty"`
Tags []Tag `json:"tags,omitempty"` Tags []Tag `json:"tags,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"` AlbumTitle string `json:"album_title,omitempty"`
@@ -117,6 +122,10 @@ type Photo struct {
PlaybackURL string `json:"playback_url,omitempty"` PlaybackURL string `json:"playback_url,omitempty"`
OriginalURL string `json:"original_url,omitempty"` OriginalURL string `json:"original_url,omitempty"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
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 { 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}) 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) { func (h *Handler) Delete(c *gin.Context) {
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil { if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
+17
View File
@@ -68,6 +68,23 @@ type Repository interface {
// dimensions on the original object. // dimensions on the original object.
SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error 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 returns every key registered in the object journal.
AllStorageKeys(ctx context.Context) ([]string, error) 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 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 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_m.storage_key, ''),
COALESCE(o_p.storage_key, ''), COALESCE(o_p.storage_key, ''),
COALESCE(o_pb.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.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.kind, COALESCE(p.duration_ms, 0),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -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), (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), 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.exif, '{}'), p.deleted_at,
COALESCE(p.edit_params, '{}'),
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name)) 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 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 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 photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id LEFT JOIN objects o_p ON o_p.id = po_p.object_id
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback' 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) { func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
var p models.Photo var p models.Photo
var exifText, tagsText, createdAt string var exifText, tagsText, editText, createdAt string
var takenAt, deletedAt sql.NullString var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64 var lat, long sql.NullFloat64
var liked int var liked int
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, &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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt, &takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil { &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil {
return nil, err return nil, err
} }
p.TakenAt = sqliteutil.ParseNullTime(takenAt) p.TakenAt = sqliteutil.ParseNullTime(takenAt)
@@ -76,6 +90,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt) p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
p.LikedByMe = liked != 0 p.LikedByMe = liked != 0
setExif(&p, exifText) setExif(&p, exifText)
setEditParams(&p, editText)
setTags(&p, tagsText) setTags(&p, tagsText)
return &p, nil return &p, nil
} }
@@ -277,17 +292,18 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
out := []models.Photo{} out := []models.Photo{}
for rows.Next() { for rows.Next() {
var p models.Photo var p models.Photo
var exifText, tagsText, createdAt, albumTitle string var exifText, tagsText, editText, createdAt, albumTitle string
var takenAt, deletedAt sql.NullString var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64 var lat, long sql.NullFloat64
var liked int var liked int
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, &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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt, &takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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 return nil, err
} }
p.TakenAt = sqliteutil.ParseNullTime(takenAt) p.TakenAt = sqliteutil.ParseNullTime(takenAt)
@@ -302,6 +318,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
p.AlbumTitle = albumTitle p.AlbumTitle = albumTitle
p.LikedByMe = liked != 0 p.LikedByMe = liked != 0
setExif(&p, exifText) setExif(&p, exifText)
setEditParams(&p, editText)
setTags(&p, tagsText) setTags(&p, tagsText)
out = append(out, p) 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_m.storage_key, ''),
COALESCE(o_p.storage_key, ''), COALESCE(o_p.storage_key, ''),
COALESCE(o_pb.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.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.kind, COALESCE(p.duration_ms, 0),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -40,6 +44,7 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
(SELECT count(*) FROM comments c WHERE c.photo_id = p.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), 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.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)) 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 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` 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 photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id LEFT JOIN objects o_p ON o_p.id = po_p.object_id
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback' 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) { func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo var p models.Photo
var exifText, tagsText string var exifText, tagsText, editText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, &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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -73,6 +87,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
return nil, err return nil, err
} }
setExif(&p, exifText) setExif(&p, exifText)
setEditParams(&p, editText)
setTags(&p, tagsText) setTags(&p, tagsText)
return &p, nil 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 // CreateWithOriginal records a new photo together with its pending original
// object, the original binding and a variant job — all in one transaction. The // 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 // 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{} out := []models.Photo{}
for rows.Next() { for rows.Next() {
var p models.Photo 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, if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, &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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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 return nil, err
} }
setExif(&p, exifText) setExif(&p, exifText)
setEditParams(&p, editText)
setTags(&p, tagsText) setTags(&p, tagsText)
out = append(out, p) out = append(out, p)
} }
@@ -309,17 +337,19 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
out := []models.Photo{} out := []models.Photo{}
for rows.Next() { for rows.Next() {
var p models.Photo 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, if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey, &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.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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 return nil, err
} }
setExif(&p, exifText) setExif(&p, exifText)
setEditParams(&p, editText)
setTags(&p, tagsText) setTags(&p, tagsText)
out = append(out, p) 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) { func (w *Worker) tick(ctx context.Context) {
w.processVariantJobs(ctx) w.processVariantJobs(ctx)
w.processVideoJobs(ctx) w.processVideoJobs(ctx)
w.processEditJobs(ctx)
w.collectGarbage(ctx) w.collectGarbage(ctx)
w.reapStalePending(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 // forEachConcurrent runs fn over ids with at most w.concurrency functions in
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism // flight. Generation is CPU-bound (decode + resize + encode), so parallelism
// scales with cores; a concurrency of 1 stays strictly sequential. // 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 { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte'; import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { EditParams } from './edit';
import type { Photo, Preferences, Tag } from './types'; import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -142,6 +143,9 @@ export const photosApi = {
body: albumId ? { album_id: albumId } : {} body: albumId ? { album_id: albumId } : {}
}), }),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }), 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) => addTag: (id: string, name: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }), api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
removeTag: (id: string, tagId: string) => 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"> <script lang="ts">
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { grid } from '$lib/stores/grid.svelte'; import { grid } from '$lib/stores/grid.svelte';
import { photoMediumUrl, photoPreviewUrl, photoThumbUrl } from '$lib/edit';
import { isPanoTag } from '$lib/pano'; import { isPanoTag } from '$lib/pano';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
@@ -38,11 +39,15 @@
// Offer every variant the photo actually has; the backend generates the // Offer every variant the photo actually has; the backend generates the
// medium variant asynchronously, so an incomplete set must still render. // 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 { function srcsetFor(photo: Photo): string {
const parts: string[] = []; const parts: string[] = [];
if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`); const thumb = photoThumbUrl(photo);
if (photo.medium_url) parts.push(`${photo.medium_url} 800w`); const medium = photoMediumUrl(photo);
if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`); 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(', '); return parts.join(', ');
} }
@@ -106,9 +111,9 @@
ondragstart={(e) => onDragStart?.(i, e)} ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)} oncontextmenu={(e) => onContextMenu?.(i, e)}
> >
{#if photo.thumb_url} {#if photoThumbUrl(photo)}
<img <img
src={photo.thumb_url} src={photoThumbUrl(photo)}
srcset={srcsetFor(photo)} srcset={srcsetFor(photo)}
{sizes} {sizes}
alt={photo.original_filename} alt={photo.original_filename}
@@ -5,6 +5,7 @@
import Comments from './Comments.svelte'; import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte'; import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte'; import PanelSection from './PanelSection.svelte';
import PhotoEditPanel from './PhotoEditPanel.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte'; import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte'; import { albums } from '$lib/stores/albums.svelte';
@@ -28,12 +29,18 @@
const canEdit = $derived(role === 'owner' || role === 'admin'); const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id); const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $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 editingName = $state(false);
let nameDraft = $state(''); let nameDraft = $state('');
let savingName = $state(false); let savingName = $state(false);
let nameError = $state(''); let nameError = $state('');
let tab = $state<'info' | 'edit'>('info');
let pendingDelete = $state(false); let pendingDelete = $state(false);
let deleting = $state(false); let deleting = $state(false);
@@ -65,6 +72,7 @@
showAll = false; showAll = false;
tagQuery = ''; tagQuery = '';
tagOpen = false; tagOpen = false;
tab = 'info';
}); });
let tagQuery = $state(''); let tagQuery = $state('');
@@ -207,9 +215,46 @@
const navLink = 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'; '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 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> </script>
<div> <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"> <div class="p-4">
{#if editingName} {#if editingName}
<form class="relative" onsubmit={saveName}> <form class="relative" onsubmit={saveName}>
@@ -443,6 +488,8 @@
<PanelSection id="photo-comments" title="Комментарии"> <PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} /> <Comments photoId={photo.id} />
</PanelSection> </PanelSection>
</div>
{/if}
</div> </div>
{#if settingsOpen} {#if settingsOpen}
+15 -2
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte'; import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte'; import { viewer } from '$lib/stores/viewer.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
interface Props { interface Props {
@@ -55,10 +56,19 @@
let startL = 0; let startL = 0;
let startT = 0; let startT = 0;
// Отредактированная версия фото показывается вместо исходной.
const previewSrc = $derived( 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 displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh)); const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed); const pano = $derived(panoTag && !panoFailed);
@@ -79,6 +89,9 @@
$effect(() => { $effect(() => {
const id = current?.id; const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null; fresh = null;
zoomed = false; zoomed = false;
scale = 1; 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; longitude?: number | null;
caption: string; caption: string;
exif?: Record<string, unknown>; exif?: Record<string, unknown>;
edit_params?: Record<string, unknown>;
tags?: Tag[]; tags?: Tag[];
deleted_at?: string | null; deleted_at?: string | null;
album_title?: string; album_title?: string;
@@ -73,6 +74,10 @@ export interface Photo {
playback_url?: string; playback_url?: string;
original_url?: string; original_url?: string;
download_url?: string; download_url?: string;
edit_url?: string;
edit_thumb_url?: string;
edit_medium_url?: string;
edit_preview_url?: string;
} }
export interface Comment { export interface Comment {
@@ -145,6 +145,11 @@
// insert an unseen photo here, or the grid would reflow. // insert an unseen photo here, or the grid would reflow.
const photo = data as unknown as Photo; const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p)); photos = photos.map((p) => (p.id === photo.id ? photo : p));
// Обновлённое фото (например, отрендеренные правки) должно
// попасть и в правую панель.
if (selection.albumId === albumId && selection.has(photo.id)) {
selection.replace(photo);
}
break; break;
} }
case 'like.added': { case 'like.added': {
@@ -9,6 +9,8 @@
import { photoUpdates } from '$lib/stores/photo-updates.svelte'; import { photoUpdates } from '$lib/stores/photo-updates.svelte';
import { trash } from '$lib/stores/trash.svelte'; import { trash } from '$lib/stores/trash.svelte';
import { viewerReturn } from '$lib/stores/viewer-return'; 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'; import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? ''); 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() { function close() {
goto(`/albums/${albumId}`); goto(`/albums/${albumId}`);
} }
+28
View File
@@ -281,3 +281,31 @@ SHALL быть доступно и для фото без альбома (нап
- **WHEN** клиент запрашивает список фото альбома - **WHEN** клиент запрашивает список фото альбома
- **THEN** контрольные суммы входят в данные полученных фото - **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: Индикатор офлайна #### Scenario: Индикатор офлайна
- **WHEN** браузер сообщает об отсутствии сети - **WHEN** браузер сообщает об отсутствии сети
- **THEN** верхняя панель показывает индикатор «Офлайн» - **THEN** верхняя панель показывает индикатор «Офлайн»
### Requirement: Вкладка «Редактирование»
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
с выбором канала. Правки SHALL сохраняться без отдельной кнопки, а переключатель
«Сброс» SHALL удалять параметры правок и отредактированную версию фото. Сетка и просмотр
SHALL показывать отредактированную версию фото, когда она отрендерена.
#### Scenario: Вкладки панели
- **WHEN** открыта правая панель фото
- **THEN** видны вкладки «Сведения» и «Редактирование»
#### Scenario: Сохранение правок
- **WHEN** пользователь отпускает ползунок
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
#### Scenario: Сброс правок
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду