feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user