feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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,10 +215,47 @@
|
||||
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="p-4">
|
||||
<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}>
|
||||
<input
|
||||
@@ -443,6 +488,8 @@
|
||||
<PanelSection id="photo-comments" title="Комментарии">
|
||||
<Comments photoId={photo.id} />
|
||||
</PanelSection>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if settingsOpen}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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** система отклоняет запрос
|
||||
|
||||
@@ -775,3 +775,22 @@ SHALL очищаться. При отсутствии сети приложен
|
||||
#### Scenario: Индикатор офлайна
|
||||
- **WHEN** браузер сообщает об отсутствии сети
|
||||
- **THEN** верхняя панель показывает индикатор «Офлайн»
|
||||
|
||||
### Requirement: Вкладка «Редактирование»
|
||||
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
|
||||
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
|
||||
с выбором канала. Правки SHALL сохраняться без отдельной кнопки, а переключатель
|
||||
«Сброс» SHALL удалять параметры правок и отредактированную версию фото. Сетка и просмотр
|
||||
SHALL показывать отредактированную версию фото, когда она отрендерена.
|
||||
|
||||
#### Scenario: Вкладки панели
|
||||
- **WHEN** открыта правая панель фото
|
||||
- **THEN** видны вкладки «Сведения» и «Редактирование»
|
||||
|
||||
#### Scenario: Сохранение правок
|
||||
- **WHEN** пользователь отпускает ползунок
|
||||
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
||||
|
||||
#### Scenario: Сброс правок
|
||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
||||
|
||||
Reference in New Issue
Block a user