feat(web): правки применяются на фронте, фильтры в WASM

Новый алгоритм редактирования: обработка идёт только на клиенте.

- Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ):
  он строит тональные кривые и обрабатывает пиксели. При недоступности модуля
  используется JS-реализация с тем же результатом — расхождение проверяется
  тестом.
- Просмотр рисует видимую область фото с правками на canvas поверх
  исходной версии, поэтому изменения видны сразу, без ожидания рендера.
- Правки уходят на сервер не сразу: через десять секунд без изменений или при
  переключении фото. Сервер сохраняет параметры и готовит варианты сетки и
  полноразмерную версию в фоне, о готовности сообщает через SSE — клиент
  подтягивает готовую версию.
This commit is contained in:
iu
2026-10-21 00:06:46 +03:00
parent 4d2069d079
commit 520886a11d
21 changed files with 788 additions and 312 deletions
-1
View File
@@ -181,7 +181,6 @@ func (s *Server) Router() *gin.Engine {
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.PATCH("/photos/:id/edit", photosHandler.Edit)
authed.DELETE("/photos/:id/edit", photosHandler.ResetEdit)
authed.GET("/photos/:id/edit/region", photosHandler.EditRegion)
authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
-137
View File
@@ -1,137 +0,0 @@
package photos
import (
"bytes"
"context"
"errors"
"image"
"math"
"github.com/disintegration/imaging"
"galo/internal/httpx"
"galo/internal/models"
)
// EditRegion is a rectangle of the photo in relative coordinates: 0 — левый
// край, 1 — правый. Так запрос не зависит от размера оригинала.
type EditRegion struct {
X float64 `form:"x" json:"x"`
Y float64 `form:"y" json:"y"`
W float64 `form:"w" json:"w"`
H float64 `form:"h" json:"h"`
}
// MaxRegionSide ограничивает сторону отдаваемого куска, чтобы ответ не был
// огромным.
const MaxRegionSide = 3200
// RenderEditRegion renders the visible part of an edited photo. Рендер только
// нужной области заметно быстрее, чем кодирование полной версии: обрабатываются
// лишь видимые пиксели.
func (s *Service) RenderEditRegion(ctx context.Context, photoID, userID string, region EditRegion) ([]byte, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
params := EditParamsOf(p)
if params.IsEmpty() {
return nil, httpx.NotFound("photo_not_edited", "photo has no edits")
}
orig, err := s.store.OriginalObject(ctx, photoID)
if err != nil {
return nil, err
}
if orig.State != ObjectReady {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
data, err := s.storage.Get(ctx, orig.StorageKey)
if err != nil {
return nil, err
}
return renderEditRegion(data, params, region)
}
// renderEditRegion crops the requested part of the original, applies the light
// adjustments to it and encodes the result.
func renderEditRegion(data []byte, params EditParams, region EditRegion) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, err
}
bounds := img.Bounds()
w := bounds.Dx()
h := bounds.Dy()
x := int(math.Round(clamp01(region.X) * float64(w)))
y := int(math.Round(clamp01(region.Y) * float64(h)))
rw := int(math.Round(clamp01(region.W) * float64(w)))
rh := int(math.Round(clamp01(region.H) * float64(h)))
if rw < 1 {
rw = 1
}
if rh < 1 {
rh = 1
}
// Область должна помещаться в кадр.
if x+rw > bounds.Max.X {
x = bounds.Max.X - rw
}
if y+rh > bounds.Max.Y {
y = bounds.Max.Y - rh
}
if x < bounds.Min.X {
x = bounds.Min.X
}
if y < bounds.Min.Y {
y = bounds.Min.Y
}
if x+rw > bounds.Max.X {
rw = bounds.Max.X - x
}
if y+rh > bounds.Max.Y {
rh = bounds.Max.Y - y
}
if rw < 1 || rh < 1 {
return nil, httpx.BadRequest("invalid_region", "region is out of the photo")
}
crop, ok := img.(interface {
SubImage(r image.Rectangle) image.Image
})
if !ok {
return nil, httpx.BadRequest("invalid_request", "photo cannot be cropped")
}
edited := applyLight(crop.SubImage(image.Rect(x, y, x+rw, y+rh)), params.Light)
// Сторону куска ограничиваем, сохраняя пропорции.
side := math.Max(float64(edited.Rect.Dx()), float64(edited.Rect.Dy()))
if side > MaxRegionSide {
scale := MaxRegionSide / side
nw := int(float64(edited.Rect.Dx()) * scale)
nh := int(float64(edited.Rect.Dy()) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
edited = imaging.Resize(edited, nw, nh, imaging.Lanczos)
}
return encodeEdited(edited)
}
// PhotoEdited reports whether the photo has saved edits.
func PhotoEdited(p *models.Photo) bool {
return !EditParamsOf(p).IsEmpty()
}
-18
View File
@@ -137,24 +137,6 @@ func (h *Handler) Edit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p})
}
// EditRegion renders the visible part of an edited photo: only the pixels the
// user actually sees are processed, which is much faster than encoding the
// whole image.
func (h *Handler) EditRegion(c *gin.Context) {
var region EditRegion
if err := c.ShouldBindQuery(&region); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "region is required"))
return
}
data, err := h.svc.RenderEditRegion(c.Request.Context(), c.Param("id"), auth.UserID(c), region)
if err != nil {
httpx.RespondError(c, err)
return
}
c.Header("Cache-Control", "private, max-age=300")
c.Data(http.StatusOK, "image/webp", data)
}
// ResetEdit removes the editing parameters of a photo together with the
// edited version, returning it to its original look.
func (h *Handler) ResetEdit(c *gin.Context) {
+9 -2
View File
@@ -227,10 +227,17 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
return
}
// The visible variants are ready: notify right away, so the grid and the
// viewer show the edit. Полноразмерная версия не готовится: в зуме
// просмотр собирает только видимую область отдельным запросом.
// viewer show the edit. Полноразмерная версия готовится следом: пока она
// не готова, браузер рисует видимую область сам.
w.notify(ctx, photoID)
if full, err := encodeEdited(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
}
}
_ = w.svc.store.CompleteEditJob(ctx, photoID)
w.notify(ctx, photoID)
}
func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {