fix(web): правки применяются сразу и не сбрасывают вкладку

Вкладка «Редактирование» больше не переключается на «Сведения» при
обновлении того же фото: временное состояние панели сбрасывается только при
смене фото.

Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу
после постановки задачи, а уведомление о готовых вариантах приходит до
кодирования полноразмерной версии. Задержка между сохранением правок и
появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
This commit is contained in:
iu
2026-10-20 23:11:57 +03:00
parent 0548b1ab5e
commit cd3d17c0f0
8 changed files with 87 additions and 27 deletions
+2
View File
@@ -73,6 +73,8 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
photosSvc.SetVideoEnabled(videoTools.Available()) photosSvc.SetVideoEnabled(videoTools.Available())
photosSvc.SetTempDir(cfg.Video.TempDir) photosSvc.SetTempDir(cfg.Video.TempDir)
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency) photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
// Правки рендерятся сразу, не дожидаясь тика воркера.
photosSvc.SetEditNotifier(photosWorker.Wake)
avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st) avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st)
+22 -18
View File
@@ -43,16 +43,20 @@ func applyLight(src image.Image, p LightParams) *image.NRGBA {
return dst return dst
} }
// buildEditedVariants renders the edited version of a photo: the full-size // decodeAndAdjust decodes the original and applies the light adjustments to
// image plus the thumb, medium and preview variants. The variants mirror the // it, so the variants and the full-size version share one decode.
// base ones, so the grid and the viewer can switch to them transparently. func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
func buildEditedVariants(data []byte, p EditParams) (thumb, medium, preview, full []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, err
}
return applyLight(img, p.Light), nil
} }
edited := applyLight(img, p.Light)
// 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) thumbImg := imaging.Fill(edited, 200, 200, imaging.Center, imaging.Lanczos)
mediumImg := imaging.Fill(edited, 800, 800, imaging.Center, imaging.Lanczos) mediumImg := imaging.Fill(edited, 800, 800, imaging.Center, imaging.Lanczos)
previewImg := imaging.Fit(edited, 1200, 1200, 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} opts := webp.Options{Quality: 82}
var buf bytes.Buffer var buf bytes.Buffer
if err := webp.Encode(&buf, thumbImg, opts); err != nil { if err := webp.Encode(&buf, thumbImg, opts); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, err
} }
thumb = buf.Bytes() thumb = buf.Bytes()
buf.Reset() buf.Reset()
if err := webp.Encode(&buf, mediumImg, opts); err != nil { if err := webp.Encode(&buf, mediumImg, opts); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, err
} }
medium = buf.Bytes() medium = buf.Bytes()
buf.Reset() buf.Reset()
if err := webp.Encode(&buf, previewImg, opts); err != nil { if err := webp.Encode(&buf, previewImg, opts); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, err
} }
preview = buf.Bytes() preview = buf.Bytes()
return thumb, medium, preview, nil
}
buf.Reset() // encodeEditedFull renders the full-size edited version of a photo. It is
if err := webp.Encode(&buf, edited, opts); err != nil { // 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 {
full = nil return nil, err
} else {
full = buf.Bytes()
} }
return thumb, medium, preview, full, nil return buf.Bytes(), nil
} }
+4
View File
@@ -55,6 +55,10 @@ func (s *Service) EditPhoto(ctx context.Context, photoID, userID string, params
if err := s.store.EnqueueEditJob(ctx, photoID); err != nil { if err := s.store.EnqueueEditJob(ctx, photoID); err != nil {
return nil, err return nil, err
} }
// Рендер начинается сразу, а не в конце цикла воркера.
if s.wakeEdit != nil {
s.wakeEdit()
}
return s.publishEdit(ctx, photoID, userID) return s.publishEdit(ctx, photoID, userID)
} }
+10 -2
View File
@@ -127,9 +127,17 @@ func TestBuildEditedVariants(t *testing.T) {
src.Pix[i+3] = 255 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 { 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 { 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)) t.Fatalf("empty variants: %d %d %d %d", len(thumb), len(medium), len(preview), len(full))
+9
View File
@@ -44,6 +44,9 @@ type Service struct {
videoMax int64 videoMax int64
videoEnabled bool videoEnabled bool
tempDir string tempDir string
// wakeEdit будит воркер сразу после постановки правок в очередь.
wakeEdit func()
} }
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service { 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 // SetVideoEnabled controls whether video uploads are accepted. It is false when
// the ffmpeg/ffprobe toolchain is unavailable. // the ffmpeg/ffprobe toolchain is unavailable.
func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled } func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled }
+27 -2
View File
@@ -41,6 +41,9 @@ type Worker struct {
videoConc int videoConc int
videoLease time.Duration videoLease time.Duration
tempDir string tempDir string
// wake будит воркер немедленно, чтобы свежие правки не ждали тика.
wake chan struct{}
} }
// NewWorker builds a worker. Parameters are clamped to sane defaults. // 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, concurrency: concurrency,
videoConc: 1, videoConc: 1,
videoLease: videoJobLease, 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(): case <-ctx.Done():
return return
case <-ticker.C: 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()) w.failEdit(ctx, photoID, "fetch original: "+err.Error())
return 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 { if err != nil {
w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) w.failEdit(ctx, photoID, "build edited variants: "+err.Error())
return return
@@ -205,7 +227,10 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
w.failEdit(ctx, photoID, "store edit preview: "+err.Error()) w.failEdit(ctx, photoID, "store edit preview: "+err.Error())
return 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 { if err := w.createVariant(ctx, owner, RoleEdit, full); err != nil {
w.failEdit(ctx, photoID, "store edit: "+err.Error()) w.failEdit(ctx, photoID, "store edit: "+err.Error())
return return
@@ -33,6 +33,8 @@
const saved = $derived(readEditParams(photo)); const saved = $derived(readEditParams(photo));
const hasEdits = $derived(!isEmptyEditParams(saved)); const hasEdits = $derived(!isEmptyEditParams(saved));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved)); const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// Правки сохранены, но отредактированная версия ещё рендерится.
const rendering = $derived(hasEdits && !photo.edit_preview_url);
// При смене фото параметры берутся из метаданных. // При смене фото параметры берутся из метаданных.
let lastPhotoId = ''; let lastPhotoId = '';
@@ -332,7 +334,9 @@
</div> </div>
{#if busy} {#if busy}
<p class="text-text-subtle text-xs">Сохраняем и рендерим…</p> <p class="text-text-subtle text-xs">Сохраняем…</p>
{:else if rendering}
<p class="text-text-subtle text-xs">Рендерим правки…</p>
{/if} {/if}
{#if error} {#if error}
<p class="text-danger text-xs">{error}</p> <p class="text-danger text-xs">{error}</p>
@@ -64,9 +64,13 @@
) )
); );
// Сброс временного состояния только при смене фото: обновления того же
// фото (например, отрендеренные правки) не должны сбрасывать вкладку.
let lastPhotoId = '';
$effect(() => { $effect(() => {
// сброс временного состояния при смене фото const id = photo.id;
photo.id; if (id === lastPhotoId) return;
lastPhotoId = id;
editingName = false; editingName = false;
nameError = ''; nameError = '';
showAll = false; showAll = false;