feat(web): вкладка «Редактирование» с инструментом «Свет»

В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
This commit is contained in:
iu
2026-10-20 23:06:24 +03:00
parent afaaaab5f0
commit 0548b1ab5e
32 changed files with 1954 additions and 24 deletions
+79
View File
@@ -106,6 +106,7 @@ func (w *Worker) ProcessOnce(ctx context.Context) {
func (w *Worker) tick(ctx context.Context) {
w.processVariantJobs(ctx)
w.processVideoJobs(ctx)
w.processEditJobs(ctx)
w.collectGarbage(ctx)
w.reapStalePending(ctx)
}
@@ -141,6 +142,84 @@ func (w *Worker) processVideoJobs(ctx context.Context) {
})
}
// processEditJobs claims and processes the pending renders of the edited
// versions of photos.
func (w *Worker) processEditJobs(ctx context.Context) {
ids, err := w.svc.store.ClaimEditJobs(ctx, w.batch, w.lease)
if err != nil {
slog.Error("claim edit jobs", "error", err)
return
}
w.forEachConcurrent(ids, func(id string) {
w.generateEdit(ctx, id)
})
}
// generateEdit renders the edited version of a photo from its original and
// the parameters stored in the metadata. The variants are bound next to the
// base ones, so the client can show the edited photo without touching the
// original object.
func (w *Worker) generateEdit(ctx context.Context, photoID string) {
p, err := w.svc.store.GetByID(ctx, photoID, "")
if err != nil {
_ = w.svc.store.CompleteEditJob(ctx, photoID)
return
}
params := EditParamsOf(p)
if params.IsEmpty() {
// The photo was reset while the job was queued.
_ = w.svc.store.CompleteEditJob(ctx, photoID)
return
}
orig, err := w.svc.store.OriginalObject(ctx, photoID)
if err != nil {
_ = w.svc.store.CompleteEditJob(ctx, photoID)
return
}
if orig.State != ObjectReady {
w.failEdit(ctx, photoID, "original object is not ready")
return
}
data, err := w.svc.storage.Get(ctx, orig.StorageKey)
if err != nil {
w.failEdit(ctx, photoID, "fetch original: "+err.Error())
return
}
thumb, medium, preview, full, err := buildEditedVariants(data, params)
if err != nil {
w.failEdit(ctx, photoID, "build edited variants: "+err.Error())
return
}
// The edit belongs to this photo only, so the variants are not shared
// with the copies of the same original.
owner := []string{photoID}
if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil {
w.failEdit(ctx, photoID, "store edit thumb: "+err.Error())
return
}
if err := w.createVariant(ctx, owner, RoleEditMedium, medium); err != nil {
w.failEdit(ctx, photoID, "store edit medium: "+err.Error())
return
}
if err := w.createVariant(ctx, owner, RoleEditPreview, preview); err != nil {
w.failEdit(ctx, photoID, "store edit preview: "+err.Error())
return
}
if len(full) > 0 {
if err := w.createVariant(ctx, owner, RoleEdit, full); err != nil {
w.failEdit(ctx, photoID, "store edit: "+err.Error())
return
}
}
_ = w.svc.store.CompleteEditJob(ctx, photoID)
w.notify(ctx, photoID)
}
func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {
slog.Warn("edit job failed", "photo", photoID, "error", msg)
_ = w.svc.store.FailEditJob(ctx, photoID, msg, maxVariantAttempts)
}
// forEachConcurrent runs fn over ids with at most w.concurrency functions in
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
// scales with cores; a concurrency of 1 stays strictly sequential.