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() }