From cd3d17c0f0fd5a89179c9acb6e1e66de56e25bc9 Mon Sep 17 00:00:00 2001 From: Iu Date: Tue, 20 Oct 2026 23:11:57 +0300 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=81=D1=80=D0=B0=D0=B7=D1=83=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вкладка «Редактирование» больше не переключается на «Сведения» при обновлении того же фото: временное состояние панели сбрасывается только при смене фото. Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу после постановки задачи, а уведомление о готовых вариантах приходит до кодирования полноразмерной версии. Задержка между сохранением правок и появлением отредактированной версии — около 0.25 с вместо нескольких секунд. --- backend/internal/app/app.go | 2 + backend/internal/photos/edit_render.go | 44 ++++++++++--------- backend/internal/photos/edit_service.go | 4 ++ backend/internal/photos/edit_test.go | 12 ++++- backend/internal/photos/service.go | 9 ++++ backend/internal/photos/worker.go | 29 +++++++++++- .../src/lib/components/PhotoEditPanel.svelte | 6 ++- frontend/src/lib/components/PhotoPanel.svelte | 8 +++- 8 files changed, 87 insertions(+), 27 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 955f5e8..39013a8 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -73,6 +73,8 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e photosSvc.SetVideoEnabled(videoTools.Available()) photosSvc.SetTempDir(cfg.Video.TempDir) photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency) + // Правки рендерятся сразу, не дожидаясь тика воркера. + photosSvc.SetEditNotifier(photosWorker.Wake) avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st) diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go index e7d8023..619971d 100644 --- a/backend/internal/photos/edit_render.go +++ b/backend/internal/photos/edit_render.go @@ -43,16 +43,20 @@ func applyLight(src image.Image, p LightParams) *image.NRGBA { return dst } -// buildEditedVariants renders the edited version of a photo: the full-size -// image plus the thumb, medium and preview variants. The variants mirror the -// base ones, so the grid and the viewer can switch to them transparently. -func buildEditedVariants(data []byte, p EditParams) (thumb, medium, preview, full []byte, err error) { +// decodeAndAdjust decodes the original and applies the light adjustments to +// it, so the variants and the full-size version share one decode. +func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) { img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) if err != nil { - return nil, nil, nil, nil, err + return nil, err } - edited := applyLight(img, p.Light) + return applyLight(img, p.Light), nil +} +// encodeEditedVariants renders the grid variants of an edited photo. They +// mirror the base ones, so the grid and the viewer can switch to them +// transparently. +func encodeEditedVariants(edited *image.NRGBA) (thumb, medium, preview []byte, err error) { thumbImg := imaging.Fill(edited, 200, 200, imaging.Center, imaging.Lanczos) mediumImg := imaging.Fill(edited, 800, 800, imaging.Center, imaging.Lanczos) previewImg := imaging.Fit(edited, 1200, 1200, imaging.Lanczos) @@ -60,30 +64,30 @@ func buildEditedVariants(data []byte, p EditParams) (thumb, medium, preview, ful opts := webp.Options{Quality: 82} var buf bytes.Buffer if err := webp.Encode(&buf, thumbImg, opts); err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, err } thumb = buf.Bytes() buf.Reset() if err := webp.Encode(&buf, mediumImg, opts); err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, err } medium = buf.Bytes() buf.Reset() if err := webp.Encode(&buf, previewImg, opts); err != nil { - return nil, nil, nil, nil, err + return nil, nil, nil, err } preview = buf.Bytes() - - buf.Reset() - if err := webp.Encode(&buf, edited, opts); err != nil { - // Полноразмерная версия не обязательна: превью и миниатюры уже - // готовы, поэтому ошибку кодирования полной версии не считаем - // фатальной. - full = nil - } else { - full = buf.Bytes() - } - return thumb, medium, preview, full, nil + return thumb, medium, preview, nil +} + +// encodeEditedFull renders the full-size edited version of a photo. It is +// encoded separately from the grid variants because it is the slowest step. +func encodeEditedFull(edited *image.NRGBA) ([]byte, error) { + var buf bytes.Buffer + if err := webp.Encode(&buf, edited, webp.Options{Quality: 82}); err != nil { + return nil, err + } + return buf.Bytes(), nil } diff --git a/backend/internal/photos/edit_service.go b/backend/internal/photos/edit_service.go index 22a85aa..8d58247 100644 --- a/backend/internal/photos/edit_service.go +++ b/backend/internal/photos/edit_service.go @@ -55,6 +55,10 @@ func (s *Service) EditPhoto(ctx context.Context, photoID, userID string, params if err := s.store.EnqueueEditJob(ctx, photoID); err != nil { return nil, err } + // Рендер начинается сразу, а не в конце цикла воркера. + if s.wakeEdit != nil { + s.wakeEdit() + } return s.publishEdit(ctx, photoID, userID) } diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go index 085cb04..b15e82c 100644 --- a/backend/internal/photos/edit_test.go +++ b/backend/internal/photos/edit_test.go @@ -127,9 +127,17 @@ func TestBuildEditedVariants(t *testing.T) { src.Pix[i+3] = 255 } } - thumb, medium, preview, full, err := buildEditedVariants(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}) + edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}) if err != nil { - t.Fatalf("build variants: %v", err) + t.Fatalf("decode and adjust: %v", err) + } + thumb, medium, preview, err := encodeEditedVariants(edited) + if err != nil { + t.Fatalf("encode variants: %v", err) + } + full, err := encodeEditedFull(edited) + if err != nil { + t.Fatalf("encode full: %v", err) } if len(thumb) == 0 || len(medium) == 0 || len(preview) == 0 || len(full) == 0 { t.Fatalf("empty variants: %d %d %d %d", len(thumb), len(medium), len(preview), len(full)) diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 409ac64..e28344f 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -44,6 +44,9 @@ type Service struct { videoMax int64 videoEnabled bool tempDir string + + // wakeEdit будит воркер сразу после постановки правок в очередь. + wakeEdit func() } func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service { @@ -68,6 +71,12 @@ func (s *Service) SetUploadLimits(imageMax, videoMax int64) { } } +// SetEditNotifier registers a callback that is invoked when an edit render is +// queued, so the worker picks the job up without waiting for the next tick. +func (s *Service) SetEditNotifier(fn func()) { + s.wakeEdit = fn +} + // SetVideoEnabled controls whether video uploads are accepted. It is false when // the ffmpeg/ffprobe toolchain is unavailable. func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled } diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index 54441cd..064f6c1 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -41,6 +41,9 @@ type Worker struct { videoConc int videoLease time.Duration tempDir string + + // wake будит воркер немедленно, чтобы свежие правки не ждали тика. + wake chan struct{} } // NewWorker builds a worker. Parameters are clamped to sane defaults. @@ -66,6 +69,19 @@ func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time. concurrency: concurrency, videoConc: 1, videoLease: videoJobLease, + wake: make(chan struct{}, 1), + } +} + +// Wake asks the worker to process the queues right away instead of waiting for +// the next tick. It is safe to call from any goroutine. +func (w *Worker) Wake() { + if w.wake == nil { + return + } + select { + case w.wake <- struct{}{}: + default: } } @@ -93,6 +109,7 @@ func (w *Worker) Run(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + case <-w.wake: } } } @@ -185,7 +202,12 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) { w.failEdit(ctx, photoID, "fetch original: "+err.Error()) return } - thumb, medium, preview, full, err := buildEditedVariants(data, params) + edited, err := decodeAndAdjust(data, params) + if err != nil { + w.failEdit(ctx, photoID, "decode original: "+err.Error()) + return + } + thumb, medium, preview, err := encodeEditedVariants(edited) if err != nil { w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) return @@ -205,7 +227,10 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) { w.failEdit(ctx, photoID, "store edit preview: "+err.Error()) return } - if len(full) > 0 { + // The visible variants are ready: notify right away, so the grid and the + // viewer show the edit while the full-size version is still encoding. + w.notify(ctx, photoID) + if full, err := encodeEditedFull(edited); err == nil && len(full) > 0 { if err := w.createVariant(ctx, owner, RoleEdit, full); err != nil { w.failEdit(ctx, photoID, "store edit: "+err.Error()) return diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index 3e2e739..b9d3860 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -33,6 +33,8 @@ const saved = $derived(readEditParams(photo)); const hasEdits = $derived(!isEmptyEditParams(saved)); const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved)); + // Правки сохранены, но отредактированная версия ещё рендерится. + const rendering = $derived(hasEdits && !photo.edit_preview_url); // При смене фото параметры берутся из метаданных. let lastPhotoId = ''; @@ -332,7 +334,9 @@ {#if busy} -

Сохраняем и рендерим…

+

Сохраняем…

+ {:else if rendering} +

Рендерим правки…

{/if} {#if error}

{error}

diff --git a/frontend/src/lib/components/PhotoPanel.svelte b/frontend/src/lib/components/PhotoPanel.svelte index b993c01..86e50e9 100644 --- a/frontend/src/lib/components/PhotoPanel.svelte +++ b/frontend/src/lib/components/PhotoPanel.svelte @@ -64,9 +64,13 @@ ) ); + // Сброс временного состояния только при смене фото: обновления того же + // фото (например, отрендеренные правки) не должны сбрасывать вкладку. + let lastPhotoId = ''; $effect(() => { - // сброс временного состояния при смене фото - photo.id; + const id = photo.id; + if (id === lastPhotoId) return; + lastPhotoId = id; editingName = false; nameError = ''; showAll = false;