Compare commits
13
Commits
f8ee93777d
...
723b19bbe5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
723b19bbe5 | ||
|
|
afe85f7be0 | ||
|
|
70ac12db10 | ||
|
|
8dd8cb6f0c | ||
|
|
759e78ff79 | ||
|
|
b075f4c3a4 | ||
|
|
748143c9d7 | ||
|
|
eb2d478275 | ||
|
|
332f35b553 | ||
|
|
48ed0bb775 | ||
|
|
b64574b921 | ||
|
|
d904b2fe31 | ||
|
|
2668d87347 |
@@ -0,0 +1,164 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
assets "galo"
|
||||||
|
"galo/internal/albums"
|
||||||
|
"galo/internal/photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A copy of an edited photo must not inherit the edited version: the edit
|
||||||
|
// belongs to the photo whose edits produced it.
|
||||||
|
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "copy@test.local", "", "password123", "C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
source, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get source: %v", err)
|
||||||
|
}
|
||||||
|
if source.EditThumbURL == "" || source.EditPreviewURL == "" {
|
||||||
|
t.Fatalf("source has no edited version: %+v", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
|
||||||
|
// The copy shares the base variants with the source.
|
||||||
|
if clone.ThumbURL != source.ThumbURL || clone.PreviewURL != source.PreviewURL {
|
||||||
|
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", source, clone)
|
||||||
|
}
|
||||||
|
// But it shows the original picture and carries no edit parameters.
|
||||||
|
if clone.EditURL != "" || clone.EditThumbURL != "" || clone.EditMediumURL != "" || clone.EditPreviewURL != "" {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", clone)
|
||||||
|
}
|
||||||
|
if len(clone.EditParams) != 0 {
|
||||||
|
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migration drops the edit bindings that copies carried before the fix,
|
||||||
|
// while the edited version of the photo that owns them stays untouched.
|
||||||
|
func TestMigrationDropsEditBindingsOfCopies(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "mig@test.local", "", "password123", "M")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
var editObjectID string
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&editObjectID); err != nil {
|
||||||
|
t.Fatalf("edited thumb object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
|
||||||
|
// Reproduce the state left by the old code: the copy carries the edited
|
||||||
|
// version of the source photo.
|
||||||
|
if _, err := st.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'edit_thumb')`,
|
||||||
|
clone.ID, editObjectID); err != nil {
|
||||||
|
t.Fatalf("bind edited version to the copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrations fs: %v", err)
|
||||||
|
}
|
||||||
|
up, err := fs.ReadFile(migFS, "000008_photo_edit_copy_bindings.up.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.ExecContext(ctx, string(up)); err != nil {
|
||||||
|
t.Fatalf("apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role LIKE 'edit%'`, clone.ID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count copy edit bindings: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("copy still carries %d edit bindings", n)
|
||||||
|
}
|
||||||
|
// The photo that owns the edited version keeps it.
|
||||||
|
if err := st.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count source edit bindings: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("source edit bindings = %d, want 1", n)
|
||||||
|
}
|
||||||
|
// A bound object is never garbage, so the edited version stays alive.
|
||||||
|
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim garbage: %v", err)
|
||||||
|
}
|
||||||
|
for _, o := range claimed {
|
||||||
|
if o.ID == editObjectID {
|
||||||
|
t.Fatalf("edited version of the owner was claimed as garbage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,60 @@ func TestCopySharesStorageObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, cl, _ := newRecordingServiceFS(t, pool)
|
||||||
|
user := createUser(t, ctx, pool)
|
||||||
|
albumA := createAlbum(t, ctx, pool, user)
|
||||||
|
albumB := createAlbum(t, ctx, pool, user)
|
||||||
|
|
||||||
|
p, err := svc.Upload(ctx, UploadInput{
|
||||||
|
AlbumID: albumA, UserID: user, Filename: "p.png", MimeType: "image/png", Data: makePNG(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
|
||||||
|
|
||||||
|
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
|
||||||
|
w.processVariantJobs(ctx)
|
||||||
|
|
||||||
|
// The edit belongs to the photo: the parameters live in its metadata and
|
||||||
|
// the background render binds the edited version to it.
|
||||||
|
if _, err := svc.EditPhoto(ctx, p.ID, user, EditParams{Light: LightParams{Exposure: 0.5}}); err != nil {
|
||||||
|
t.Fatalf("edit photo: %v", err)
|
||||||
|
}
|
||||||
|
w.processEditJobs(ctx)
|
||||||
|
|
||||||
|
src, err := svc.store.GetByID(ctx, p.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get source: %v", err)
|
||||||
|
}
|
||||||
|
if src.EditThumbKey == "" || src.EditPreviewKey == "" {
|
||||||
|
t.Fatalf("source has no edited version: %+v", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
|
||||||
|
if err != nil || len(clones) != 1 {
|
||||||
|
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||||
|
}
|
||||||
|
clone := clones[0]
|
||||||
|
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
|
||||||
|
|
||||||
|
// The copy shares the base variants with the source.
|
||||||
|
if clone.ThumbKey != src.ThumbKey || clone.MediumKey != src.MediumKey || clone.PreviewKey != src.PreviewKey {
|
||||||
|
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", src, clone)
|
||||||
|
}
|
||||||
|
// But it never inherits the edited version or its parameters.
|
||||||
|
if clone.EditKey != "" || clone.EditThumbKey != "" || clone.EditMediumKey != "" || clone.EditPreviewKey != "" {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", clone)
|
||||||
|
}
|
||||||
|
if len(clone.EditParams) != 0 {
|
||||||
|
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sameSet(a, b []string) bool {
|
func sameSet(a, b []string) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ const (
|
|||||||
RoleEditPreview = "edit_preview"
|
RoleEditPreview = "edit_preview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EditRoles lists the roles of the edited version. The edited version belongs
|
||||||
|
// to the photo whose edits produced it, so copies of that photo never share
|
||||||
|
// these roles.
|
||||||
|
func EditRoles() []string {
|
||||||
|
return []string{RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEditRole reports whether the role belongs to the edited version.
|
||||||
|
func IsEditRole(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// applyLight renders the light adjustments into a copy of the image. The
|
// applyLight renders the light adjustments into a copy of the image. The
|
||||||
// original pixels are never modified in place.
|
// original pixels are never modified in place.
|
||||||
func applyLight(src image.Image, p LightParams) *image.NRGBA {
|
func applyLight(src image.Image, p LightParams) *image.NRGBA {
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ func (s *Service) ResetEdit(ctx context.Context, photoID, userID string) (*model
|
|||||||
if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
|
if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteObjectBindings(ctx, photoID,
|
if err := s.store.DeleteObjectBindings(ctx, photoID, EditRoles()...); err != nil {
|
||||||
RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.publishEdit(ctx, photoID, userID)
|
return s.publishEdit(ctx, photoID, userID)
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ func TestParseEditParamsClampsValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditRolesCoverTheEditedVersion(t *testing.T) {
|
||||||
|
roles := EditRoles()
|
||||||
|
if len(roles) != 4 {
|
||||||
|
t.Fatalf("edit roles = %v, want four", roles)
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
if !IsEditRole(role) {
|
||||||
|
t.Fatalf("role %q must be an edit role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} {
|
||||||
|
if IsEditRole(role) {
|
||||||
|
t.Fatalf("role %q must not be an edit role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEditParamsIsEmpty(t *testing.T) {
|
func TestEditParamsIsEmpty(t *testing.T) {
|
||||||
var zero EditParams
|
var zero EditParams
|
||||||
if !zero.IsEmpty() {
|
if !zero.IsEmpty() {
|
||||||
|
|||||||
@@ -250,10 +250,18 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
|
|||||||
mediaKind(src.Kind), src.DurationMS); err != nil {
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it, so the
|
||||||
|
// copy shares the base variants only.
|
||||||
|
roles := EditRoles()
|
||||||
|
args := []any{id, src.ID}
|
||||||
|
for _, role := range roles {
|
||||||
|
args = append(args, role)
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO photo_objects (photo_id, object_id, role)
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
id, src.ID); err != nil {
|
WHERE photo_id = $2 AND role NOT IN (`+sqliteutil.Placeholders(3, len(roles))+`)`,
|
||||||
|
args...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|||||||
@@ -290,10 +290,13 @@ func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderI
|
|||||||
mediaKind(src.Kind), src.DurationMS); err != nil {
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it, so the
|
||||||
|
// copy shares the base variants only.
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`INSERT INTO photo_objects (photo_id, object_id, role)
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
id, src.ID); err != nil {
|
WHERE photo_id = $2 AND role <> ALL($3::text[])`,
|
||||||
|
id, src.ID, EditRoles()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
|||||||
{"Social", testSocial},
|
{"Social", testSocial},
|
||||||
{"Photos", testPhotos},
|
{"Photos", testPhotos},
|
||||||
{"Objects", testObjects},
|
{"Objects", testObjects},
|
||||||
|
{"Clone", testClone},
|
||||||
{"UserAvatar", testUserAvatar},
|
{"UserAvatar", testUserAvatar},
|
||||||
{"VariantJobKinds", testVariantJobKinds},
|
{"VariantJobKinds", testVariantJobKinds},
|
||||||
{"Settings", testSettings},
|
{"Settings", testSettings},
|
||||||
@@ -709,6 +710,56 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testClone(t *testing.T, ctx context.Context, r Repos) {
|
||||||
|
owner := seedUser(t, ctx, r, "Owner")
|
||||||
|
a := seedAlbum(t, ctx, r, owner.ID)
|
||||||
|
target := seedAlbum(t, ctx, r, owner.ID)
|
||||||
|
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
|
|
||||||
|
// The photo carries the base variants and an edited version.
|
||||||
|
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||||
|
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
|
||||||
|
t.Fatalf("create thumb: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
|
||||||
|
t.Fatalf("activate thumb: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
|
||||||
|
t.Fatalf("bind thumb: %v", err)
|
||||||
|
}
|
||||||
|
edited := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||||
|
if err := r.Photos.CreateObject(ctx, edited); err != nil {
|
||||||
|
t.Fatalf("create edited object: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, edited.ID); err != nil {
|
||||||
|
t.Fatalf("activate edited object: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.BindObject(ctx, p.ID, edited.ID, photos.RoleEdit); err != nil {
|
||||||
|
t.Fatalf("bind edited version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneID := newID()
|
||||||
|
if err := r.Photos.Clone(ctx, p, target.ID, owner.ID, cloneID); err != nil {
|
||||||
|
t.Fatalf("clone: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneObjects, err := r.Photos.PhotoObjectIDs(ctx, cloneID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone objects: %v", err)
|
||||||
|
}
|
||||||
|
// The copy shares the base variants.
|
||||||
|
if !contains(cloneObjects, thumb.ID) {
|
||||||
|
t.Fatalf("clone objects = %+v, want thumb %s", cloneObjects, thumb.ID)
|
||||||
|
}
|
||||||
|
// The edited version belongs to the photo whose edits produced it.
|
||||||
|
if contains(cloneObjects, edited.ID) {
|
||||||
|
t.Fatalf("clone shares the edited version: %+v", cloneObjects)
|
||||||
|
}
|
||||||
|
if refs, _ := r.Photos.ObjectRefCount(ctx, edited.ID); refs != 1 {
|
||||||
|
t.Fatalf("edited object refs = %d, want 1", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
||||||
owner := seedUser(t, ctx, r, "Owner")
|
owner := seedUser(t, ctx, r, "Owner")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The removed bindings cannot be restored: which object belonged to which copy
|
||||||
|
-- is not recorded anywhere. Rollback of the code is enough - the edited
|
||||||
|
-- versions of the photos that own them stay untouched.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The edited version belongs to the photo whose edits produced it. Copies made
|
||||||
|
-- before the fix still carried the edit bindings, so a photo without edit
|
||||||
|
-- parameters showed the edited picture. Drop those bindings: the objects
|
||||||
|
-- become collectable garbage and the copies show the original picture again.
|
||||||
|
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (
|
||||||
|
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'::jsonb
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The removed bindings cannot be restored: which object belonged to which copy
|
||||||
|
-- is not recorded anywhere. Rollback of the code is enough - the edited
|
||||||
|
-- versions of the photos that own them stay untouched.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The edited version belongs to the photo whose edits produced it. Copies made
|
||||||
|
-- before the fix still carried the edit bindings, so a photo without edit
|
||||||
|
-- parameters showed the edited picture. Drop those bindings: the objects
|
||||||
|
-- become collectable garbage and the copies show the original picture again.
|
||||||
|
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (
|
||||||
|
SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}'
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { photosApi } from '$lib/api';
|
import { photosApi } from '$lib/api';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import { edit } from '$lib/stores/edit.svelte';
|
import { edit } from '$lib/stores/edit.svelte';
|
||||||
|
import { applyLight } from '$lib/light-wasm';
|
||||||
import {
|
import {
|
||||||
CHANNEL_COLORS,
|
CHANNEL_COLORS,
|
||||||
CHANNEL_LABELS,
|
CHANNEL_LABELS,
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
let curveCollapsed = $state(true);
|
||||||
let channel = $state<CurveChannel>('rgb');
|
let channel = $state<CurveChannel>('rgb');
|
||||||
|
|
||||||
const saved = $derived(readEditParams(photo));
|
const saved = $derived(readEditParams(photo));
|
||||||
@@ -53,6 +55,89 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Гистограмма каналов считается по базовому превью фото и пересчитывается
|
||||||
|
// с учётом текущих правок, поэтому реагирует на движение ползунков и кривой.
|
||||||
|
type Histogram = { red: number[]; green: number[]; blue: number[] };
|
||||||
|
let sample = $state<Uint8ClampedArray | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const url = photo.preview_url;
|
||||||
|
if (!url) {
|
||||||
|
sample = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
sample = sampleImage(img);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Уменьшенная копия фото: пиксели для расчёта гистограммы. */
|
||||||
|
function sampleImage(img: HTMLImageElement): Uint8ClampedArray | null {
|
||||||
|
const width = 256;
|
||||||
|
const height = Math.max(1, Math.round((img.naturalHeight / img.naturalWidth) * width));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
return new Uint8ClampedArray(ctx.getImageData(0, 0, width, height).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Гистограмма с учётом текущих правок. */
|
||||||
|
const histogram = $derived.by<Histogram | null>(() => {
|
||||||
|
const pixels = sample;
|
||||||
|
if (!pixels) return null;
|
||||||
|
const data = new Uint8ClampedArray(pixels);
|
||||||
|
applyLight(data, params.light);
|
||||||
|
const r = new Array<number>(256).fill(0);
|
||||||
|
const g = new Array<number>(256).fill(0);
|
||||||
|
const b = new Array<number>(256).fill(0);
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
r[data[i]]++;
|
||||||
|
g[data[i + 1]]++;
|
||||||
|
b[data[i + 2]]++;
|
||||||
|
}
|
||||||
|
let max = 0;
|
||||||
|
for (const values of [r, g, b]) {
|
||||||
|
for (const v of values) if (v > max) max = v;
|
||||||
|
}
|
||||||
|
if (!max) return null;
|
||||||
|
const norm = (values: number[]) => values.map((v) => v / max);
|
||||||
|
return { red: norm(r), green: norm(g), blue: norm(b) };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Каналы, которые показываем на графике: у мастер-канала — все три. */
|
||||||
|
const visibleChannels = $derived<CurveChannel[]>(
|
||||||
|
channel === 'rgb' ? ['red', 'green', 'blue'] : [channel]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Значения гистограммы для канала. */
|
||||||
|
function histogramValues(ch: CurveChannel): number[] {
|
||||||
|
if (!histogram) return [];
|
||||||
|
if (ch === 'rgb') return histogram.red;
|
||||||
|
return histogram[ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Гистограмма канала в виде вертикальных столбиков. */
|
||||||
|
function histogramPath(values: number[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const last = values.length - 1;
|
||||||
|
for (let i = 0; i <= last; i++) {
|
||||||
|
const x = ((i / last) * 100).toFixed(2);
|
||||||
|
const y = (100 - values[i] * 100).toFixed(2);
|
||||||
|
parts.push(`M${x},100 L${x},${y}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
|
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
|
||||||
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
|
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
|
||||||
const SAVE_DELAY = 10_000;
|
const SAVE_DELAY = 10_000;
|
||||||
@@ -317,7 +402,19 @@
|
|||||||
|
|
||||||
<div class="border-border border-t pt-3">
|
<div class="border-border border-t pt-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-text text-xs font-semibold">Кривая</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text hover:text-primary flex items-center gap-1 text-xs font-semibold"
|
||||||
|
aria-expanded={!curveCollapsed}
|
||||||
|
aria-controls="light-curve"
|
||||||
|
onclick={() => (curveCollapsed = !curveCollapsed)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="expand_more"
|
||||||
|
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
|
||||||
|
/>
|
||||||
|
Кривая
|
||||||
|
</button>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
{#each CURVE_CHANNELS as ch (ch)}
|
{#each CURVE_CHANNELS as ch (ch)}
|
||||||
<button
|
<button
|
||||||
@@ -336,6 +433,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !curveCollapsed}
|
||||||
|
<div id="light-curve">
|
||||||
<svg
|
<svg
|
||||||
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
|
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
@@ -347,20 +446,34 @@
|
|||||||
onpointerleave={onPointUp}
|
onpointerleave={onPointUp}
|
||||||
>
|
>
|
||||||
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
|
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
|
||||||
|
{#if histogram}
|
||||||
|
{#each visibleChannels as ch (ch)}
|
||||||
|
<path
|
||||||
|
d={histogramPath(histogramValues(ch))}
|
||||||
|
fill="none"
|
||||||
|
stroke-width="0.4"
|
||||||
|
style="stroke: {CHANNEL_COLORS[ch]}; opacity: {ch === 'rgb' ? 0.45 : 0.7}"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{#each [25, 50, 75] as step (step)}
|
{#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={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" />
|
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
|
||||||
{/each}
|
{/each}
|
||||||
<path d={curvePath(channel)} fill="none" stroke={CHANNEL_COLORS[channel]} stroke-width="1.2" />
|
<path
|
||||||
|
d={curvePath(channel)}
|
||||||
|
fill="none"
|
||||||
|
stroke-width="1.4"
|
||||||
|
style="stroke: {CHANNEL_COLORS[channel]}"
|
||||||
|
/>
|
||||||
{#each channelPoints(channel) as point, index (index)}
|
{#each channelPoints(channel) as point, index (index)}
|
||||||
<circle
|
<circle
|
||||||
cx={point.x * 100}
|
cx={point.x * 100}
|
||||||
cy={(1 - point.y) * 100}
|
cy={(1 - point.y) * 100}
|
||||||
r="2.4"
|
r="2.4"
|
||||||
class="cursor-grab"
|
class="cursor-grab"
|
||||||
fill="var(--bg)"
|
stroke-width="1.2"
|
||||||
stroke={CHANNEL_COLORS[channel]}
|
style="fill: hsl(var(--bg)); stroke: {CHANNEL_COLORS[channel]}"
|
||||||
stroke-width="1"
|
|
||||||
role="slider"
|
role="slider"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Точка кривой {index + 1}"
|
aria-label="Точка кривой {index + 1}"
|
||||||
@@ -385,6 +498,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
||||||
|
|||||||
@@ -231,9 +231,11 @@
|
|||||||
const dh = Math.round(visibleH);
|
const dh = Math.round(visibleH);
|
||||||
|
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||||
const data = ctx.getImageData(dx, dy, dw, dh);
|
|
||||||
applyLight(data.data, liveParams.light);
|
// Фильтруем весь холст: так прошлый кадр не смешивается с новым.
|
||||||
ctx.putImageData(data, dx, dy);
|
const frame = ctx.getImageData(0, 0, width, height);
|
||||||
|
applyLight(frame.data, liveParams.light);
|
||||||
|
ctx.putImageData(frame, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleViewport() {
|
function scheduleViewport() {
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export const CHANNEL_LABELS: Record<CurveChannel, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
|
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
|
||||||
rgb: 'var(--text)',
|
// --text в теме хранит компоненты HSL, поэтому цвет собирается через hsl().
|
||||||
|
rgb: 'hsl(var(--text))',
|
||||||
red: '#ef4444',
|
red: '#ef4444',
|
||||||
green: '#22c55e',
|
green: '#22c55e',
|
||||||
blue: '#3b82f6'
|
blue: '#3b82f6'
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
|
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -19,6 +19,9 @@ interface LightExports {
|
|||||||
|
|
||||||
let wasm: LightExports;
|
let wasm: LightExports;
|
||||||
|
|
||||||
|
const LUTS_OFFSET = 0;
|
||||||
|
const PIXELS_OFFSET = 4096;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -27,24 +30,27 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
/** Применяет правки модулем WASM к копии пикселей. */
|
/** Применяет правки модулем WASM к копии пикселей. */
|
||||||
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
||||||
const points = light.curve?.rgb ?? [];
|
|
||||||
const floats = new Float32Array(points.length * 2);
|
|
||||||
points.forEach((p, i) => {
|
|
||||||
floats[i * 2] = p.x;
|
|
||||||
floats[i * 2 + 1] = p.y;
|
|
||||||
});
|
|
||||||
const mem = wasm.memory;
|
const mem = wasm.memory;
|
||||||
const need = Math.ceil((4096 + pixels.length) / 65536);
|
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
|
||||||
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536);
|
const have = mem.buffer.byteLength / 65536;
|
||||||
new Float32Array(mem.buffer, 0, floats.length).set(floats);
|
if (need > have) mem.grow(need - have);
|
||||||
wasm.build_lut(0, points.length, 0);
|
|
||||||
wasm.build_lut(0, 0, 1);
|
const curve = light.curve ?? {};
|
||||||
wasm.build_lut(0, 0, 2);
|
const tables = [
|
||||||
wasm.build_lut(0, 0, 3);
|
buildCurveLUT(curve.rgb),
|
||||||
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels);
|
buildCurveLUT(curve.red),
|
||||||
|
buildCurveLUT(curve.green),
|
||||||
|
buildCurveLUT(curve.blue)
|
||||||
|
];
|
||||||
|
const luts = new Uint8Array(256 * tables.length);
|
||||||
|
tables.forEach((table, i) => luts.set(table, i * 256));
|
||||||
|
new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts);
|
||||||
|
|
||||||
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
|
||||||
wasm.apply(
|
wasm.apply(
|
||||||
4096,
|
PIXELS_OFFSET,
|
||||||
pixels.length,
|
pixels.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -52,7 +58,7 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
|
|||||||
(light.whites / 100) * 80,
|
(light.whites / 100) * 80,
|
||||||
(light.blacks / 100) * 80
|
(light.blacks / 100) * 80
|
||||||
);
|
);
|
||||||
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice();
|
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WASM-модуль фильтров', () => {
|
describe('WASM-модуль фильтров', () => {
|
||||||
@@ -86,4 +92,10 @@ describe('WASM-модуль фильтров', () => {
|
|||||||
const out = applyWasm(pixels, light);
|
const out = applyWasm(pixels, light);
|
||||||
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('осветляет при положительной экспозиции', () => {
|
||||||
|
const light: LightParams = { ...emptyEditParams().light, exposure: 1 };
|
||||||
|
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
|
||||||
|
expect(out[0]).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
|
import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WASM-модуль фильтров «Свет».
|
* WASM-модуль фильтров «Свет».
|
||||||
*
|
*
|
||||||
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
||||||
* Он считает правки по пикселям — это горячий путь предпросмотра в
|
* Он попиксельно применяет правки — это горячий путь предпросмотра в
|
||||||
* просмотрщике. Если модуль недоступен, используется JS-реализация.
|
* просмотрщике. Если модуль недоступен или считает иначе, чем эталонная
|
||||||
|
* JS-реализация, используется JS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const POINTS_OFFSET = 0;
|
const LUTS_OFFSET = 0;
|
||||||
const PIXELS_OFFSET = 4096;
|
const PIXELS_OFFSET = 4096;
|
||||||
const PAGE = 65536;
|
const PAGE = 65536;
|
||||||
|
const LUT_LEN = 256;
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -36,7 +38,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
|
|||||||
if (!loading) {
|
if (!loading) {
|
||||||
loading = (async () => {
|
loading = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, { cache: 'no-store' });
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const bytes = await res.arrayBuffer();
|
const bytes = await res.arrayBuffer();
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -67,17 +69,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
|
|||||||
return mem;
|
return mem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */
|
/** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
|
||||||
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void {
|
function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
|
||||||
const ex = exportsRef;
|
const curve = light.curve ?? {};
|
||||||
if (!ex) return;
|
const tables = [
|
||||||
const floats = new Float32Array(points.length * 2);
|
buildCurveLUT(curve.rgb),
|
||||||
for (let i = 0; i < points.length; i++) {
|
buildCurveLUT(curve.red),
|
||||||
floats[i * 2] = points[i].x;
|
buildCurveLUT(curve.green),
|
||||||
floats[i * 2 + 1] = points[i].y;
|
buildCurveLUT(curve.blue)
|
||||||
}
|
];
|
||||||
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats);
|
const bytes = new Uint8Array(LUT_LEN * tables.length);
|
||||||
ex.build_lut(POINTS_OFFSET, points.length, channel);
|
tables.forEach((table, i) => bytes.set(table, i * LUT_LEN));
|
||||||
|
new Uint8Array(mem.buffer, LUTS_OFFSET, bytes.length).set(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,16 +94,12 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
|
|||||||
const mem = ensureMemory(bytes);
|
const mem = ensureMemory(bytes);
|
||||||
if (!mem) return false;
|
if (!mem) return false;
|
||||||
|
|
||||||
const curve = light.curve ?? {};
|
writeLuts(mem, light);
|
||||||
buildLut(mem, curve.rgb ?? [], 0);
|
|
||||||
buildLut(mem, curve.red ?? [], 1);
|
|
||||||
buildLut(mem, curve.green ?? [], 2);
|
|
||||||
buildLut(mem, curve.blue ?? [], 3);
|
|
||||||
|
|
||||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||||
ex.apply(
|
ex.apply(
|
||||||
PIXELS_OFFSET,
|
PIXELS_OFFSET,
|
||||||
data.length,
|
data.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -112,8 +111,33 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сверяет модуль с эталонной JS-реализацией на маленьком образце.
|
||||||
|
*
|
||||||
|
* Модуль и JS должны считать одинаково; если модуль расходится (другая
|
||||||
|
* сборка, битая версия в кэше) — доверяем JS, чтобы предпросмотр не был
|
||||||
|
* испорчен.
|
||||||
|
*/
|
||||||
|
function wasmMatchesReference(light: LightParams): boolean {
|
||||||
|
const probe = new Uint8ClampedArray(16 * 4);
|
||||||
|
for (let i = 0; i < probe.length; i += 4) {
|
||||||
|
probe[i] = (i * 7) % 256;
|
||||||
|
probe[i + 1] = (i * 13) % 256;
|
||||||
|
probe[i + 2] = (i * 29) % 256;
|
||||||
|
probe[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = new Uint8ClampedArray(probe);
|
||||||
|
applyLightToPixels(expected, light);
|
||||||
|
const actual = new Uint8ClampedArray(probe);
|
||||||
|
if (!applyLightWasm(actual, light)) return false;
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (expected[i] !== actual[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
||||||
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||||
if (applyLightWasm(data, light)) return;
|
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
|
||||||
applyLightToPixels(data, light);
|
applyLightToPixels(data, light);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
|
|||||||
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
||||||
async function handleAsset(request: Request): Promise<Response> {
|
async function handleAsset(request: Request): Promise<Response> {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
// wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы
|
||||||
|
// предпросмотр правок, поэтому его всегда берём из сети.
|
||||||
|
if (new URL(request.url).pathname.endsWith('.wasm')) {
|
||||||
|
try {
|
||||||
|
const fresh = await fetch(request, { cache: 'no-store' });
|
||||||
|
if (fresh.ok) await cache.put(request, fresh.clone());
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
const stale = await cache.match(request);
|
||||||
|
if (stale) return stale;
|
||||||
|
}
|
||||||
|
}
|
||||||
const cached = await cache.match(request);
|
const cached = await cache.match(request);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,12 @@
|
|||||||
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка.
|
//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
|
||||||
//!
|
//!
|
||||||
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
||||||
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
||||||
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go).
|
//! рендера.
|
||||||
|
//!
|
||||||
|
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
|
||||||
|
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
|
||||||
|
//! функции, поэтому работает без математической библиотеки.
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use core::panic::PanicInfo;
|
use core::panic::PanicInfo;
|
||||||
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
|
|||||||
loop {}
|
loop {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
||||||
const CHANNELS: usize = 4;
|
const CHANNELS: usize = 4;
|
||||||
|
|
||||||
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS];
|
/// Длина таблицы соответствия одного канала.
|
||||||
|
const LUT_LEN: usize = 256;
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
fn clamp01(v: f32) -> f32 {
|
fn clamp01(v: f32) -> f32 {
|
||||||
@@ -39,57 +44,16 @@ fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Строит таблицу соответствия 0..255 по точкам кривой.
|
|
||||||
///
|
|
||||||
/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x.
|
|
||||||
/// Точки вне диапазона обрезаются, дубликаты по x пропускаются.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) {
|
|
||||||
unsafe {
|
|
||||||
let luts = &mut *core::ptr::addr_of_mut!(LUTS);
|
|
||||||
let ch = (channel as usize).min(CHANNELS - 1);
|
|
||||||
let lut = &mut luts[ch];
|
|
||||||
if count < 2 {
|
|
||||||
for (i, slot) in lut.iter_mut().enumerate() {
|
|
||||||
*slot = i as u8;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let n = count as usize;
|
|
||||||
let mut seg = 0usize;
|
|
||||||
for v in 0..256 {
|
|
||||||
let x = v as f32 / 255.0;
|
|
||||||
while seg + 1 < n && x > *points.add((seg + 1) * 2) {
|
|
||||||
seg += 1;
|
|
||||||
}
|
|
||||||
let x0 = *points.add(seg * 2);
|
|
||||||
let y0 = *points.add(seg * 2 + 1);
|
|
||||||
let x1 = *points.add((seg + 1) * 2);
|
|
||||||
let y1 = *points.add((seg + 1) * 2 + 1);
|
|
||||||
let y = if x <= x0 {
|
|
||||||
y0
|
|
||||||
} else if x >= x1 {
|
|
||||||
y1
|
|
||||||
} else if x1 - x0 < 1e-6 {
|
|
||||||
y1
|
|
||||||
} else {
|
|
||||||
let t = (x - x0) / (x1 - x0);
|
|
||||||
y0 + t * (y1 - y0)
|
|
||||||
};
|
|
||||||
lut[v] = (clamp01(y) * 255.0 + 0.5) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
||||||
///
|
///
|
||||||
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental
|
/// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
|
||||||
/// функции, поэтому работает без математической библиотеки.
|
/// синий), подготовленные вызывающим кодом.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "C" fn apply(
|
pub extern "C" fn apply(
|
||||||
data: *mut u8,
|
data: *mut u8,
|
||||||
len: u32,
|
len: u32,
|
||||||
|
luts: *const u8,
|
||||||
exposure_mul: f32,
|
exposure_mul: f32,
|
||||||
contrast_k: f32,
|
contrast_k: f32,
|
||||||
highlights: f32,
|
highlights: f32,
|
||||||
@@ -97,54 +61,22 @@ pub extern "C" fn apply(
|
|||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
) {
|
) {
|
||||||
unsafe {
|
let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
|
||||||
let luts = &*core::ptr::addr_of!(LUTS);
|
let master = &tables[0..LUT_LEN];
|
||||||
let master = &luts[0];
|
let red = &tables[LUT_LEN..2 * LUT_LEN];
|
||||||
let red = &luts[1];
|
let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
|
||||||
let green = &luts[2];
|
let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
|
||||||
let blue = &luts[3];
|
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
||||||
let buf = core::slice::from_raw_parts_mut(data, len as usize);
|
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
while i + 3 < buf.len() {
|
while i + 3 < buf.len() {
|
||||||
if buf[i + 3] != 0 {
|
if buf[i + 3] != 0 {
|
||||||
buf[i] = adjust(
|
buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red);
|
||||||
buf[i] as f32,
|
buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green);
|
||||||
exposure_mul,
|
buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue);
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
red,
|
|
||||||
);
|
|
||||||
buf[i + 1] = adjust(
|
|
||||||
buf[i + 1] as f32,
|
|
||||||
exposure_mul,
|
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
green,
|
|
||||||
);
|
|
||||||
buf[i + 2] = adjust(
|
|
||||||
buf[i + 2] as f32,
|
|
||||||
exposure_mul,
|
|
||||||
contrast_k,
|
|
||||||
highlights,
|
|
||||||
shadows,
|
|
||||||
whites,
|
|
||||||
blacks,
|
|
||||||
master,
|
|
||||||
blue,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
i += 4;
|
i += 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
||||||
/// и обе тональные кривые (общая, затем канальная).
|
/// и обе тональные кривые (общая, затем канальная).
|
||||||
@@ -158,8 +90,8 @@ fn adjust(
|
|||||||
shadows: f32,
|
shadows: f32,
|
||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
master: &[u8; 256],
|
master: &[u8],
|
||||||
channel: &[u8; 256],
|
channel: &[u8],
|
||||||
) -> u8 {
|
) -> u8 {
|
||||||
let mut v = value * exposure_mul;
|
let mut v = value * exposure_mul;
|
||||||
v = 128.0 + (v - 128.0) * contrast_k;
|
v = 128.0 + (v - 128.0) * contrast_k;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-21
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. `proposal.md` — Why. Ограничения, важные для подхода:
|
||||||
|
|
||||||
|
- `Store.Clone` (Postgres) и `SQLiteStore.Clone` (SQLite) копируют привязки
|
||||||
|
`photo_objects` одной вставкой `INSERT ... SELECT` без фильтра по ролям, поэтому
|
||||||
|
копия наследует и обычные варианты, и edit-варианты.
|
||||||
|
- Обычные варианты действительно должны разделяться: воркер создаёт их через
|
||||||
|
`createObject(ctx, siblings, ...)` , где `siblings` — все фото, делящие оригинал
|
||||||
|
(`PhotosByOriginalObject`). Edit-варианты, наоборот, создаются с владельцем
|
||||||
|
`[]string{photoID}` и привязаны только к одному фото.
|
||||||
|
- Параметры правок лежат в колонке `photos.edit_params` и при клонировании не
|
||||||
|
переносятся: `INSERT INTO photos (...)` в `Clone` её не включает. То есть рассинхрон
|
||||||
|
возникает именно на уровне привязок объектов.
|
||||||
|
- `ResetEdit` удаляет и параметры, и привязки edit-объектов
|
||||||
|
(`DeleteObjectBindings`). Значит, у фото с пустыми `edit_params` edit-привязок быть
|
||||||
|
не должно — кроме как последствия копирования.
|
||||||
|
- Выдача URL смотрит только на привязки (`attachURLs`), а фронтенд выбирает
|
||||||
|
`edit_thumb_url ?? thumb_url`. Привязка — источник правды о том, какие варианты
|
||||||
|
есть у фото; чинить выдачу URL вместо привязки нельзя.
|
||||||
|
- `ClaimGarbage` собирает готовые объекты без привязок, поэтому удаление привязки
|
||||||
|
само по себе освобождает объект — отдельная уборка не нужна.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Копия фото не наследует отредактированную версию: показывается исходник, параметры
|
||||||
|
правок пусты.
|
||||||
|
- Postgres и SQLite ведут себя одинаково, список исключаемых ролей задан один раз.
|
||||||
|
- Уже существующие копии в базе перестают показывать отредактированную версию без
|
||||||
|
ручного вмешательства.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Не переносим параметры правок на копию: правка принадлежит фото, а не копии.
|
||||||
|
- Не рендерим отредактированную версию заново для копии.
|
||||||
|
- Не меняем поведение переноса (`MoveBatch`) и разделяемых обычных вариантов.
|
||||||
|
- Не трогаем клиентов и HTTP API: формат ответа не меняется.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Фильтровать роли в `Clone`, а не в выдаче URL
|
||||||
|
|
||||||
|
Копирование исключает edit-роли из переносимых привязок:
|
||||||
|
|
||||||
|
```
|
||||||
|
INSERT INTO photo_objects (photo_id, object_id, role)
|
||||||
|
SELECT $1, object_id, role FROM photo_objects
|
||||||
|
WHERE photo_id = $2 AND role <> ALL($3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Альтернатива: прятать edit-URL, если у фото нет `edit_params`.** Отклонено:
|
||||||
|
привязка осталась бы в базе, GC считал бы объект живым, а модель и привязка
|
||||||
|
расходились бы. Привязка — источник правды.
|
||||||
|
- **Альтернатива: копировать и параметры, и готовую версию.** Отклонено: меняет
|
||||||
|
семантику «правка принадлежит фото» и требует отдельного рендера для копии.
|
||||||
|
|
||||||
|
### 2. Список edit-ролей — в одном месте
|
||||||
|
|
||||||
|
Роли уже объявлены в `backend/internal/photos/edit_render.go`
|
||||||
|
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`), оба хранилища
|
||||||
|
находятся в том же пакете. Добавляется общий список ролей и хелпер проверки, чтобы
|
||||||
|
Postgres и SQLite не расходились и в SQL не попадали строковые литералы.
|
||||||
|
|
||||||
|
- **Альтернатива: писать роли литералами в каждом `Clone`.** Отклонено: список
|
||||||
|
размножится, добавление новой edit-роли забудет одно из хранилищ.
|
||||||
|
|
||||||
|
### 3. Разовая чистка существующих копий — миграцией
|
||||||
|
|
||||||
|
В базе могут остаться привязки `edit*` у фото с пустыми `edit_params` — это и есть
|
||||||
|
следы бага копирования. Чистка выполняется миграцией в обоих диалектах
|
||||||
|
(Postgres и SQLite нумеруются независимо):
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE FROM photo_objects
|
||||||
|
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
|
||||||
|
AND photo_id IN (SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}')
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Альтернатива: чистить лениво при чтении.** Отклонено: правило прячется в двух
|
||||||
|
местах и не отражается в данных.
|
||||||
|
- **Альтернатива: ничего не чистить.** Отклонено: старые копии продолжат показывать
|
||||||
|
отредактированную версию, и расхождение останется в данных.
|
||||||
|
- Удалённые привязки не осиротевают объекты: отредактированная версия остаётся
|
||||||
|
привязана к своему фото, поэтому её объекты живут, пока живёт фото, и обычный
|
||||||
|
`ClaimGarbage` уберёт их вместе с ним.
|
||||||
|
|
||||||
|
### 4. Проверка — в существующем тесте копирования
|
||||||
|
|
||||||
|
`backend/internal/photos/copy_test.go` уже проверяет, что копия делит объекты с
|
||||||
|
оригиналом. Добавляется сценарий: после рендера правок копия получает обычные
|
||||||
|
варианты, но не edit-объекты, и её `edit_params` пусты.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Копии, созданные до фикса, продолжат показывать правки до применения миграции] →
|
||||||
|
Миграция чистит привязки у фото с пустыми `edit_params`; после неё поведение
|
||||||
|
единообразно.
|
||||||
|
- [Пользователь может потерять «неожиданно» отредактированную копию] → Копия без
|
||||||
|
параметров правок всё равно не позволяла их править осмысленно: первая новая правка
|
||||||
|
перезаписывала обработку с нуля.
|
||||||
|
- [Опечатка в списке ролей оставит edit-привязку в копии] → Список ролей берётся из
|
||||||
|
общих констант, а тест проверяет конкретные роли.
|
||||||
|
- [Миграция удалит привязки у фото, которые потеряли `edit_params` не из-за копирования]
|
||||||
|
→ Таких фото нет: `ResetEdit` удаляет и параметры, и привязки одновременно.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Миграция Postgres (`000016_*`) и SQLite (`000008_*`) удаляют «чужие» edit-привязки.
|
||||||
|
Обе идемпотентны (`DELETE` без `IF EXISTS` по данным).
|
||||||
|
2. Изменения в `Clone` — только код, схема не меняется.
|
||||||
|
3. Откат: миграции `down` возвращают удалённые привязки не могут — откат выполняется
|
||||||
|
только откатом кода; правки фото при этом сохраняются у исходных фото.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Отредактированная версия фото задумана принадлежащей только тому фото, чьи правки её
|
||||||
|
породили: воркер создаёт edit-объекты с владельцем `[]string{photoID}` и комментирует
|
||||||
|
это прямо. Но копирование фото (`Clone`) переносит **все** привязки `photo_objects`,
|
||||||
|
включая роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`. Копия фото, сделанная
|
||||||
|
после рендера правок, поэтому показывает отредактированную картинку, но не наследует
|
||||||
|
`edit_params`: в панели правок у копии параметров нет, и первая же новая правка
|
||||||
|
перезаписывает исходную обработку с нуля. Поведение расходится с замыслом и с
|
||||||
|
описанием в спеке.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Копирование фото перестаёт переносить отредактированную версию: в копию
|
||||||
|
попадают только обычные роли (`original`, `thumb`, `medium`, `preview`, `playback`).
|
||||||
|
- Копия фото с правками показывается в исходном виде, параметры правок копии
|
||||||
|
остаются пустыми — как и сейчас.
|
||||||
|
- Обычные варианты по-прежнему разделяются между копиями одного оригинала: правка
|
||||||
|
принадлежит фото, а не объекту.
|
||||||
|
- Спека `media-objects` приводится к этому правилу: уточняется, кому принадлежит
|
||||||
|
отредактированная версия и что происходит при копировании.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `media-objects`: копирование фото не переносит отредактированную версию на копию;
|
||||||
|
отредактированная версия остаётся привязанной только к фото, чьи правки её создали.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `backend/internal/photos/store.go` — `Store.Clone` (Postgres): при копировании
|
||||||
|
`photo_objects` исключаются роли `edit*`.
|
||||||
|
- `backend/internal/photos/sqlite_store.go` — `SQLiteStore.Clone` (SQLite): то же
|
||||||
|
поведение.
|
||||||
|
- `backend/internal/photos/edit_render.go` — константы ролей `edit*` используются как
|
||||||
|
список исключений (без дублирования строк в двух хранилищах).
|
||||||
|
- `backend/internal/photos/copy_test.go` — тест на копию фото с отрендеренной
|
||||||
|
отредактированной версией.
|
||||||
|
- Схема БД, HTTP API и клиенты не меняются; миграций нет.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Совместные объекты
|
||||||
|
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
||||||
|
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
||||||
|
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
|
||||||
|
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
|
||||||
|
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
|
||||||
|
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
|
||||||
|
виде и SHALL NOT наследовать параметры правок.
|
||||||
|
|
||||||
|
#### Scenario: Копирование
|
||||||
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
|
- **THEN** создаётся новая запись, ссылающаяся на те же объекты, без операций с хранилищем
|
||||||
|
|
||||||
|
#### Scenario: Перенос
|
||||||
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
|
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
||||||
|
|
||||||
|
#### Scenario: Правка не распространяется на копии
|
||||||
|
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
|
||||||
|
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
|
||||||
|
остаётся в исходном виде
|
||||||
|
|
||||||
|
#### Scenario: Копия отредактированного фото
|
||||||
|
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
|
||||||
|
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
|
||||||
|
не наследует параметры правок
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Список edit-ролей
|
||||||
|
|
||||||
|
- [x] 1.1 В `backend/internal/photos/edit_render.go` собрать общий список ролей
|
||||||
|
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`) и хелпер проверки
|
||||||
|
роли; проверить, что список используется в обоих хранилищах и не дублируется
|
||||||
|
строковыми литералами
|
||||||
|
|
||||||
|
## 2. Копирование без отредактированной версии
|
||||||
|
|
||||||
|
- [x] 2.1 В `Store.Clone` (`backend/internal/photos/store.go`) исключить edit-роли из
|
||||||
|
переносимых привязок `photo_objects`; проверить тестом `copy_test.go`: копия
|
||||||
|
получает обычные варианты и не получает edit-объекты
|
||||||
|
- [x] 2.2 В `SQLiteStore.Clone` (`backend/internal/photos/sqlite_store.go`) сделать то же
|
||||||
|
для SQLite; проверить тем же тестом, прогнанным на SQLite-хранилище
|
||||||
|
|
||||||
|
## 3. Разовая чистка существующих копий
|
||||||
|
|
||||||
|
- [x] 3.1 Добавить миграцию Postgres `backend/migrations/000016_*.up.sql` и `.down.sql`,
|
||||||
|
удаляющую edit-привязки у фото с пустыми `edit_params`; проверить на тестовой базе,
|
||||||
|
что привязки исходных фото с правками остались, а «чужие» удалены
|
||||||
|
- [x] 3.2 Добавить миграцию SQLite `backend/migrations/sqlite/000008_*.up.sql` и
|
||||||
|
`.down.sql` с тем же действием; проверить тем же сценарием на SQLite
|
||||||
|
- [x] 3.3 Убедиться, что удалённые объекты подбираются сборщиком мусора
|
||||||
|
(`ClaimGarbage` не возвращает объекты с привязками); проверить существующим тестом
|
||||||
|
`objects_test.go`
|
||||||
|
|
||||||
|
## 4. Интеграционная проверка
|
||||||
|
|
||||||
|
- [x] 4.1 Прогнать `cd backend && go test ./internal/photos/ -v`; убедиться, что тесты
|
||||||
|
зелёные
|
||||||
|
- [x] 4.2 Прогнать `make test`; убедиться, что весь бэкенд зелёный
|
||||||
|
- [x] 4.3 Проверить вручную: отредактировать фото, скопировать его в другой альбом и
|
||||||
|
убедиться, что копия показывает исходник, а панель правок у копии пустая
|
||||||
@@ -53,3 +53,22 @@ Android-клиента и устанавливает его на выбранн
|
|||||||
#### Scenario: Явная цель
|
#### Scenario: Явная цель
|
||||||
- **WHEN** `make` запущен с именем цели, например `make frontend`
|
- **WHEN** `make` запущен с именем цели, например `make frontend`
|
||||||
- **THEN** выполняется именно эта цель
|
- **THEN** выполняется именно эта цель
|
||||||
|
|
||||||
|
### Requirement: Сборка WASM-модуля фильтров
|
||||||
|
Фильтры правок в браузере SHALL считаться WASM-модулем, собираемым из исходников на
|
||||||
|
Rust в каталоге `frontend/wasm/light`. Сборка фронтенда SHALL обновлять модуль перед
|
||||||
|
сборкой статики. Если инструмент сборки модуля недоступен, SHALL использоваться готовый
|
||||||
|
модуль из репозитория, а сборка SHALL NOT прерываться. Готовый модуль SHALL храниться
|
||||||
|
в репозитории, чтобы фронтенд собирался без Rust-инструментов.
|
||||||
|
|
||||||
|
#### Scenario: Сборка фронтенда с модулем
|
||||||
|
- **WHEN** выполняется сборка фронтенда
|
||||||
|
- **THEN** перед сборкой статики обновляется WASM-модуль фильтров
|
||||||
|
|
||||||
|
#### Scenario: Нет Rust-инструментов
|
||||||
|
- **WHEN** инструмент сборки модуля недоступен
|
||||||
|
- **THEN** используется готовый модуль из репозитория, а сборка завершается успешно
|
||||||
|
|
||||||
|
#### Scenario: Готовый модуль в репозитории
|
||||||
|
- **WHEN** репозиторий проверяется на чистоту
|
||||||
|
- **THEN** собранный модуль фильтров присутствует в нём
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
|
|
||||||
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
||||||
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
||||||
ролям original, thumb, medium, preview и playback.
|
ролям original, thumb, medium, preview, playback, edit, edit_thumb, edit_medium и
|
||||||
|
edit_preview.
|
||||||
|
|
||||||
#### Scenario: Привязка ролей
|
#### Scenario: Привязка ролей
|
||||||
|
|
||||||
@@ -42,10 +43,18 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
- **WHEN** видео обработано
|
- **WHEN** видео обработано
|
||||||
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
|
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
|
||||||
|
|
||||||
|
#### Scenario: Привязка отредактированной версии
|
||||||
|
|
||||||
|
- **WHEN** фото отредактировано и отредактированная версия отрендерена
|
||||||
|
- **THEN** фото ссылается на объекты edit, edit_thumb, edit_medium и edit_preview рядом с
|
||||||
|
исходными вариантами
|
||||||
|
|
||||||
### Requirement: Совместные объекты
|
### Requirement: Совместные объекты
|
||||||
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
||||||
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
||||||
объекты хранилища.
|
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
|
||||||
|
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
|
||||||
|
разделяются копиями того же оригинала.
|
||||||
|
|
||||||
#### Scenario: Копирование
|
#### Scenario: Копирование
|
||||||
- **WHEN** пользователь копирует фото в другой альбом
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
@@ -55,6 +64,16 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
- **WHEN** пользователь переносит фото в другой альбом
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
||||||
|
|
||||||
|
#### Scenario: Правка не распространяется на копии
|
||||||
|
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
|
||||||
|
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
|
||||||
|
остаётся в исходном виде
|
||||||
|
|
||||||
|
#### Scenario: Копия отредактированного фото
|
||||||
|
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
|
||||||
|
- **THEN** копия разделяет те же объекты, включая отредактированные, но не наследует
|
||||||
|
параметры правок
|
||||||
|
|
||||||
### Requirement: Сборка мусора по ссылкам
|
### Requirement: Сборка мусора по ссылкам
|
||||||
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
|
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
|
||||||
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
|
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
|
||||||
@@ -93,7 +112,9 @@ MIME-типом, размером, габаритами и состоянием;
|
|||||||
однократным выполнением и повтором при сбое; готовый результат SHALL
|
однократным выполнением и повтором при сбое; готовый результат SHALL
|
||||||
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
|
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
|
||||||
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
|
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
|
||||||
SHALL различать короткие задания изображений и длительные задания видео.
|
SHALL различать короткие задания изображений и длительные задания видео. Рендер
|
||||||
|
отредактированных версий SHALL использовать отдельную очередь, чтобы правки не
|
||||||
|
блокировали генерацию обычных вариантов.
|
||||||
|
|
||||||
#### Scenario: Повтор после рестарта
|
#### Scenario: Повтор после рестарта
|
||||||
- **WHEN** процесс перезапускается с незавершёнными заданиями
|
- **WHEN** процесс перезапускается с незавершёнными заданиями
|
||||||
@@ -115,6 +136,10 @@ SHALL различать короткие задания изображений
|
|||||||
- **WHEN** выполняется длительная подготовка видео
|
- **WHEN** выполняется длительная подготовка видео
|
||||||
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
|
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
|
||||||
|
|
||||||
|
#### Scenario: Рендер правок не блокирует варианты
|
||||||
|
- **WHEN** в фоне выполняется рендер отредактированной версии
|
||||||
|
- **THEN** генерация обычных вариантов продолжается независимо от него
|
||||||
|
|
||||||
### Requirement: Аватар пользователя как объект журнала
|
### Requirement: Аватар пользователя как объект журнала
|
||||||
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
|
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
|
||||||
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
|
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
|
||||||
|
|||||||
@@ -286,9 +286,10 @@ SHALL быть доступно и для фото без альбома (нап
|
|||||||
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок
|
Система SHALL поддерживать неразрушающее редактирование фотографий. Параметры правок
|
||||||
SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как
|
SHALL храниться в метаданных фото, а отредактированная версия SHALL создаваться как
|
||||||
отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с
|
отдельные объекты (роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`) рядом с
|
||||||
исходными вариантами. Исходный объект SHALL оставаться неизменным, а скачивание SHALL
|
исходными вариантами. Рендер SHALL выполняться по исходному объекту фото, а не по
|
||||||
отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную версию.
|
его уменьшенным вариантам. Исходный объект SHALL оставаться неизменным, а скачивание
|
||||||
Правки SHALL применять только к изображениям.
|
SHALL отдавать оригинал. Сброс правок SHALL удалять и параметры, и отредактированную
|
||||||
|
версию. Правки SHALL применять только к изображениям.
|
||||||
|
|
||||||
#### Scenario: Сохранение правок
|
#### Scenario: Сохранение правок
|
||||||
- **WHEN** пользователь сохраняет правки фото
|
- **WHEN** пользователь сохраняет правки фото
|
||||||
@@ -298,6 +299,10 @@ SHALL храниться в метаданных фото, а отредакти
|
|||||||
- **WHEN** правки фото сохранены
|
- **WHEN** правки фото сохранены
|
||||||
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
||||||
|
|
||||||
|
#### Scenario: Рендер по оригиналу
|
||||||
|
- **WHEN** готовится отредактированная версия фото
|
||||||
|
- **THEN** правки применяются к пикселям исходного объекта, а не к его уменьшенной копии
|
||||||
|
|
||||||
#### Scenario: Неразрушачность
|
#### Scenario: Неразрушачность
|
||||||
- **WHEN** фото отредактировано
|
- **WHEN** фото отредактировано
|
||||||
- **THEN** исходный объект и его варианты остаются неизменными
|
- **THEN** исходный объект и его варианты остаются неизменными
|
||||||
|
|||||||
@@ -263,9 +263,9 @@ SHALL выдвигать правую панель поверх содержим
|
|||||||
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||||
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
|
верхним пределом 200% размеров фото. Клавиши `+` и `-` SHALL менять масштаб на шаг, сохраняя
|
||||||
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
|
точку в центре просмотра. `Space` SHALL возвращать изображение из любого зума во
|
||||||
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
вписанный вид, а из вписанного — в 100% от оригинала с центрированием по центру кадра.
|
||||||
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
При зуме SHALL догружаться оригинал изображения. Зум SHALL работать и в полноэкранном
|
||||||
работать и в полноэкранном режиме.
|
режиме.
|
||||||
|
|
||||||
#### Scenario: Зум колесом к указателю
|
#### Scenario: Зум колесом к указателю
|
||||||
- **WHEN** пользователь вращает колесо мыши над фото
|
- **WHEN** пользователь вращает колесо мыши над фото
|
||||||
@@ -278,16 +278,27 @@ SHALL выдвигать правую панель поверх содержим
|
|||||||
|
|
||||||
#### Scenario: Пробел
|
#### Scenario: Пробел
|
||||||
- **WHEN** пользователь нажимает `Space`
|
- **WHEN** пользователь нажимает `Space`
|
||||||
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
- **THEN** из вписанного вида изображение переходит в 100% от оригинала по центру кадра, а из
|
||||||
|
любого зума возвращается во вписанный
|
||||||
|
|
||||||
#### Scenario: Оригинал в зуме
|
#### Scenario: Оригинал в зуме
|
||||||
- **WHEN** пользователь входит в зум
|
- **WHEN** пользователь входит в зум
|
||||||
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||||
|
|
||||||
|
### Requirement: Навигация в зуме
|
||||||
|
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Клавиши
|
||||||
|
со стрелками в зуме SHALL перемещать изображение, а переключение соседних фото SHALL
|
||||||
|
оставаться за мышью и кнопками у краёв. В зуме пользователь SHALL перемещать изображение
|
||||||
|
перетаскиванием и прокруткой.
|
||||||
|
|
||||||
#### Scenario: Стрелки соседних фото
|
#### Scenario: Стрелки соседних фото
|
||||||
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||||
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||||
|
|
||||||
|
#### Scenario: Стрелки в зуме
|
||||||
|
- **WHEN** фото увеличено, и пользователь жмёт стрелки клавиатуры
|
||||||
|
- **THEN** изображение перемещается, а переключение соседних фото не происходит
|
||||||
|
|
||||||
#### Scenario: Панорамирование
|
#### Scenario: Панорамирование
|
||||||
- **WHEN** фото увеличено
|
- **WHEN** фото увеличено
|
||||||
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
||||||
@@ -782,27 +793,29 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
||||||
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
||||||
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
||||||
показывать отредактированную версию фото, когда она отрендерена.
|
показывать отредактированную версию фото, когда она отрендерена. Обновление фото SHALL
|
||||||
|
сохранять открытую вкладку панели.
|
||||||
#### Scenario: Отложенная отправка
|
|
||||||
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
|
||||||
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
|
|
||||||
|
|
||||||
#### Scenario: Вкладки панели
|
#### Scenario: Вкладки панели
|
||||||
- **WHEN** открыта правая панель фото
|
- **WHEN** открыта правая панель фото
|
||||||
- **THEN** видны вкладки «Сведения» и «Редактирование»
|
- **THEN** видны вкладки «Сведения» и «Редактирование»
|
||||||
|
|
||||||
|
#### Scenario: Отложенная отправка
|
||||||
|
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
||||||
|
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
|
||||||
|
|
||||||
#### Scenario: Сохранение правок
|
#### Scenario: Сохранение правок
|
||||||
- **WHEN** пользователь отпускает ползунок
|
- **WHEN** пользователь отпускает ползунок
|
||||||
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
||||||
|
|
||||||
#### Scenario: Правки видны сразу
|
#### Scenario: Правки видны сразу
|
||||||
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
|
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
|
||||||
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера
|
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера — и во
|
||||||
|
вписанном виде, и в зуме
|
||||||
|
|
||||||
#### Scenario: Фильтры считаются в браузере
|
#### Scenario: Фильтры считаются в браузере
|
||||||
- **WHEN** браузер рисует предпросмотр правок
|
- **WHEN** браузер рисует предпросмотр правок
|
||||||
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом
|
- **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом
|
||||||
|
|
||||||
#### Scenario: Сброс правок
|
#### Scenario: Сброс правок
|
||||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||||
@@ -811,3 +824,25 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
#### Scenario: Зум при правках
|
#### Scenario: Зум при правках
|
||||||
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
||||||
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
|
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
|
||||||
|
|
||||||
|
### Requirement: График тональной кривой
|
||||||
|
График кривой SHALL показывать гистограмму каналов фото: выбранный канал — своим
|
||||||
|
цветом, мастер-канал — всеми тремя. Гистограмма SHALL пересчитываться с учётом
|
||||||
|
текущих правок. Точки кривой SHALL добавляться кликом по полю графика, перетаскиваться
|
||||||
|
и перемещаться стрелками клавиатуры. Блок «Кривая» SHALL сворачиваться и разворачиваться
|
||||||
|
по клику на заголовок, по умолчанию SHALL быть свёрнут, а кнопки выбора канала SHALL
|
||||||
|
оставаться доступными.
|
||||||
|
|
||||||
|
#### Scenario: Живая гистограмма
|
||||||
|
- **WHEN** пользователь двигает ползунок или точку кривой
|
||||||
|
- **THEN** гистограмма на графике пересчитывается с учётом текущих правок
|
||||||
|
|
||||||
|
#### Scenario: Точки кривой
|
||||||
|
- **WHEN** пользователь кликает по полю графика, перетаскивает точку или жмёт стрелки с
|
||||||
|
фокусом на точке
|
||||||
|
- **THEN** точка добавляется, перемещается и меняет кривую
|
||||||
|
|
||||||
|
#### Scenario: Сворачивание кривой
|
||||||
|
- **WHEN** пользователь открывает инструмент «Свет»
|
||||||
|
- **THEN** блок «Кривая» свёрнут, кнопки выбора канала доступны, а по клику на заголовок
|
||||||
|
график разворачивается и сворачивается обратно
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Отчёт: изменения, сделанные без спецификаций
|
||||||
|
|
||||||
|
- **Дата:** 2026-10-21
|
||||||
|
- **Диапазон:** `45a953f^..eb2d478` — 21 коммит, 2026-10-20 — 2026-10-21
|
||||||
|
- **Ветка:** `develop`
|
||||||
|
- **Предмет:** правки веб-интерфейса просмотра и нового инструмента «Свет» (редактирование фото), сделанные без обновления спецификаций OpenSpec
|
||||||
|
- **Результат:** спеки дописаны (`748143c`), заведено изменение `fix-copy-edit-variant-sharing`
|
||||||
|
|
||||||
|
## 1. Метод
|
||||||
|
|
||||||
|
1. Разобран диапазон коммитов `45a953f^..eb2d478`: для каждого проверено, затронул ли
|
||||||
|
он `openspec/specs`.
|
||||||
|
2. Для коммитов без спек — прочитаны затронутые участки кода
|
||||||
|
(`frontend/src/lib/components/Viewer.svelte`, `PhotoEditPanel.svelte`,
|
||||||
|
`PhotoPanel.svelte`, `frontend/src/lib/edit.ts`, `backend/internal/photos/worker.go`,
|
||||||
|
`store.go`, `sqlite_store.go`).
|
||||||
|
3. Сопоставлено фактическое поведение с текстом существующих требований
|
||||||
|
`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`.
|
||||||
|
4. Каждое найденное расхождение зафиксировано как требование (SHALL) со сценариями
|
||||||
|
WHEN/THEN; после правок спеки проверены `openspec validate --specs`.
|
||||||
|
|
||||||
|
## 2. Сводка
|
||||||
|
|
||||||
|
| Показатель | Значение |
|
||||||
|
| --- | --- |
|
||||||
|
| Коммитов в диапазоне | 21 |
|
||||||
|
| Из них со спеками | 11 |
|
||||||
|
| Из них без спек | 10 |
|
||||||
|
| Затронутых спецификаций | 4 (`ui-workspace`, `media-objects`, `photos`, `dev-make-workflow`) |
|
||||||
|
| Новых требований | 3 («График тональной кривой», «Навигация в зуме», «Сборка WASM-модуля фильтров») |
|
||||||
|
| Уточнённых требований | 6 |
|
||||||
|
| Новых сценариев | 12 |
|
||||||
|
| Ошибок валидации | 0 (`23 passed, 0 failed`) |
|
||||||
|
|
||||||
|
## 3. Покрытие по коммитам
|
||||||
|
|
||||||
|
| Коммит | Изменение | Где описано |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `45a953f` | скрытие боковых панелей, хоткеи размера миниатюр | `ui-workspace` → «Скрытие боковых панелей» (уже было) |
|
||||||
|
| `50b9983` | размер миниатюр не меняется при скрытых панелях | там же, сценарий «Размер миниатюр при скрытых панелях» |
|
||||||
|
| `35f8439` | зум клавишами `+` / `-` | `ui-workspace` → «Зум в просмотре» (уже было) |
|
||||||
|
| `0a9e527` | лента миниатюр следует за текущим фото | уже было |
|
||||||
|
| `961dff1` | `Ctrl+F` — полноэкранный режим приложения | уже было |
|
||||||
|
| `afaaaab` | офлайн-режим с кешем эскизов и превью | `ui-workspace` → «Офлайн-режим веб-приложения» (уже было) |
|
||||||
|
| `0548b1a` | вкладка «Редактирование», инструмент «Свет» | `ui-workspace` → «Вкладка «Редактирование»» (уточнено) |
|
||||||
|
| `cd3d17c` | правки применяются сразу; вкладка не сбрасывается при обновлении фото | уточнено: «Обновление фото SHALL сохранять открытую вкладку панели» |
|
||||||
|
| `6d3d7cb` | ускорение рендера правок в 9 раз | сознательно не специфицировано (внутренняя оптимизация) |
|
||||||
|
| `4d2069d`, `520886a` | клиентский рендер правок, WASM-фильтры | «Фильтры считаются в браузере» (уже было) |
|
||||||
|
| `2adcb13` | предпросмотр правок работает и без зума | уточнено: «Правки видны сразу — и во вписанном виде, и в зуме» |
|
||||||
|
| `500ec10`, `f8ee937` | починка зума, центрирование | «Зум в просмотре»: `Space` = 100% **от оригинала по центру кадра** |
|
||||||
|
| `b2a509f` | в зуме стрелки панорамируют фото | **новое** требование «Навигация в зуме» |
|
||||||
|
| `2668d87` | искажение цветов в предпросмотре | уже было (сценарий про сверку с эталоном) |
|
||||||
|
| `d904b2f` | невидимая линия тональной кривой | сознательно не специфицировано (багфикс) |
|
||||||
|
| `b64574b` | гистограммы каналов в редакторе кривой | **новое** требование «График тональной кривой» |
|
||||||
|
| `48ed0bb` | гистограмма пересчитывается при правке | там же, сценарий «Живая гистограмма» |
|
||||||
|
| `332f35b`, `eb2d478` | сворачивание блока «Кривая», свёрнут по умолчанию | там же, сценарий «Сворачивание кривой» |
|
||||||
|
| `520886a` | сборка WASM-модуля фильтров (`npm run wasm`, cargo) | `dev-make-workflow` → **новое** требование «Сборка WASM-модуля фильтров» |
|
||||||
|
| `520886a`, `worker.go` | роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` | `media-objects` → «Журнал объектов» + сценарий «Привязка отредактированной версии» |
|
||||||
|
| `worker.go` | отдельная очередь рендера правок (`edit_jobs`) | `media-objects` → «Генерация вариантов» + «Рендер правок не блокирует варианты» |
|
||||||
|
| `worker.go` | рендер правок по исходному объекту | `photos` → «Неразрушающее редактирование фотографии» + «Рендер по оригиналу» |
|
||||||
|
|
||||||
|
## 4. Новые и уточнённые требования
|
||||||
|
|
||||||
|
**Новые требования:**
|
||||||
|
|
||||||
|
- `ui-workspace` → «График тональной кривой»: гистограмма каналов, живой пересчёт,
|
||||||
|
добавление и перетаскивание точек, сворачиваемый блок «Кривая» (по умолчанию свёрнут,
|
||||||
|
кнопки выбора канала остаются доступными).
|
||||||
|
- `ui-workspace` → «Навигация в зуме»: клавиши-стрелки панорамируют фото и не
|
||||||
|
переключают соседние снимки, переключение остаётся за мышью и кнопками у краёв.
|
||||||
|
- `dev-make-workflow` → «Сборка WASM-модуля фильтров»: Rust-исходники в
|
||||||
|
`frontend/wasm/light`, обновление модуля перед сборкой статики, откат на готовый
|
||||||
|
модуль из репозитория, если cargo недоступен.
|
||||||
|
|
||||||
|
**Уточнённые требования:**
|
||||||
|
|
||||||
|
- `ui-workspace` → «Вкладка «Редактирование»»: добавлено сохранение вкладки при
|
||||||
|
обновлении фото; предпросмотр правок работает в любом режиме просмотра.
|
||||||
|
- `ui-workspace` → «Зум в просмотре»: `Space` показывает 100% **от оригинала** по
|
||||||
|
центру кадра.
|
||||||
|
- `media-objects` → «Журнал объектов»: перечислены роли `edit`, `edit_thumb`,
|
||||||
|
`edit_medium`, `edit_preview`.
|
||||||
|
- `media-objects` → «Совместные объекты»: уточнено, что отредактированная версия
|
||||||
|
создаётся и привязывается только к фото, чьи правки её породили.
|
||||||
|
- `media-objects` → «Генерация вариантов»: рендер правок идёт в отдельной очереди.
|
||||||
|
- `photos` → «Неразрушающее редактирование фотографии»: рендер идёт по исходному
|
||||||
|
объекту, а не по уменьшенной копии.
|
||||||
|
|
||||||
|
## 5. Что сознательно осталось без спецификаций
|
||||||
|
|
||||||
|
| Изменение | Почему |
|
||||||
|
| --- | --- |
|
||||||
|
| `6d3d7cb` — ускорение рендера в 9 раз | внутренняя оптимизация, внешнее поведение не изменилось |
|
||||||
|
| `d904b2f` — видимость линии кривой | багфикс: требование «кривая показывается» уже было |
|
||||||
|
| `500ec10` — контейнер прокрутки зума | деталь вёрстки, покрыта требованием «Панорамирование» |
|
||||||
|
| правки переноса/центрирования внутри `f8ee937` | описаны пользовательские последствия, а не механизм |
|
||||||
|
|
||||||
|
## 6. Найденное расхождение кода и замысла
|
||||||
|
|
||||||
|
`Store.Clone` и `SQLiteStore.Clone` копируют **все** привязки `photo_objects`,
|
||||||
|
включая роли `edit*`, хотя воркер создаёт edit-объекты с владельцем
|
||||||
|
`[]string{photoID}` и комментирует это так: «The edit belongs to this photo only, so
|
||||||
|
the variants are not shared with the copies of the same original».
|
||||||
|
|
||||||
|
Последствия:
|
||||||
|
|
||||||
|
| Сценарий | Фактическое поведение | Ожидаемое |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Копия **до** правки | копия в исходном виде | так и должно быть |
|
||||||
|
| Копия **после** рендера | копия показывает отредактированную картинку, но `edit_params` пусты: в панели правок параметров нет, первая же новая правка перезаписывает обработку с нуля | копия в исходном виде |
|
||||||
|
|
||||||
|
Оформлено как изменение `fix-copy-edit-variant-sharing`
|
||||||
|
(`openspec/changes/fix-copy-edit-variant-sharing/`):
|
||||||
|
|
||||||
|
- `proposal.md` — зачем;
|
||||||
|
- `design.md` — фильтр ролей в `Clone` вместо правки выдачи URL, единый список
|
||||||
|
edit-ролей, разовая чистка существующих копий миграцией в обоих диалектах;
|
||||||
|
- `specs/media-objects/spec.md` — дельта требования «Совместные объекты»;
|
||||||
|
- `tasks.md` — 4 группы задач (список ролей → копирование → миграции → проверки).
|
||||||
|
|
||||||
|
## 7. Проверка
|
||||||
|
|
||||||
|
```
|
||||||
|
openspec validate --specs → 23 passed, 0 failed
|
||||||
|
openspec validate fix-copy-edit-variant-sharing --type change → valid
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Реализация `fix-copy-edit-variant-sharing`
|
||||||
|
|
||||||
|
Изменение реализовано полностью (9/9 задач):
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
| --- | --- |
|
||||||
|
| Общий список edit-ролей `EditRoles()` и предикат `IsEditRole()` | `backend/internal/photos/edit_render.go` |
|
||||||
|
| `Clone` не переносит edit-привязки (Postgres: `role <> ALL($3::text[])`) | `backend/internal/photos/store.go` |
|
||||||
|
| То же для SQLite (`role NOT IN (...)`) | `backend/internal/photos/sqlite_store.go` |
|
||||||
|
| Сброс правок использует общий список ролей | `backend/internal/photos/edit_service.go` |
|
||||||
|
| Разовая чистка копий, созданных до фикса | `backend/migrations/000016_*.sql`, `backend/migrations/sqlite/000008_*.sql` |
|
||||||
|
| Контрактная проверка копирования (оба хранилища) | `backend/internal/repo/contract/contract.go`, кейс `Clone` |
|
||||||
|
| Сервисные тесты: копия и миграция | `backend/internal/photos/copy_test.go`, `backend/internal/e2e/copy_edit_test.go` |
|
||||||
|
|
||||||
|
Проверки:
|
||||||
|
|
||||||
|
- `make test` (с Postgres) — весь бэкенд зелёный;
|
||||||
|
- контрактный кейс `Clone` проходит на SQLite и Postgres; без фикса он падает
|
||||||
|
(«clone shares the edited version»);
|
||||||
|
- e2e на SQLite: копия показывает исходник, `edit_params` пусты, миграция убирает
|
||||||
|
«чужие» привязки, не трогая привязки владельца;
|
||||||
|
- ручная проверка через HTTP и браузер: в просмотре копии грузится `thumb.webp`
|
||||||
|
(не `edit_preview`), все ползунки панели «Редактирование» в нуле.
|
||||||
|
|
||||||
|
## 9. Что дальше
|
||||||
|
|
||||||
|
- Ревью и архивация изменения `/opsx-archive` — дельта `media-objects` переедет в
|
||||||
|
основную спеку.
|
||||||
|
- Изменение требует Postgres для полного прогона: в этом окружении поднимался
|
||||||
|
временный контейнер `postgres:16-alpine` на порту 55432
|
||||||
|
(`TEST_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:55432/galo?sslmode=disable'`).
|
||||||
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Описание релиза Galo — 21 октября 2026
|
||||||
|
|
||||||
|
**База релиза:** `main` (`84ce4d1`, 10 октября 2026) → `develop` (`afe85f7`, 21 октября 2026)
|
||||||
|
**Объём:** 138 коммитов, 4 спецификации обновлены, 1 изменение реализовано
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коротко о главном
|
||||||
|
|
||||||
|
1. **Локальный режим без внешних сервисов.** Слой метаданных переключается на
|
||||||
|
встроенный SQLite, хранилище блобов — на локальную файловую систему. По умолчанию
|
||||||
|
приложение работает полностью на одной машине: без Postgres и без S3.
|
||||||
|
2. **Редактирование фотографий в браузере.** Вкладка «Редактирование» с инструментом
|
||||||
|
«Свет»: экспозиция, контраст, света, тени, белые и чёрные, тональная кривая по
|
||||||
|
каналам с живой гистограммой. Правки видны сразу, без ожидания сервера.
|
||||||
|
3. **Новый интерфейс рабочего пространства.** Альбомы с обложками, тёмная, светлая и
|
||||||
|
нейтральная темы, единая система векторных иконок, гало-эмблема и фавикон.
|
||||||
|
4. **Нативный Android-клиент.** Отдельное приложение: просмотр альбомов, загрузка с
|
||||||
|
устройства, двусторонняя синхронизация с сервером, безопасная разгрузка превью.
|
||||||
|
5. **Видео и офлайн.** Видео в общих альбомах с фоновой подготовкой и перемоткой;
|
||||||
|
офлайн-кеш оболочки, списков и превью.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новые возможности
|
||||||
|
|
||||||
|
### Локальный режим: SQLite и файловая система
|
||||||
|
|
||||||
|
- **Слой метаданных** выбирается конфигурацией: внешний PostgreSQL или встроенный
|
||||||
|
SQLite с тем же поведением для одного экземпляра приложения.
|
||||||
|
- **Хранилище блобов** выбирается отдельно: S3-совместимое (Garage) или локальная
|
||||||
|
файловая система. Клиент никогда не видит хранилище — блобы всегда отдаются через
|
||||||
|
бэкенд по подписанному URL.
|
||||||
|
- По умолчанию (`DB_BACKEND=sqlite`, `STORAGE_BACKEND=fs`) приложение поднимается без
|
||||||
|
Postgres и S3: данные лежат в `galo.db` и каталоге `data/`.
|
||||||
|
- **Секрет подписи JWT и URL объектов** генерируются при первом старте и хранятся в
|
||||||
|
слое метаданных — переживают перезапуск и рестарт SQLite.
|
||||||
|
- Реконсиляция хранилища с журналом объектов: `make reconcile` (отчёт),
|
||||||
|
`make reconcile-delete` (удаление осиротевших блобов). Сборку мусора ведёт журнал,
|
||||||
|
хранилище не сканируется.
|
||||||
|
|
||||||
|
### Слой объектов
|
||||||
|
|
||||||
|
- Физическое объектное хранилище с **непрозрачными ключами**: идентификаторы альбомов и
|
||||||
|
фото в ключах не участвуют, сопоставление живёт только в метаданных.
|
||||||
|
- **Журнал объектов**: у каждого объекта ровно одна запись с ключом, MIME-типом,
|
||||||
|
размером, габаритами и состоянием; фото ссылается на объекты по ролям
|
||||||
|
(`original`, `thumb`, `medium`, `preview`, `playback`, `edit`, `edit_thumb`,
|
||||||
|
`edit_medium`, `edit_preview`).
|
||||||
|
- Один физический объект может разделяться несколькими фото: перенос и копирование
|
||||||
|
меняют только ссылки, хранилище не трогается.
|
||||||
|
- Сборка мусора по ссылкам: объект удаляется, когда на него не ссылается ни одно фото
|
||||||
|
и ни один аватар.
|
||||||
|
|
||||||
|
### Редактирование фотографий
|
||||||
|
|
||||||
|
- Вкладка «Редактирование» в правой панели фото, инструмент «Свет»: экспозиция,
|
||||||
|
контраст, света, тени, белые, чёрные.
|
||||||
|
- **Тональная кривая**: выбор канала (R/G/B/мастер), точки добавляются кликом по
|
||||||
|
полю графика, перетаскиваются и двигаются стрелками с клавиатуры.
|
||||||
|
- **Гистограмма каналов** пересчитывается на лету с учётом текущих правок.
|
||||||
|
- Блок «Кривая» сворачивается (по умолчанию свёрнут), кнопки выбора канала остаются
|
||||||
|
доступными.
|
||||||
|
- Предпросмотр правок рисуется **в браузере** — WASM-модулем на Rust, с резервной
|
||||||
|
JS-реализацией, если модуль недоступен или считает иначе.
|
||||||
|
- Правки применяются сразу; на сервер уходят через 10 секунд без изменений или при
|
||||||
|
переключении фото. Сервер рендерит отредактированную версию в фоне отдельной
|
||||||
|
очередью и сообщает о готовности.
|
||||||
|
- Переключатель «Сброс» удаляет и параметры правок, и отредактированную версию.
|
||||||
|
- Редактирование неразрушающее: оригинал остаётся неизменным, скачивание отдаёт
|
||||||
|
исходник. Рендер идёт по исходному объекту, а не по уменьшенной копии.
|
||||||
|
|
||||||
|
### Интерфейс рабочего пространства
|
||||||
|
|
||||||
|
- Альбомы с обложками, «лайтрумовская» компоновка: рельс альбомов, сетка, единая
|
||||||
|
панель фотографии.
|
||||||
|
- Три темы — тёмная, светлая, нейтральная — с семантическими токенами; переключатель
|
||||||
|
тем переехал в шапку, нейтральная тема затемнена.
|
||||||
|
- **Единая система иконок**: встроенная векторная графика (Material Symbols) вместо
|
||||||
|
эмодзи и символов системного шрифта — иконки наследуют цвет темы и не тянут
|
||||||
|
внешние ресурсы.
|
||||||
|
- **Гало-эмблема** (диск солнца в отделённом кольце) и фирменный фавикон: отдаются
|
||||||
|
самим приложением, наследуют тему, читаются в размере 16–24 px.
|
||||||
|
- Панель сведений о фото со **сворачиваемыми секциями** и настраиваемым набором
|
||||||
|
атрибутов: состав EXIF хранится в профиле пользователя.
|
||||||
|
- Полноэкранный просмотр: своя панель, кнопка «на весь экран», возврат по `Esc`.
|
||||||
|
|
||||||
|
### Просмотр и навигация
|
||||||
|
|
||||||
|
- **Зум** колесом мыши (с привязкой к курсору, до 200%), клавишами `+` и `-`;
|
||||||
|
`Space` возвращает во вписанный вид, а из вписанного — в 100% от оригинала по
|
||||||
|
центру кадра.
|
||||||
|
- В зуме стрелки клавиатуры **панорамируют** фото, переключение соседних снимков
|
||||||
|
остаётся за мышью и кнопками у краёв.
|
||||||
|
- **Скрытие боковых панелей** альбома: кнопка в панели сетки и хоткей `Tab`. Панели
|
||||||
|
уезжают за края, содержимое занимает всю ширину, панели возвращаются при наведении
|
||||||
|
на кромку окна. Размер миниатюр при этом сохраняется — в строке помещается больше
|
||||||
|
фото.
|
||||||
|
- Лента миниатюр в просмотре следует за текущим фото; на превью работает наведение-
|
||||||
|
зум.
|
||||||
|
- Полноэкранный режим приложения по `Ctrl+F`, навигация с клавиатуры в сетке с
|
||||||
|
возвратом фокуса, открытие фото тапом на тач-экранах.
|
||||||
|
- Просмотр 360-панорам по тегу `pano360`, корректное направление перетаскивания.
|
||||||
|
- Пока грузится оригинал для зума — спиннер; на Android — щипок-зум.
|
||||||
|
|
||||||
|
### Работа с фотографиями
|
||||||
|
|
||||||
|
- Мультивыбор в сетке, контекстные меню, **перенос и копирование** снимков, корзина
|
||||||
|
на уровне альбома и общие счётчики.
|
||||||
|
- **Теги** фото: правки расходятся по сетке и просмотру.
|
||||||
|
- Переименование фото и удаление из просмотра с подтверждением.
|
||||||
|
- EXIF извлекается полностью при загрузке; для старых снимков — дозаполнение
|
||||||
|
(`cmd/backfill-exif`).
|
||||||
|
|
||||||
|
### Офлайн-режим
|
||||||
|
|
||||||
|
- Service worker кеширует оболочку приложения, списки и эскизы/превью фотографий:
|
||||||
|
открытые альбомы и ранее загруженные снимки доступны без сети.
|
||||||
|
- Модуль фильтров в офлайне не кешируется — он всегда подтягивается свежим.
|
||||||
|
|
||||||
|
### Видео
|
||||||
|
|
||||||
|
- Загрузка видео в те же альбомы, что и фото (`kind=video`).
|
||||||
|
- Фоновая подготовка: постер (кадр кадра → обычные варианты сетки) и web-совместимый
|
||||||
|
вариант для воспроизведения (ремукс без перекодирования либо транскод в
|
||||||
|
h264/yuv420p/aac с `+faststart`), с поддержкой перемотки (`Range`).
|
||||||
|
- Транскод идёт в отдельном малом пуле и не вытесняет генерацию вариантов
|
||||||
|
изображений; готовность транслируется подписчикам альбома.
|
||||||
|
- Без `ffmpeg`/`ffprobe` приём видео отклоняется, `/api/health` сообщает
|
||||||
|
`"video": "unavailable"`, изображения работают как прежде.
|
||||||
|
|
||||||
|
### Android-клиент
|
||||||
|
|
||||||
|
- Отдельный Gradle-модуль, работает с существующим HTTP API, сервер и веб не
|
||||||
|
трогает. Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`.
|
||||||
|
- **Двусторонняя синхронизация** альбома устройства с серверным альбомом: загрузки,
|
||||||
|
изменения состава, удаления.
|
||||||
|
- **Безопасная разгрузка**: с устройства уходит превью, оригинал остаётся на сервере;
|
||||||
|
потеря серверной копии распознаётся и показывается в интерфейсе.
|
||||||
|
- Статус синхронизации на карточке альбома, ход восстановления оригиналов,
|
||||||
|
сортировка списка, обновление списка альбомов в реальном времени.
|
||||||
|
- Установка на устройство одной командой: `make install-android-app` (с выбором
|
||||||
|
серийного номера при нескольких подключённых устройствах).
|
||||||
|
|
||||||
|
### Профиль и учётная запись
|
||||||
|
|
||||||
|
- Профиль пользователя: круглый аватар с загрузкой и кадрированием, публичная отдача
|
||||||
|
аватара по версионированной ссылке.
|
||||||
|
- Вход и регистрация по телефону (E.164) в дополнение к email, управление
|
||||||
|
идентификаторами, удаление аккаунта.
|
||||||
|
- Предпочтения пользователя (в том числе состав панели сведений) хранятся на сервере.
|
||||||
|
|
||||||
|
### Альбомы и живое обновление
|
||||||
|
|
||||||
|
- Обложки альбомов: отдельный жизненный цикл, обложка доводится до готовности и
|
||||||
|
доставляется подписчикам.
|
||||||
|
- Живое обновление: события жизненного цикла альбома и изменения состава фото через
|
||||||
|
SSE, подписчики видят изменения без перезагрузки.
|
||||||
|
- Квадратный вариант `medium` — резкие миниатюры в сетке; шаг переключения размера
|
||||||
|
миниатюр и направление ползунка приведены в порядок.
|
||||||
|
- Быстрая загрузка больших альбомов.
|
||||||
|
|
||||||
|
### Наблюдаемость и эксплуатация
|
||||||
|
|
||||||
|
- Настраиваемое логирование: `LOG_LEVEL` (`debug` включает access-логи и ожидаемые
|
||||||
|
4xx), `LOG_FORMAT` (`json`/`text`), request id в ответах и логах.
|
||||||
|
- `scripts/seed.py` и `make testdata` — наполнение запущенного инстанса тестовыми
|
||||||
|
данными через публичный API.
|
||||||
|
- Release-сборка: `make build-release` — статичный бинарник с вшитой версией и
|
||||||
|
UPX-сжатием, если он установлен.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исправления
|
||||||
|
|
||||||
|
- **Копия фото больше не наследует отредактированную версию.** Раньше копирование
|
||||||
|
переносило все привязки объектов, включая `edit*`: копия показывала
|
||||||
|
отредактированную картинку, но не наследовала параметры правок, и первая же новая
|
||||||
|
правка перезаписывала обработку с нуля. Теперь правка принадлежит своему фото, а
|
||||||
|
копия показывает исходник. Разовая миграция убирает привязки у копий, созданных до
|
||||||
|
фикса.
|
||||||
|
- Предпросмотр правок работает и без зума, а не только в увеличенном виде.
|
||||||
|
- Устранено искажение цветов в предпросмотре (WASM-фильтры считались по
|
||||||
|
преобразованным каналам).
|
||||||
|
- Гистограмма пересчитывается при правке, а не остаётся от исходного снимка.
|
||||||
|
- Линия тональной кривой видима в тёмной теме.
|
||||||
|
- Починен зум в просмотре: контейнер прокрутки и центрирование.
|
||||||
|
- Android: разгруженное превью больше не считается новой версией элемента.
|
||||||
|
- Сервер отдаёт контрольную сумму оригинала в ответе на загрузку — клиент может
|
||||||
|
сверить файл.
|
||||||
|
- Счётчик корзины и правки тегов синхронизируются с сеткой и просмотром.
|
||||||
|
- Ошибка загрузки крупного файла показывается явно, лимит поднят.
|
||||||
|
- `make run` пересобирает фронтенд, если он изменился.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические изменения
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Новые эндпоинты:
|
||||||
|
|
||||||
|
- `PATCH /api/photos/:id/edit` — сохранить параметры правок (тело: `{"light": {...}}`).
|
||||||
|
- `DELETE /api/photos/:id/edit` — сбросить правки вместе с отредактированной
|
||||||
|
версией.
|
||||||
|
- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`,
|
||||||
|
`POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences`,
|
||||||
|
`POST|DELETE /api/auth/me/avatar`, `GET /api/avatars/:userID/:version?size=large`.
|
||||||
|
- `POST /api/photo-batch/copy`, `POST /api/photo-batch/move` — пакетные операции над
|
||||||
|
снимками.
|
||||||
|
|
||||||
|
Изменившиеся ответы: у фото появились поля `edit_params`, `edit_url`,
|
||||||
|
`edit_thumb_url`, `edit_medium_url`, `edit_preview_url`; у видео — `playback_url`.
|
||||||
|
|
||||||
|
### Схема данных и миграции
|
||||||
|
|
||||||
|
- Postgres: миграции до `000016`; SQLite: до `000008` (нумерация диалектов
|
||||||
|
независимая, миграции вшиты в бинарник и применяются автоматически при старте;
|
||||||
|
`make migrate` применяет только Postgres).
|
||||||
|
- Новые роли объектов `edit`, `edit_thumb`, `edit_medium`, `edit_preview` — рядом с
|
||||||
|
обычными `original`, `thumb`, `medium`, `preview`, `playback`.
|
||||||
|
- Новая колонка `photos.edit_params` — параметры неразрушающих правок.
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
- Фильтры правок считает WASM-модуль из `frontend/wasm/light` (Rust, `no_std`).
|
||||||
|
Пересборка нужна только при правке самого модуля; в репозитории лежит готовый
|
||||||
|
`light.wasm`, поэтому фронтенд собирается без Rust-инструментов.
|
||||||
|
- `npm run wasm` — отдельная пересборка модуля; обычная сборка фронтенда обновляет
|
||||||
|
его сама.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как обновиться
|
||||||
|
|
||||||
|
1. Собрать бинарник: `make build` (фронтенд → `backend/static` → `go:embed` → `bin/galo`)
|
||||||
|
или `make build-release` — с вшитой версией.
|
||||||
|
2. Остановить сервис, заменить бинарник. Фронтенк вшит в бинарник, отдельно
|
||||||
|
выкладывать нечего.
|
||||||
|
3. Запустить: миграции применятся автоматически при старте для выбранного бэкенда.
|
||||||
|
Для Postgres можно отдельно прогнать `make migrate`.
|
||||||
|
4. Для видео нужен `ffmpeg`/`ffprobe` на хосте; без них приём видео отключается.
|
||||||
|
5. Android-клиент ставится отдельно: `make install-android-app`.
|
||||||
|
|
||||||
|
Откат: бинарник возвращается предыдущей версии; миграции `down` не восстанавливают
|
||||||
|
удалённые привязки копий — это безопасно, отредактированные версии исходных фото
|
||||||
|
остаются на месте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Редактирование — только для изображений; видео не редактируется.
|
||||||
|
- Правки не порождают отдельной записи в истории: параметры хранятся в метаданных
|
||||||
|
фото, отредактированная версия — как отдельный объект.
|
||||||
|
- Офлайн-кеш ограничен оболочкой, списками и эскизами/превью: оригиналы для зума и
|
||||||
|
скачивания требуют сети.
|
||||||
|
- Android-клиент требует JDK 17 и Android SDK (`platforms;android-35`,
|
||||||
|
`build-tools;35.0.0`).
|
||||||
|
- Номер версии берётся из `git describe --tags`; в репозитории пока нет тегов, поэтому
|
||||||
|
версия в сборке — короткий SHA или `dev`. Для осмысленной нумерации нужен тег.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Для разработчиков
|
||||||
|
|
||||||
|
- Спецификации OpenSpec синхронизированы с поведением: обновлены `ui-workspace`
|
||||||
|
(редактирование, кривая, зум и навигация), `media-objects` (роли `edit*`, раздельное
|
||||||
|
владение правками, отдельная очередь рендера), `photos` (рендер по оригиналу),
|
||||||
|
`dev-make-workflow` (сборка WASM-модуля).
|
||||||
|
- Изменение `fix-copy-edit-variant-sharing` реализовано (9/9 задач) и ждёт ревью и
|
||||||
|
архивации: `openspec list` показывает его как `complete`.
|
||||||
|
- Контрактные тесты репозиториев (`internal/repo/contract`) гоняют один набор
|
||||||
|
проверок на SQLite и Postgres — новый кейс `Clone` проверяет, что копия не
|
||||||
|
наследует отредактированную версию, в обоих хранилищах.
|
||||||
|
- Интеграционные тесты Postgres требуют `TEST_DATABASE_URL`; без них они
|
||||||
|
пропускаются. Тесты e2e поднимают SQLite в `t.TempDir()` и внешних сервисов не
|
||||||
|
требуют.
|
||||||
|
- Сервис переименован в `galo`, бренд — «гало»; сборка `make build-release` пишет
|
||||||
|
`bin/galo-$(VERSION)`.
|
||||||
Reference in New Issue
Block a user