feat(photos): рендер только видимой области при зуме правок
Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера только видимую область кадра: координаты относительные, поэтому запрос не зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с для всей картинки, а полноразмерная версия больше не готовится вовсе. Зум больше не сбрасывается, когда приходит отрендеренная версия правок: состояние просмотра сбрасывается только при смене самого фото.
This commit is contained in:
@@ -181,6 +181,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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()
|
||||
}
|
||||
@@ -113,9 +113,8 @@ func encodeEditedVariants(edited *image.NRGBA) (thumb, medium, preview []byte, e
|
||||
return thumb, medium, preview, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// encodeEdited renders an edited image part as WebP.
|
||||
func encodeEdited(edited *image.NRGBA) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := webp.Encode(&buf, edited, webp.Options{Quality: 82}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestBuildEditedVariants(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("encode variants: %v", err)
|
||||
}
|
||||
full, err := encodeEditedFull(edited)
|
||||
full, err := encodeEdited(edited)
|
||||
if err != nil {
|
||||
t.Fatalf("encode full: %v", err)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,24 @@ 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(®ion); 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) {
|
||||
|
||||
@@ -211,8 +211,7 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
|
||||
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
|
||||
} // 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 {
|
||||
@@ -228,16 +227,10 @@ 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 while the full-size version is still encoding.
|
||||
// viewer show the edit. Полноразмерная версия не готовится: в зуме
|
||||
// просмотр собирает только видимую область отдельным запросом.
|
||||
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
|
||||
}
|
||||
}
|
||||
_ = w.svc.store.CompleteEditJob(ctx, photoID)
|
||||
w.notify(ctx, photoID)
|
||||
}
|
||||
|
||||
func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {
|
||||
|
||||
Reference in New Issue
Block a user