Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d23e76ab5 | ||
|
|
2b8d329a3e | ||
|
|
1c29f5d961 |
@@ -0,0 +1,109 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// TestEditPendingLifecycle covers the render-state flag clients use to decide
|
||||
// whether the edit_* URLs can be shown: saving edits reports a pending render,
|
||||
// the worker clears it and publishes the fresh URLs, a second edit keeps the
|
||||
// previous render (still pending), and a reset clears both the flag and the
|
||||
// edited version.
|
||||
func TestEditPendingLifecycle(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, "editpending@test.local", "", "password123", "E")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||
AlbumID: album.ID, UserID: user.ID, Filename: "p.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)
|
||||
|
||||
first, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 0.5},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
if !first.EditPending {
|
||||
t.Fatalf("edit response does not report a pending render: %+v", first)
|
||||
}
|
||||
if first.EditPreviewURL != "" {
|
||||
t.Fatalf("first render already exposes edit_preview_url: %q", first.EditPreviewURL)
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
rendered, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get rendered photo: %v", err)
|
||||
}
|
||||
if rendered.EditPending {
|
||||
t.Fatalf("rendered photo still reports edit_pending: %+v", rendered)
|
||||
}
|
||||
if rendered.EditPreviewURL == "" {
|
||||
t.Fatalf("rendered photo has no edit_preview_url: %+v", rendered)
|
||||
}
|
||||
firstPreview := rendered.EditPreviewURL
|
||||
|
||||
// A second edit is queued while the previous render is still bound: the
|
||||
// photo stays pending and the old URLs must not be treated as current.
|
||||
second, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 1.25},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second edit: %v", err)
|
||||
}
|
||||
if !second.EditPending {
|
||||
t.Fatalf("second edit does not report a pending render: %+v", second)
|
||||
}
|
||||
if second.EditPreviewURL != firstPreview {
|
||||
t.Fatalf("second edit should keep the previous render while pending: %q want %q",
|
||||
second.EditPreviewURL, firstPreview)
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
after, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after second render: %v", err)
|
||||
}
|
||||
if after.EditPending {
|
||||
t.Fatalf("second render still reports edit_pending: %+v", after)
|
||||
}
|
||||
if after.EditPreviewURL == "" || after.EditPreviewURL == firstPreview {
|
||||
t.Fatalf("second render preview url = %q, want a fresh one (old %q)",
|
||||
after.EditPreviewURL, firstPreview)
|
||||
}
|
||||
|
||||
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
if reset.EditPending {
|
||||
t.Fatalf("reset photo reports edit_pending: %+v", reset)
|
||||
}
|
||||
if reset.EditPreviewURL != "" || reset.EditURL != "" {
|
||||
t.Fatalf("reset photo still exposes edit urls: %+v", reset)
|
||||
}
|
||||
if reset.EditParams != nil && len(reset.EditParams) > 0 {
|
||||
t.Fatalf("reset photo still exposes edit params: %+v", reset.EditParams)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@ type Photo struct {
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
EditParams map[string]any `json:"edit_params,omitempty"`
|
||||
// EditPending is true while the server still renders the saved edit
|
||||
// parameters, so the edit_* URLs do not describe the current edit yet.
|
||||
EditPending bool `json:"edit_pending"`
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
|
||||
@@ -41,7 +41,8 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
|
||||
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2
|
||||
ORDER BY lower(t.name)) t), '[]')`
|
||||
ORDER BY lower(t.name)) t), '[]'),
|
||||
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
|
||||
|
||||
const sqlitePhotoJoins = `
|
||||
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
||||
@@ -76,7 +77,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
@@ -311,7 +312,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &albumTitle, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending, &albumTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
|
||||
@@ -47,7 +47,8 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(p.edit_params, '{}'::jsonb)::text,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
|
||||
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text,
|
||||
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
|
||||
|
||||
const photoJoins = `
|
||||
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
||||
@@ -79,7 +80,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText)
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -228,7 +229,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
@@ -348,7 +349,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &p.AlbumTitle, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending, &p.AlbumTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
|
||||
@@ -48,6 +48,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"ShareLinks", testShareLinks},
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"EditPending", testEditPending},
|
||||
{"Objects", testObjects},
|
||||
{"Clone", testClone},
|
||||
{"UserAvatar", testUserAvatar},
|
||||
@@ -490,6 +491,11 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("cover not cleared: %+v", al.CoverPhotoID)
|
||||
}
|
||||
|
||||
// Trash rows are read with the same column list as the album list, so the
|
||||
// tags JSON and the album title must land in their own fields.
|
||||
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
|
||||
t.Fatalf("re-attach tag for trash: %v", err)
|
||||
}
|
||||
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
@@ -500,6 +506,12 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
if err != nil || len(trash) != 1 {
|
||||
t.Fatalf("trash: %v len=%d", err, len(trash))
|
||||
}
|
||||
if trash[0].AlbumTitle != a.Title {
|
||||
t.Fatalf("trash album title = %q, want %q", trash[0].AlbumTitle, a.Title)
|
||||
}
|
||||
if len(trash[0].Tags) != 1 || trash[0].Tags[0].Name != "Vacation" {
|
||||
t.Fatalf("trash tags = %+v", trash[0].Tags)
|
||||
}
|
||||
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
@@ -563,6 +575,56 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
// testEditPending verifies that reads expose whether an edit render is still
|
||||
// queued: clients rely on it to tell stale edit URLs from the current ones.
|
||||
func testEditPending(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
|
||||
getPending := func() bool {
|
||||
t.Helper()
|
||||
got, err := r.Photos.GetByID(ctx, p.ID, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get photo: %v", err)
|
||||
}
|
||||
return got.EditPending
|
||||
}
|
||||
listPending := func() bool {
|
||||
t.Helper()
|
||||
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Viewer: owner.ID, Limit: 10})
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
return list[0].EditPending
|
||||
}
|
||||
|
||||
if getPending() || listPending() {
|
||||
t.Fatalf("photo without an edit job reports a pending edit render")
|
||||
}
|
||||
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("enqueue edit job: %v", err)
|
||||
}
|
||||
if !getPending() || !listPending() {
|
||||
t.Fatalf("photo with a queued edit render does not report edit_pending")
|
||||
}
|
||||
if err := r.Photos.CompleteEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("complete edit job: %v", err)
|
||||
}
|
||||
if getPending() || listPending() {
|
||||
t.Fatalf("rendered photo still reports edit_pending")
|
||||
}
|
||||
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("enqueue again: %v", err)
|
||||
}
|
||||
if err := r.Photos.DeleteEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("delete edit job: %v", err)
|
||||
}
|
||||
if getPending() {
|
||||
t.Fatalf("photo reports edit_pending after the job was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
|
||||
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
|
||||
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params));
|
||||
// Правки сохранены, но отредактированная версия ещё рендерится.
|
||||
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
||||
// Правки сохранены, но сервер ещё рендерит отредактированную версию.
|
||||
const rendering = $derived(photo.edit_pending === true);
|
||||
|
||||
const customPresets = $derived(normalizeCurvePresets(auth.preferences.curve_presets));
|
||||
const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
isEmptyLight,
|
||||
photoFullUrl,
|
||||
photoPreviewUrl,
|
||||
readEditParams
|
||||
} from '$lib/edit';
|
||||
import { computeViewerSource, editPendingOf } from '$lib/viewer-source';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -35,6 +34,10 @@
|
||||
let { photos, index, onClose, onIndexChange }: Props = $props();
|
||||
|
||||
let fresh = $state<Photo | null>(null);
|
||||
// URL серверного рендера, который уже загружен и декодирован браузером.
|
||||
// Пока он не готов, показывается предпросмотр — иначе при подмене на
|
||||
// мгновение мелькает предыдущее изображение (в зуме — оригинал).
|
||||
let serverImageSrc = $state('');
|
||||
let touchX = 0;
|
||||
let previewLow = $state(false);
|
||||
let useGL = $state(false);
|
||||
@@ -57,7 +60,6 @@
|
||||
let isFullscreen = $state(false);
|
||||
let zoomed = $state(false);
|
||||
let scale = $state(1);
|
||||
let hiResLoaded = $state(false);
|
||||
|
||||
let panoFailed = $state(false);
|
||||
let panoRef: { reset: () => void } | undefined = $state();
|
||||
@@ -74,57 +76,76 @@
|
||||
let startL = 0;
|
||||
let startT = 0;
|
||||
|
||||
// Отредактированная версия фото показывается вместо исходной.
|
||||
const previewSrc = $derived(
|
||||
(fresh ? photoPreviewUrl(fresh) : undefined) ??
|
||||
(current ? photoPreviewUrl(current) : undefined) ??
|
||||
fresh?.original_url ??
|
||||
current?.original_url ??
|
||||
''
|
||||
);
|
||||
const fullSrc = $derived(
|
||||
(fresh ? photoFullUrl(fresh) : undefined) ??
|
||||
(current ? photoFullUrl(current) : undefined) ??
|
||||
''
|
||||
);
|
||||
const originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||||
// Базовые (неотредактированные) версии: на них браузер рисует предпросмотр.
|
||||
const basePreviewSrc = $derived(fresh?.preview_url ?? current?.preview_url ?? '');
|
||||
// Варианты фото. Актуальный объект — current: его обновляют SSE-события,
|
||||
// поэтому URL берутся из него, а fresh остаётся фолбэком для страниц без
|
||||
// SSE (просмотр по share-ссылке).
|
||||
const basePreviewSrc = $derived(current?.preview_url || fresh?.preview_url || '');
|
||||
const originalSrc = $derived(current?.original_url || fresh?.original_url || '');
|
||||
const editPreviewSrc = $derived(current?.edit_preview_url || fresh?.edit_preview_url || '');
|
||||
const editFullSrc = $derived(current?.edit_url || fresh?.edit_url || '');
|
||||
const previewSrc = $derived(editPreviewSrc || basePreviewSrc || originalSrc);
|
||||
const fullSrc = $derived(editFullSrc || originalSrc || '');
|
||||
// Сервер ещё рендерит сохранённые правки: edit-URL не описывают текущую
|
||||
// правку. Старый бэкенд поле не присылает — считаем рендер завершённым.
|
||||
const editPending = $derived(current ? editPendingOf(current) : editPendingOf(fresh));
|
||||
|
||||
// Пока сервер не отрендерил текущие правки, видимую область фото рисует
|
||||
// сам браузер: правки видны сразу, без ожидания рендера.
|
||||
const liveParams = $derived(
|
||||
edit.photoId === current?.id ? edit.params : readEditParams(current)
|
||||
);
|
||||
const hasEdits = $derived(!isEmptyEditParams(liveParams));
|
||||
const editUrl = $derived(fresh?.edit_url ?? current?.edit_url ?? '');
|
||||
const savedParams = $derived(readEditParams(current));
|
||||
const pending = $derived(
|
||||
edit.photoId === current?.id &&
|
||||
JSON.stringify(edit.params) !== JSON.stringify(savedParams)
|
||||
);
|
||||
const needClientRender = $derived(hasEdits && (pending || !editUrl));
|
||||
const clientPreview = $derived(needClientRender);
|
||||
const source = $derived(
|
||||
computeViewerSource({
|
||||
zoomed,
|
||||
dirty: pending,
|
||||
editPending,
|
||||
localParamsEmpty: isEmptyEditParams(liveParams),
|
||||
originalUrl: originalSrc,
|
||||
previewUrl: basePreviewSrc,
|
||||
editUrl: editFullSrc,
|
||||
editPreviewUrl: editPreviewSrc
|
||||
})
|
||||
);
|
||||
|
||||
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
||||
const clientSource = $derived(
|
||||
useGL
|
||||
? zoomed
|
||||
? originalSrc || basePreviewSrc
|
||||
: basePreviewSrc
|
||||
: previewLow
|
||||
? basePreviewSrc
|
||||
: zoomed
|
||||
? originalSrc || basePreviewSrc
|
||||
: basePreviewSrc
|
||||
);
|
||||
const displaySrc = $derived(
|
||||
clientPreview
|
||||
? clientSource || previewSrc
|
||||
: zoomed && hiResLoaded && fullSrc
|
||||
? fullSrc
|
||||
: previewSrc
|
||||
// Переход «предпросмотр → серверная версия». Canvas держится только на
|
||||
// время перехода, чтобы не мелькал оригинал; при простом открытии фото с
|
||||
// готовым рендером canvas не нужен — серверную версию грузит сам <img>.
|
||||
let previewSwapping = $state(false);
|
||||
let previousUseBase: boolean | null = null;
|
||||
|
||||
// Подменяем предпросмотр серверной версией только после загрузки и
|
||||
// декодирования кадра: пока браузер не готов его показать, остаётся canvas.
|
||||
$effect(() => {
|
||||
const useBase = source.useBase;
|
||||
if (previousUseBase === true && !useBase) previewSwapping = true;
|
||||
if (useBase) previewSwapping = false;
|
||||
previousUseBase = useBase;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (source.useBase || !previewSwapping) return;
|
||||
const url = source.serverSrc;
|
||||
if (!url || serverImageSrc === url) return;
|
||||
let alive = true;
|
||||
void decodeImage(url).then((ok) => {
|
||||
if (alive && ok) serverImageSrc = url;
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
const serverImageReady = $derived(
|
||||
!previewSwapping || (source.serverSrc !== '' && serverImageSrc === source.serverSrc)
|
||||
);
|
||||
const clientPreview = $derived(source.useBase || !serverImageReady);
|
||||
const canvasVisible = $derived(source.drawCanvas && clientPreview);
|
||||
const displaySrc = $derived(clientPreview ? source.baseSrc : source.serverSrc);
|
||||
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
||||
const pano = $derived(panoTag && !panoFailed);
|
||||
const panoSrc = $derived(fullSrc || previewSrc);
|
||||
@@ -134,6 +155,24 @@
|
||||
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? ''
|
||||
);
|
||||
|
||||
/** Загружает и декодирует изображение; false — загрузить не удалось. */
|
||||
async function decodeImage(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (typeof img.decode !== 'function') {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
img.decode()
|
||||
.then(() => resolve(true))
|
||||
.catch(() => resolve(true));
|
||||
};
|
||||
img.onerror = () => resolve(false);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
|
||||
viewer.open = true;
|
||||
@@ -145,14 +184,20 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Фолбэк для страниц без SSE: перечитываем фото при смене версии превью.
|
||||
// Ответы нумеруются, чтобы устаревший запрос не перебил более новый.
|
||||
let freshSeq = 0;
|
||||
$effect(() => {
|
||||
const id = current?.id;
|
||||
// Версия фото входит в зависимость: после рендера правок превью
|
||||
// меняется, и данные нужно перечитать.
|
||||
current?.edit_preview_url ?? current?.preview_url ?? '';
|
||||
if (!id) return;
|
||||
const seq = ++freshSeq;
|
||||
api<{ photo: Photo }>(`/photos/${id}`)
|
||||
.then((data) => (fresh = data.photo))
|
||||
.then((data) => {
|
||||
if (seq === freshSeq) fresh = data.photo;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -164,30 +209,14 @@
|
||||
if (id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
fresh = null;
|
||||
serverImageSrc = '';
|
||||
previewSwapping = false;
|
||||
previousUseBase = null;
|
||||
zoomed = false;
|
||||
scale = 1;
|
||||
hiResLoaded = false;
|
||||
panoFailed = false;
|
||||
});
|
||||
|
||||
// In zoom mode, preload the full-resolution original and swap it in once loaded.
|
||||
// Во время правок full-res не грузим: превью рисуется из base preview.
|
||||
$effect(() => {
|
||||
if (!zoomed || hiResLoaded || clientPreview) return;
|
||||
const url = fullSrc;
|
||||
if (!url) return;
|
||||
const im = new Image();
|
||||
let alive = true;
|
||||
im.onload = () => {
|
||||
if (alive) hiResLoaded = true;
|
||||
};
|
||||
im.src = url;
|
||||
return () => {
|
||||
alive = false;
|
||||
im.onload = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = scroller;
|
||||
if (!el) return;
|
||||
@@ -917,7 +946,7 @@
|
||||
onclick={onImageClick}
|
||||
></button>
|
||||
{/if}
|
||||
{#if clientPreview}
|
||||
{#if canvasVisible}
|
||||
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
||||
сервер готовит отредактированную версию фото. -->
|
||||
<canvas
|
||||
|
||||
@@ -79,6 +79,10 @@ export interface Photo {
|
||||
caption: string;
|
||||
exif?: Record<string, unknown>;
|
||||
edit_params?: Record<string, unknown>;
|
||||
// edit_pending is true while the server renders the saved edit parameters,
|
||||
// so the edit_* URLs do not describe the current edit yet. Older backends
|
||||
// omit the field (treated as false).
|
||||
edit_pending?: boolean;
|
||||
tags?: Tag[];
|
||||
deleted_at?: string | null;
|
||||
album_title?: string;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Photo } from './types';
|
||||
import { computeViewerSource, editPendingOf } from './viewer-source';
|
||||
|
||||
const BASE = {
|
||||
originalUrl: '/orig.jpg',
|
||||
previewUrl: '/preview.jpg',
|
||||
editUrl: '/edit.jpg',
|
||||
editPreviewUrl: '/edit-preview.jpg'
|
||||
};
|
||||
|
||||
describe('computeViewerSource', () => {
|
||||
it('shows the server render when it matches the saved edits', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: false,
|
||||
dirty: false,
|
||||
editPending: false,
|
||||
localParamsEmpty: false,
|
||||
...BASE
|
||||
});
|
||||
expect(src.useBase).toBe(false);
|
||||
expect(src.serverSrc).toBe('/edit-preview.jpg');
|
||||
expect(src.baseSrc).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('uses the full-size variants in zoom', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: true,
|
||||
dirty: false,
|
||||
editPending: false,
|
||||
localParamsEmpty: false,
|
||||
...BASE
|
||||
});
|
||||
expect(src.serverSrc).toBe('/edit.jpg');
|
||||
expect(src.baseSrc).toBe('/orig.jpg');
|
||||
});
|
||||
|
||||
it('keeps the client preview while local edits are unsaved', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: true,
|
||||
dirty: true,
|
||||
editPending: false,
|
||||
localParamsEmpty: false,
|
||||
...BASE
|
||||
});
|
||||
expect(src.useBase).toBe(true);
|
||||
expect(src.drawCanvas).toBe(true);
|
||||
expect(src.baseSrc).toBe('/orig.jpg');
|
||||
});
|
||||
|
||||
it('shows the base version immediately when the edits are reset to defaults', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: true,
|
||||
dirty: true,
|
||||
editPending: false,
|
||||
localParamsEmpty: true,
|
||||
...BASE
|
||||
});
|
||||
expect(src.useBase).toBe(true);
|
||||
expect(src.drawCanvas).toBe(false);
|
||||
expect(src.baseSrc).toBe('/orig.jpg');
|
||||
});
|
||||
|
||||
it('ignores a stale server render while the saved edits are still rendering', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: false,
|
||||
dirty: false,
|
||||
editPending: true,
|
||||
localParamsEmpty: false,
|
||||
...BASE
|
||||
});
|
||||
expect(src.useBase).toBe(true);
|
||||
expect(src.drawCanvas).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the client preview when a second edit is pending over an old render', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: true,
|
||||
dirty: false,
|
||||
editPending: true,
|
||||
localParamsEmpty: false,
|
||||
...BASE
|
||||
});
|
||||
expect(src.useBase).toBe(true);
|
||||
expect(src.drawCanvas).toBe(true);
|
||||
expect(src.baseSrc).toBe('/orig.jpg');
|
||||
});
|
||||
|
||||
it('falls back to the base version when there is no edit render', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: false,
|
||||
dirty: false,
|
||||
editPending: false,
|
||||
localParamsEmpty: true,
|
||||
originalUrl: '/orig.jpg',
|
||||
previewUrl: '/preview.jpg'
|
||||
});
|
||||
expect(src.useBase).toBe(true);
|
||||
expect(src.drawCanvas).toBe(false);
|
||||
expect(src.serverSrc).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('prefers the full-size base when zoomed without a preview', () => {
|
||||
const src = computeViewerSource({
|
||||
zoomed: true,
|
||||
dirty: false,
|
||||
editPending: false,
|
||||
localParamsEmpty: true,
|
||||
originalUrl: '/orig.jpg'
|
||||
});
|
||||
expect(src.baseSrc).toBe('/orig.jpg');
|
||||
expect(src.serverSrc).toBe('/orig.jpg');
|
||||
});
|
||||
|
||||
it('treats the old backend without edit_pending as a finished render', () => {
|
||||
expect(editPendingOf(undefined)).toBe(false);
|
||||
expect(editPendingOf({ id: 'p' } as Photo)).toBe(false);
|
||||
expect(editPendingOf({ id: 'p', edit_pending: true } as Photo)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Photo } from './types';
|
||||
|
||||
/**
|
||||
* Выбор изображения для просмотрщика. Модуль намеренно не использует руны
|
||||
* Svelte: правила подмены предпросмотра серверной версией можно проверить
|
||||
* обычными unit-тестами.
|
||||
*
|
||||
* Состояния:
|
||||
* - локальные правки ещё не сохранены (`dirty`) или сервер ещё рендерит
|
||||
* сохранённые правки (`editPending`) — показывать нельзя серверный рендер,
|
||||
* он не соответствует параметрам; показываем базовую версию, поверх неё
|
||||
* рисуется клиентский предпросмотр;
|
||||
* - локальные правки пусты (сброс возвратом контролов) — сразу базовую версию
|
||||
* без предпросмотра, не дожидаясь отложенного сохранения;
|
||||
* - серверный рендер соответствует сохранённым правкам — показываем его, но
|
||||
* только после загрузки и декодирования (решает компонент).
|
||||
*/
|
||||
export interface ViewerSourceInput {
|
||||
/** Пользователь увеличил фото: показываем полноразмерную версию. */
|
||||
zoomed: boolean;
|
||||
/** Локальные параметры отличаются от сохранённых на сервере. */
|
||||
dirty: boolean;
|
||||
/** Сервер ещё рендерит сохранённые правки (Photo.edit_pending). */
|
||||
editPending: boolean;
|
||||
/** Локальные параметры правок пусты (значения по умолчанию). */
|
||||
localParamsEmpty: boolean;
|
||||
originalUrl?: string;
|
||||
previewUrl?: string;
|
||||
editUrl?: string;
|
||||
editPreviewUrl?: string;
|
||||
}
|
||||
|
||||
export interface ViewerSource {
|
||||
/** Показывать базовую (неотредактированную) версию вместо серверного рендера. */
|
||||
useBase: boolean;
|
||||
/** Рисовать клиентский предпросмотр поверх базовой версии. */
|
||||
drawCanvas: boolean;
|
||||
/** URL базовой версии — подложка под предпросмотр. */
|
||||
baseSrc: string;
|
||||
/** URL серверной версии, который можно показать после загрузки. */
|
||||
serverSrc: string;
|
||||
}
|
||||
|
||||
/** Первый непустой URL из списка. */
|
||||
function pick(urls: (string | undefined)[]): string {
|
||||
for (const url of urls) {
|
||||
if (url) return url;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Признак незавершённого рендера правок, устойчивый к старому бэкенду: поле
|
||||
* `edit_pending` может отсутствовать, тогда рендер считается завершённым.
|
||||
*/
|
||||
export function editPendingOf(photo: Photo | null | undefined): boolean {
|
||||
return photo?.edit_pending === true;
|
||||
}
|
||||
|
||||
/** Вычисляет источник изображения для просмотрщика. */
|
||||
export function computeViewerSource(input: ViewerSourceInput): ViewerSource {
|
||||
const baseSrc = input.zoomed
|
||||
? pick([input.originalUrl, input.previewUrl])
|
||||
: pick([input.previewUrl, input.originalUrl]);
|
||||
const serverSrc = input.zoomed
|
||||
? pick([input.editUrl, input.editPreviewUrl])
|
||||
: pick([input.editPreviewUrl, input.editUrl]);
|
||||
const useBase = input.dirty || input.editPending || !serverSrc;
|
||||
return {
|
||||
useBase,
|
||||
// Пустые локальные правки — это исходный вид: предпросмотр не нужен,
|
||||
// базовая версия уже показывает оригинал.
|
||||
drawCanvas: !input.localParamsEmpty,
|
||||
baseSrc,
|
||||
serverSrc: serverSrc || baseSrc
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-25
|
||||
@@ -0,0 +1,151 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
Просмотрщик уже рисует клиентский предпросмотр правок: canvas с отфильтрованной
|
||||
видимой областью лежит поверх базовой версии фото (`original_url` в зуме,
|
||||
`preview_url` иначе). Решение «показывать ли серверную версию» сейчас принимается
|
||||
по флагам `hasEdits`, `pending` (локальные параметры ≠ сохранённым) и `!editUrl`
|
||||
([Viewer.svelte](frontend/src/lib/components/Viewer.svelte)) — то есть по наличию
|
||||
URL, а не по состоянию рендера.
|
||||
|
||||
Сохранение отложено на 10 секунд ([PhotoEditPanel.svelte](frontend/src/lib/components/PhotoEditPanel.svelte)):
|
||||
`PATCH /photos/:id/edit` ставит задание в `edit_jobs` и публикует `photo.updated`,
|
||||
затем воркер публикует `photo.updated` после вариантов превью и ещё раз после
|
||||
полного варианта. Таблица `edit_jobs` уже существует (`photo_id` — первичный
|
||||
ключ), миграции не нужны. Обогащение URL — `attachURLs`, через него проходят все
|
||||
выдачи фото, включая события SSE. У Viewer есть вторая копия фото `fresh`,
|
||||
перезапрашиваемая по REST, и её URL имеют приоритет над `current`, хотя актуальные
|
||||
приходят по SSE.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- API даёт однозначный признак «рендер сохранённых правок не завершён».
|
||||
- Просмотрщик выбирает источник изображения по состоянию, а не по наличию URL.
|
||||
- Подмена предпросмотра серверной версией происходит без показа оригинала или
|
||||
предыдущей отрендеренной версии.
|
||||
- Сброс правок виден сразу, в том числе при возврате контролов в значения по
|
||||
умолчанию.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Менять 10-секундный дебаунс сохранения и схему БД.
|
||||
- Новые состояния рендера (ошибки, приоритеты, отмена), статус ошибки рендера.
|
||||
- Кроссфейд/анимация подмены.
|
||||
- Изменения Android-клиента и синхронизация правок между устройствами сверх SSE.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. `edit_pending` — булев признак из наличия задания в `edit_jobs`, считается в SQL
|
||||
|
||||
- В `models.Photo` добавляется `EditPending bool` с JSON-тегом `edit_pending`.
|
||||
Поле присутствует всегда; его отсутствие означает старый бэкенд и трактуется
|
||||
клиентом как `false`.
|
||||
- В общие списки колонок фото (`photoColumns` в Postgres, `sqlitePhotoColumns` в
|
||||
SQLite) добавляется `EXISTS (SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`;
|
||||
сканирование поля добавляется в функции чтения фото (включая чтение корзины).
|
||||
В корзине попутно исправляется порядок чтения: tags JSON и `album_title` были
|
||||
перепутаны (проверяется регрессионным тестом в контракте).
|
||||
- Обоснование: ответы сохранения/сброса и оба события `photo.updated` собираются
|
||||
через `GetByID`, списки альбома — через `List`; один SELECT даёт признак во всех
|
||||
путях, без дополнительных запросов и без изменений в сервисном слое.
|
||||
- Альтернативы:
|
||||
- отдельный метод репозитория `PendingEditIDs` + обогащение в сервисе — больше
|
||||
точек изменения и N+1 для списков; отвергнуто;
|
||||
- хранить ревизию или хэш отрендеренных параметров (миграция и сравнение на
|
||||
клиенте) — точнее переживает неудачный рендер, но дороже; выносится в отдельное
|
||||
изменение, если понадобится;
|
||||
- клиентская эвристика «URL изменился с момента сохранения» — хрупкая (сброс,
|
||||
повторный рендер, несколько вкладок); отвергнуто.
|
||||
- Семантика: `true`, пока строка задания есть (ожидает, выполняется, повтор);
|
||||
`false` после `CompleteEditJob`/`DeleteEditJob`, включая сброс. Поиск идёт по
|
||||
первичному ключу `edit_jobs.photo_id`.
|
||||
- Совместимость: поле аддитивное, миграций нет; старые клиенты его игнорируют.
|
||||
|
||||
### D2. Клиентская машина состояний просмотра
|
||||
|
||||
| `dirty` (локальные ≠ сохранённым) | `edit_pending` | Что показывает просмотрщик |
|
||||
|---|---|---|
|
||||
| true | не важно | базовая версия + canvas с локальными правками; canvas не рисуется, если локальные правки пусты (сброс) |
|
||||
| false | true | базовая версия + canvas с сохранёнными правками |
|
||||
| false | false | серверная версия; canvas держится до загрузки/декодирования нового изображения |
|
||||
|
||||
- «Базовая версия» — `original_url` в зуме, `preview_url` иначе (без edit-ролей).
|
||||
- «Серверная версия» — `edit_url` в зуме, `edit_preview_url` иначе; если
|
||||
edit-вариантов нет, показывается базовая версия.
|
||||
- `edit_pending` отсутствует в ответе (старый бэкенд) — считается `false`
|
||||
(поведение как раньше, без падений).
|
||||
- Когда `dirty` и локальные правки пусты (сброс возвратом контролов) — сразу
|
||||
базовая версия, без canvas и без опоры на серверные edit-URL. Это делает сброс
|
||||
мгновенным.
|
||||
- Обоснование: все три наблюдаемых симптома — следствие того, что показ серверной
|
||||
версии включался по `!editUrl` и `hasEdits`, а не по «серверный рендер
|
||||
соответствует сохранённым параметрам и картинка готова к показу».
|
||||
|
||||
### D3. Подмена — только после загрузки и декодирования
|
||||
|
||||
- При переходе в состояние «серверная версия» клиент предзагружает целевой URL
|
||||
(скрытый `Image`) и ждёт `decode()` (фолбэк — `load`); только затем снимает
|
||||
canvas и меняет `src`. До этого canvas остаётся, поэтому оригинал не мелькает.
|
||||
- Canvas поднимается только на время перехода «предпросмотр → серверная версия»:
|
||||
при обычном открытии фото с уже готовым рендером серверную версию грузит сам
|
||||
`<img>`, лишний кадр оригинала не показывается.
|
||||
- Устаревшие предзагрузки отменяются по счётчику/`AbortSignal`, чтобы поздний
|
||||
ответ не вернул предыдущую версию.
|
||||
- Цена — небольшая задержка финального кадра (обычно сотни мс), зато без мигания.
|
||||
- Кроссфейд отвергнут: спекой не требуется, лишняя косметика.
|
||||
|
||||
### D4. Один актуальный источник фото в Viewer
|
||||
|
||||
- `current` (обновляется SSE `photo.updated`) — основной источник URL и состояния.
|
||||
- `fresh` остаётся только фолбэком для страниц без SSE (просмотр по share-ссылке)
|
||||
и первичной загрузки; при выборе URL приоритет у актуального объекта, а refetch
|
||||
защищён счётчиком последовательности, чтобы устаревший ответ не перебил новый.
|
||||
- Обоснование: `photoFullUrl(fresh)` всегда возвращает хотя бы `original_url`,
|
||||
поэтому протухший `fresh` показывает именно оригинал.
|
||||
|
||||
### D5. Сброс: показ — мгновенно, отправка — по текущей политике дебаунса
|
||||
|
||||
- Дебаунс сохранения не меняется: показ исходного вида обеспечивает D2
|
||||
(`dirty` + пустые параметры → базовая версия), а отложенный `flushSave`/`resetEdit`
|
||||
лишь приводит сервер в соответствие. При уходе из панели `flushSave` уже
|
||||
отправляет сброс.
|
||||
- Альтернатива — отправлять сброс сразу, без дебаунса — уменьшает расхождение между
|
||||
вкладками, но добавляет отдельную политику сохранения и `DELETE` на каждое
|
||||
обнуление контролов; отвергнуто.
|
||||
- Следствие: другие вкладки и устройства увидят сброс после дебаунса — как и любое
|
||||
отложенное сохранение; это осознанный компромисс.
|
||||
|
||||
### D6. Индикатор рендера в панели редактора
|
||||
|
||||
- `rendering` в панели считается как `photo.edit_pending`, а не как
|
||||
`hasEdits && !photo.edit_preview_url`: так индикатор корректен и для повторной
|
||||
правки, и после сброса.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Рендер падает после всех попыток** → задание удаляется, `edit_pending=false`,
|
||||
остаётся последняя успешная отрендеренная версия, клиент покажет её. Поведение не
|
||||
хуже текущего; отдельный статус ошибки рендера — вне этого изменения.
|
||||
- **Смешанные версии бэкенда и фронтенда** → аддитивное поле: новый фронтенд со
|
||||
старым бэкендом трактует отсутствие как `false` и работает как раньше; новый
|
||||
бэкенд со старым фронтендом безвреден. Деплой в любом порядке, рекомендуется
|
||||
сначала бэкенд.
|
||||
- **Дополнительная колонка в горячих SELECT фото** → `EXISTS` по первичному ключу,
|
||||
стоимость пренебрежимо мала; при необходимости позже выносится в отдельное
|
||||
обогащение.
|
||||
- **Порядок SSE-событий** → сохранение публикует `photo.updated` с `edit_pending=true`,
|
||||
готовность — с `false`; клиент зависит только от значения, не от числа событий.
|
||||
Гонка «воркер завершил рендер раньше reload в publishEdit» даёт `false` и новые
|
||||
URL — консистентно.
|
||||
- **Большие full-res файлы** → финальный кадр появится позже, пока canvas с
|
||||
предпросмотром продолжает показываться; это ожидаемое поведение.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Миграций БД нет. Бэкенд и фронтенд разворачиваются независимо (аддитивное поле);
|
||||
рекомендация — сначала бэкенд.
|
||||
- Откат: достаточно вернуть прежний Viewer/тип (поле бэкенда безвредно); откат
|
||||
бэкенда не ломает старый фронтенд.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Клиент узнаёт о готовности серверного рендера правок только по появлению
|
||||
`edit_url`/`edit_preview_url` в объекте фото, а признака «рендер сохранённых правок
|
||||
ещё идёт» в API нет. Из-за этого в просмотрщике:
|
||||
|
||||
- после отложенного сохранения (дебаунс 10 с) при появлении полного рендера
|
||||
клиентский предпросмотр выключается раньше, чем новое изображение загружено, и
|
||||
в зуме на время загрузки виден оригинал (мигание «оригинал → правка»);
|
||||
- повторная правка, у которой уже есть предыдущий рендер, после сохранения
|
||||
показывает старую отрендеренную версию, пока не завершится новый рендер;
|
||||
- сброс, сделанный возвратом контролов в значения по умолчанию, не виден до
|
||||
срабатывания дебаунса: локальные параметры уже пусты, предпросмотр выключен, и
|
||||
просмотрщик показывает старую серверную версию — «сброс срабатывает через 10 с».
|
||||
|
||||
## What Changes
|
||||
|
||||
- Фото получает аддитивный признак незавершённого рендера правок — булево поле
|
||||
`edit_pending` в ответах API и в событиях `photo.updated`: `true`, пока в
|
||||
`edit_jobs` есть задание на рендер; `false`, когда рендера нет или он завершён.
|
||||
- Клиент перестаёт угадывать готовность по наличию URL и опирается на признак:
|
||||
пока есть несохранённые локальные правки или серверный рендер не завершён,
|
||||
просмотрщик показывает клиентский предпросмотр поверх исходной версии.
|
||||
- Замена предпросмотра серверной версией выполняется только после загрузки и
|
||||
декодирования нового изображения — без промежуточного показа оригинала или
|
||||
предыдущей отрендеренной версии.
|
||||
- Сброс правок (кнопкой и возвратом контролов в значения по умолчанию) виден в
|
||||
просмотрщике сразу, не дожидаясь отложенного сохранения.
|
||||
- Viewer перестаёт предпочитать устаревшую локальную копию фото: URL для показа
|
||||
берутся из актуального объекта, обновляемого SSE-событиями.
|
||||
- Индикатор «Рендерим правки на сервере…» в панели редактора опирается на
|
||||
`edit_pending`, а не на отсутствие URL.
|
||||
- Без изменений: отложенное сохранение (10 с), неразрушающий рендер, роли
|
||||
объектов, формат `edit_params`, схема БД, Android-клиент (поле аддитивное,
|
||||
отсутствие трактуется как `false`).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет — изменение уточняет существующие способности.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `photos`: новый признак незавершённого рендера правок; уточняются «Живой
|
||||
предпросмотр цветовых правок», «Живой предпросмотр эффектов» (бесшовная
|
||||
подмена) и добавляется требование о мгновенном показе сброса.
|
||||
- `realtime`: `photo.updated` отражает жизненный цикл рендера правок — сохранение
|
||||
(`edit_pending=true`), готовность (`edit_pending=false` + URL отредактированных
|
||||
вариантов), сброс (без URL отредактированных вариантов).
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/models/models.go` — поле `EditPending` в `Photo`.
|
||||
- `backend/internal/photos/store.go`, `sqlite_store.go` — `EXISTS` по `edit_jobs`
|
||||
в общем списке колонок; сканирование поля. Миграции не нужны: таблица уже есть.
|
||||
- `backend/internal/photos/edit_service.go`, `worker.go`, `handlers.go` — признак
|
||||
попадает в ответы сохранения/сброса и в события (через общие запросы фото).
|
||||
- `frontend/src/lib/types.ts` — поле в типе `Photo`.
|
||||
- `frontend/src/lib/components/Viewer.svelte` — выбор источника изображения по
|
||||
состоянию (локальные правки / `edit_pending` / загруженность нового кадра),
|
||||
предзагрузка и декодирование, единый актуальный источник URL.
|
||||
- `frontend/src/lib/components/PhotoEditPanel.svelte` — статус рендера.
|
||||
- Новый чистый модуль выбора источника предпросмотра + vitest.
|
||||
- Тесты: контрактные (репозитории, SQLite и Postgres), e2e рендера правок,
|
||||
frontend-модульные тесты, проверка `svelte-check`.
|
||||
- API: аддитивное поле `edit_pending`; старые клиенты (Android) его игнорируют.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Spec Delta: photos
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Признак незавершённого рендера правок
|
||||
|
||||
Фото в ответах API и в событиях `photo.updated` SHALL содержать булево поле `edit_pending`. Оно SHALL быть `true`, пока серверный рендер сохранённых правок не завершён, и `false`, когда рендера нет или он завершён. Пока `edit_pending` равно `true`, URL отредактированных вариантов SHALL NOT считаться актуальными, даже если они присутствуют. Поле SHALL быть аддитивным: клиент, который его не читает, продолжает работать как раньше.
|
||||
|
||||
#### Scenario: Сохранение правок
|
||||
|
||||
- **WHEN** правки фото сохранены, а серверный рендер ещё не завершён
|
||||
- **THEN** ответ на сохранение и событие `photo.updated` содержат `edit_pending=true`
|
||||
|
||||
#### Scenario: Предыдущий рендер устарел
|
||||
|
||||
- **WHEN** у фото уже есть отрендеренная версия, а для новых сохранённых правок рендер ещё не завершён
|
||||
- **THEN** фото содержит `edit_pending=true`, а URL отредактированных вариантов не выдаются за актуальные
|
||||
|
||||
#### Scenario: Готовность рендера
|
||||
|
||||
- **WHEN** серверный рендер сохранённых правок завершён
|
||||
- **THEN** `edit_pending=false`, а URL отредактированных вариантов соответствуют сохранённым правкам
|
||||
|
||||
#### Scenario: Сброс правок
|
||||
|
||||
- **WHEN** правки фото сброшены
|
||||
- **THEN** `edit_pending=false` и URL отредактированных вариантов отсутствуют
|
||||
|
||||
### Requirement: Мгновенный показ сброса правок
|
||||
|
||||
Клиент SHALL показывать исходный вид фото сразу после возврата контролов редактора в значения по умолчанию или нажатия сброса, не дожидаясь отложенного сохранения на сервер. Пока сброс, выполненный возвратом контролов, ещё не отправлен, просмотрщик SHALL NOT показывать отредактированную версию фото.
|
||||
|
||||
#### Scenario: Сброс возвратом контролов
|
||||
|
||||
- **WHEN** пользователь возвращает контролы редактора в значения по умолчанию (двойной клик по ползунку, шкала в нуле)
|
||||
- **THEN** просмотрщик сразу показывает исходный вид, не дожидаясь срабатывания отложенного сохранения
|
||||
|
||||
#### Scenario: Сброс кнопкой
|
||||
|
||||
- **WHEN** пользователь нажимает «Сброс»
|
||||
- **THEN** просмотрщик показывает исходный вид не позже ответа на запрос сброса
|
||||
|
||||
#### Scenario: Сброс при готовом рендере
|
||||
|
||||
- **WHEN** сброс выполнен, а отредактированная версия ещё существует на сервере
|
||||
- **THEN** просмотрщик показывает исходный вид, а не отредактированную версию
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Живой предпросмотр цветовых правок
|
||||
|
||||
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или пока локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После завершения рендера SHALL показываться серверная версия. Клиент SHALL NOT показывать исходную или предыдущую отрендеренную версию между предпросмотром и серверной версией: подмена SHALL выполняться только после загрузки нового изображения.
|
||||
|
||||
#### Scenario: Правки видны сразу
|
||||
|
||||
- **WHEN** пользователь двигает цветовой контрол
|
||||
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
|
||||
|
||||
#### Scenario: Серверная версия заменяет предпросмотр
|
||||
|
||||
- **WHEN** серверный рендер цветовых правок завершён
|
||||
- **THEN** просмотрщик показывает отрендеренную версию
|
||||
|
||||
#### Scenario: Бесшовная подмена предпросмотра
|
||||
|
||||
- **WHEN** серверный рендер завершён, но новое изображение ещё загружается
|
||||
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
|
||||
|
||||
### Requirement: Живой предпросмотр эффектов
|
||||
|
||||
Клиент SHALL показывать эффекты в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или пока локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После готовности серверной версии клиент SHALL показывать её после загрузки нового изображения, не показывая в промежутке оригинал или предыдущую отрендеренную версию. Предпросмотр пространственных эффектов (текстура, чёткость, дымка) MAY отличаться от серверного рендера деталями, но SHALL передавать характер эффекта.
|
||||
|
||||
#### Scenario: Эффекты видны сразу
|
||||
|
||||
- **WHEN** пользователь двигает ползунок эффекта
|
||||
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
|
||||
|
||||
#### Scenario: Серверная версия заменяет предпросмотр
|
||||
|
||||
- **WHEN** серверный рендер эффектов завершён
|
||||
- **THEN** просмотрщик показывает серверную версию
|
||||
|
||||
#### Scenario: Бесшовная подмена предпросмотра
|
||||
|
||||
- **WHEN** серверный рендер эффектов завершён, но новое изображение ещё загружается
|
||||
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
|
||||
|
||||
#### Scenario: Предпросмотр без WebGL
|
||||
|
||||
- **WHEN** аппаратное ускорение недоступно
|
||||
- **THEN** клиент показывает эффекты доступным способом или показывает их после серверного рендера, не блокируя интерфейс
|
||||
@@ -0,0 +1,38 @@
|
||||
# Spec Delta: realtime
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: События альбома
|
||||
|
||||
Система SHALL публиковать события photo.created, photo.updated, photo.deleted,
|
||||
comment.created, like.added, like.removed, member.joined, member.left, album.updated.
|
||||
Событие photo.created SHALL обозначать появление фото в альбоме, а готовность вариантов
|
||||
существующего фото SHALL публиковаться как photo.updated. Сохранение, готовность и сброс
|
||||
правок фото SHALL также публиковаться как photo.updated, неся аддитивный признак
|
||||
незавершённого рендера правок (`edit_pending`) и актуальность URL отредактированных
|
||||
вариантов.
|
||||
|
||||
#### Scenario: Новое фото
|
||||
|
||||
- **WHEN** в альбоме появляется фото
|
||||
- **THEN** подписчики получают событие photo.created с метаданными и URL превью
|
||||
|
||||
#### Scenario: Готовы варианты
|
||||
|
||||
- **WHEN** для существующего фото сгенерированы thumb и preview
|
||||
- **THEN** подписчики получают событие photo.updated с этим фото
|
||||
|
||||
#### Scenario: Сохранены правки фото
|
||||
|
||||
- **WHEN** пользователь сохраняет правки фото, а рендер ещё не завершён
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=true`
|
||||
|
||||
#### Scenario: Рендер правок готов
|
||||
|
||||
- **WHEN** серверный рендер сохранённых правок завершён
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и URL отредактированных вариантов
|
||||
|
||||
#### Scenario: Правки фото сброшены
|
||||
|
||||
- **WHEN** правки фото сброшены
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и без URL отредактированных вариантов
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Бэкенд: признак `edit_pending` в модели и запросах
|
||||
|
||||
- [x] 1.1 Добавить `EditPending bool` в `models.Photo` (JSON-тег `edit_pending`, поле присутствует всегда) и `EXISTS (SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)` в общие списки колонок фото для Postgres (`photoColumns`) и SQLite (`sqlitePhotoColumns`); обновить сканирование поля в функциях чтения фото и корзины. Проверка: `cd backend && go build -tags nodynamic ./...` и `go test ./internal/photos/` проходят.
|
||||
- [x] 1.2 Покрыть контрактными тестами оба диалекта: после `EnqueueEditJob` фото из `GetByID` и `List` имеет `EditPending=true`, после `CompleteEditJob` и `DeleteEditJob` — `false`. Проверка: `cd backend && go test ./internal/repo/ -v`; с `TEST_DATABASE_URL` тот же набор проходит и на Postgres.
|
||||
- [x] 1.3 Добавить e2e-проверку жизненного цикла: `EditPhoto` возвращает `edit_pending=true` (в том числе при повторной правке, когда старый рендер ещё привязан), `Worker.ProcessOnce` — `false` вместе с URL отредактированных вариантов, `ResetEdit` — `false` без URL. Проверка: `cd backend && go test ./internal/e2e/ -run 'Edit' -v`.
|
||||
|
||||
## 2. Фронтенд: выбор источника изображения по состоянию
|
||||
|
||||
- [x] 2.1 Добавить `edit_pending?: boolean` в тип `Photo` (`frontend/src/lib/types.ts`). Проверка: `cd frontend && npm run check`.
|
||||
- [x] 2.2 Вынести выбор источника в чистый модуль (`frontend/src/lib/viewer-source.ts`): вход — локальные и сохранённые параметры, `edit_pending`, режим зума, URL вариантов; выход — показывать базовую или серверную версию, нужен ли canvas, какой URL грузить. Покрыть сценариями спеки: локальные правки, пустые локальные правки (сброс), `edit_pending=true` при старом рендере, готовый рендер, отсутствие поля (старый бэкенд = `false`). Проверка: `cd frontend && npm run test -- viewer-source`.
|
||||
- [x] 2.3 Подключить модуль в `Viewer.svelte`: canvas держится до предзагрузки и `decode()` нового изображения (устаревшие предзагрузки отменяются), показ строится от актуального `current`, а `fresh` становится фолбэком с защитой от устаревших ответов. Проверка: `cd frontend && npm run check && npm run test`.
|
||||
- [x] 2.4 Перевести индикатор «Рендерим правки на сервере…» в `PhotoEditPanel.svelte` на `photo.edit_pending` вместо `hasEdits && !photo.edit_preview_url`. Проверка: `cd frontend && npm run check`.
|
||||
|
||||
## 3. Интеграционная проверка
|
||||
|
||||
- [x] 3.1 Сквозной сценарий на собранном приложении (`make build`): правка с зумом не показывает оригинал между предпросмотром и серверной версией; сброс возвратом ползунков в значения по умолчанию сразу показывает исходный вид, не дожидаясь 10 секунд. Проверка: автопрогон в chromium 156 (headless + CDP) на собранном бинарнике: 189 замеров состояния — 0 нарушений инварианта «без canvas только серверная версия», 34 кадра подмены — оригинала нет ни на одном, после сброса через 80 мс показан оригинал (canvas снят, temp=0), серверный сброс доехал после debounce. Результат зафиксирован в отчёте об изменении.
|
||||
- [x] 3.2 Полный набор проверок: `make test`, `cd frontend && npm run test && npm run check`, `openspec validate fix-edit-preview-handoff --strict`. Проверка: все команды завершаются успешно.
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Заархивировать изменение после проверки и обновления основного отчёта об изменениях.
|
||||
@@ -329,6 +329,25 @@ SHALL быть доступно и для фото без альбома (нап
|
||||
- **WHEN** во всех эффектах заданы только значения по умолчанию (сила эффекта, виньетки и зерна равна нулю)
|
||||
- **THEN** правки считаются пустыми и фото не получает новую отредактированную версию
|
||||
|
||||
### Requirement: Признак незавершённого рендера правок
|
||||
Фото в ответах API и в событиях `photo.updated` SHALL содержать булево поле `edit_pending`. Оно SHALL быть `true`, пока серверный рендер сохранённых правок не завершён, и `false`, когда рендера нет или он завершён. Пока `edit_pending` равно `true`, URL отредактированных вариантов SHALL NOT считаться актуальными, даже если они присутствуют. Поле SHALL быть аддитивным: клиент, который его не читает, продолжает работать как раньше.
|
||||
|
||||
#### Scenario: Сохранение правок
|
||||
- **WHEN** правки фото сохранены, а серверный рендер ещё не завершён
|
||||
- **THEN** ответ на сохранение и событие `photo.updated` содержат `edit_pending=true`
|
||||
|
||||
#### Scenario: Предыдущий рендер устарел
|
||||
- **WHEN** у фото уже есть отрендеренная версия, а для новых сохранённых правок рендер ещё не завершён
|
||||
- **THEN** фото содержит `edit_pending=true`, а URL отредактированных вариантов не выдаются за актуальные
|
||||
|
||||
#### Scenario: Готовность рендера
|
||||
- **WHEN** серверный рендер сохранённых правок завершён
|
||||
- **THEN** `edit_pending=false`, а URL отредактированных вариантов соответствуют сохранённым правкам
|
||||
|
||||
#### Scenario: Сброс правок
|
||||
- **WHEN** правки фото сброшены
|
||||
- **THEN** `edit_pending=false` и URL отредактированных вариантов отсутствуют
|
||||
|
||||
### Requirement: Пипетка баланса белого
|
||||
Клиент SHALL позволять выбрать нейтральную точку на фото и SHALL устанавливать температуру и оттенок так, чтобы цвет этой точки стал нейтральным. Выборка SHALL браться из исходного (неотредактированного) превью, а пресет баланса белого SHALL переходить в «Пользовательский».
|
||||
|
||||
@@ -341,7 +360,7 @@ SHALL быть доступно и для фото без альбома (нап
|
||||
- **THEN** пипетка недоступна
|
||||
|
||||
### Requirement: Живой предпросмотр цветовых правок
|
||||
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. После завершения рендера SHALL показываться серверная версия.
|
||||
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или пока локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После завершения рендера SHALL показываться серверная версия.
|
||||
|
||||
#### Scenario: Правки видны сразу
|
||||
- **WHEN** пользователь двигает цветовой контрол
|
||||
@@ -351,6 +370,10 @@ SHALL быть доступно и для фото без альбома (нап
|
||||
- **WHEN** серверный рендер цветовых правок завершён
|
||||
- **THEN** просмотрщик показывает отрендеренную версию
|
||||
|
||||
#### Scenario: Бесшовная подмена предпросмотра
|
||||
- **WHEN** серверный рендер завершён, но новое изображение ещё загружается
|
||||
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
|
||||
|
||||
### Requirement: Тоновая кривая
|
||||
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
|
||||
|
||||
@@ -516,7 +539,7 @@ SHALL быть доступно и для фото без альбома (нап
|
||||
- **THEN** зерно не влияет на изображение
|
||||
|
||||
### Requirement: Живой предпросмотр эффектов
|
||||
Клиент SHALL показывать эффекты в просмотрщике до завершения серверного рендера. После готовности серверной версии клиент SHALL показывать её. Предпросмотр пространственных эффектов (текстура, чёткость, дымка) MAY отличаться от серверного рендера деталями, но SHALL передавать характер эффекта.
|
||||
Клиент SHALL показывать эффекты в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После готовности клиент SHALL показывать серверную версию, не показывая в промежутке оригинал или предыдущую версию. Предпросмотр пространственных эффектов MAY отличаться от серверного рендера деталями, но SHALL передавать характер эффекта.
|
||||
|
||||
#### Scenario: Эффекты видны сразу
|
||||
- **WHEN** пользователь двигает ползунок эффекта
|
||||
@@ -526,10 +549,29 @@ SHALL быть доступно и для фото без альбома (нап
|
||||
- **WHEN** серверный рендер эффектов завершён
|
||||
- **THEN** просмотрщик показывает серверную версию
|
||||
|
||||
#### Scenario: Бесшовная подмена предпросмотра
|
||||
- **WHEN** серверный рендер эффектов завершён, но новое изображение ещё загружается
|
||||
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
|
||||
|
||||
#### Scenario: Предпросмотр без WebGL
|
||||
- **WHEN** аппаратное ускорение недоступно
|
||||
- **THEN** клиент показывает эффекты доступным способом или показывает их после серверного рендера, не блокируя интерфейс
|
||||
|
||||
### Requirement: Мгновенный показ сброса правок
|
||||
Клиент SHALL показывать исходный вид фото сразу после возврата контролов редактора в значения по умолчанию или нажатия сброса, не дожидаясь отложенного сохранения на сервер. Пока сброс, выполненный возвратом контролов, ещё не отправлен, просмотрщик SHALL NOT показывать отредактированную версию фото.
|
||||
|
||||
#### Scenario: Сброс возвратом контролов
|
||||
- **WHEN** пользователь возвращает контролы редактора в значения по умолчанию (двойной клик по ползунку, шкала в нуле)
|
||||
- **THEN** просмотрщик сразу показывает исходный вид, не дожидаясь срабатывания отложенного сохранения
|
||||
|
||||
#### Scenario: Сброс кнопкой
|
||||
- **WHEN** пользователь нажимает «Сброс»
|
||||
- **THEN** просмотрщик показывает исходный вид не позже ответа на запрос сброса
|
||||
|
||||
#### Scenario: Сброс при готовом рендере
|
||||
- **WHEN** сброс выполнен, а отредактированная версия ещё существует на сервере
|
||||
- **THEN** просмотрщик показывает исходный вид, а не отредактированную версию
|
||||
|
||||
### Requirement: Свёрнутые секции редактора
|
||||
Редактор SHALL показывать все сворачиваемые секции инструментов свёрнутыми по умолчанию. Клиент SHALL сохранять состояние каждой секции (свёрнута или развёрнута) в браузере пользователя и восстанавливать его при повторном открытии редактора и перезагрузке страницы. Состояние SHALL NOT зависеть от выбранного фото.
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
Система SHALL публиковать события photo.created, photo.updated, photo.deleted,
|
||||
comment.created, like.added, like.removed, member.joined, member.left, album.updated.
|
||||
Событие photo.created SHALL обозначать появление фото в альбоме, а готовность вариантов
|
||||
существующего фото SHALL публиковаться как photo.updated.
|
||||
существующего фото SHALL публиковаться как photo.updated. Сохранение, готовность и сброс
|
||||
правок фото SHALL также публиковаться как photo.updated с признаком незавершённого
|
||||
рендера правок (`edit_pending`).
|
||||
|
||||
#### Scenario: Новое фото
|
||||
- **WHEN** в альбоме появляется фото
|
||||
@@ -27,6 +29,18 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd
|
||||
- **WHEN** для существующего фото сгенерированы thumb и preview
|
||||
- **THEN** подписчики получают событие photo.updated с этим фото
|
||||
|
||||
#### Scenario: Сохранены правки фото
|
||||
- **WHEN** пользователь сохраняет правки фото, а рендер ещё не завершён
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=true`
|
||||
|
||||
#### Scenario: Рендер правок готов
|
||||
- **WHEN** серверный рендер сохранённых правок завершён
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и URL отредактированных вариантов
|
||||
|
||||
#### Scenario: Правки фото сброшены
|
||||
- **WHEN** правки фото сброшены
|
||||
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и без URL отредактированных вариантов
|
||||
|
||||
### Requirement: Персональный поток
|
||||
Клиент SHALL иметь возможность подписаться на персональный поток (приглашения, уведомления,
|
||||
изменения его альбомов).
|
||||
|
||||
Reference in New Issue
Block a user