fix(web): правки применяются сразу и не сбрасывают вкладку
Вкладка «Редактирование» больше не переключается на «Сведения» при обновлении того же фото: временное состояние панели сбрасывается только при смене фото. Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу после постановки задачи, а уведомление о готовых вариантах приходит до кодирования полноразмерной версии. Задержка между сохранением правок и появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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)
|
||||
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()
|
||||
return thumb, medium, preview, nil
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := webp.Encode(&buf, edited, opts); err != nil {
|
||||
// Полноразмерная версия не обязательна: превью и миниатюры уже
|
||||
// готовы, поэтому ошибку кодирования полной версии не считаем
|
||||
// фатальной.
|
||||
full = nil
|
||||
} else {
|
||||
full = buf.Bytes()
|
||||
// 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 thumb, medium, preview, full, nil
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
|
||||
{#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 error}
|
||||
<p class="text-danger text-xs">{error}</p>
|
||||
|
||||
@@ -64,9 +64,13 @@
|
||||
)
|
||||
);
|
||||
|
||||
// Сброс временного состояния только при смене фото: обновления того же
|
||||
// фото (например, отрендеренные правки) не должны сбрасывать вкладку.
|
||||
let lastPhotoId = '';
|
||||
$effect(() => {
|
||||
// сброс временного состояния при смене фото
|
||||
photo.id;
|
||||
const id = photo.id;
|
||||
if (id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
editingName = false;
|
||||
nameError = '';
|
||||
showAll = false;
|
||||
|
||||
Reference in New Issue
Block a user