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.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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user