feat(photos): рендер только видимой области при зуме правок

Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера
только видимую область кадра: координаты относительные, поэтому запрос не
зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с
для всей картинки, а полноразмерная версия больше не готовится вовсе.

Зум больше не сбрасывается, когда приходит отрендеренная версия правок:
состояние просмотра сбрасывается только при смене самого фото.
This commit is contained in:
iu
2026-10-20 23:34:45 +03:00
parent 6d3d7cbdd4
commit 4d2069d079
9 changed files with 328 additions and 40 deletions
+1
View File
@@ -181,6 +181,7 @@ func (s *Server) Router() *gin.Engine {
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag) authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.PATCH("/photos/:id/edit", photosHandler.Edit) authed.PATCH("/photos/:id/edit", photosHandler.Edit)
authed.DELETE("/photos/:id/edit", photosHandler.ResetEdit) authed.DELETE("/photos/:id/edit", photosHandler.ResetEdit)
authed.GET("/photos/:id/edit/region", photosHandler.EditRegion)
authed.GET("/tags", photosHandler.Tags) authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch) authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
+137
View File
@@ -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()
}
+2 -3
View File
@@ -113,9 +113,8 @@ func encodeEditedVariants(edited *image.NRGBA) (thumb, medium, preview []byte, e
return thumb, medium, preview, nil return thumb, medium, preview, nil
} }
// encodeEditedFull renders the full-size edited version of a photo. It is // encodeEdited renders an edited image part as WebP.
// encoded separately from the grid variants because it is the slowest step. func encodeEdited(edited *image.NRGBA) ([]byte, error) {
func encodeEditedFull(edited *image.NRGBA) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
if err := webp.Encode(&buf, edited, webp.Options{Quality: 82}); err != nil { if err := webp.Encode(&buf, edited, webp.Options{Quality: 82}); err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -135,7 +135,7 @@ func TestBuildEditedVariants(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("encode variants: %v", err) t.Fatalf("encode variants: %v", err)
} }
full, err := encodeEditedFull(edited) full, err := encodeEdited(edited)
if err != nil { if err != nil {
t.Fatalf("encode full: %v", err) t.Fatalf("encode full: %v", err)
} }
+18
View File
@@ -137,6 +137,24 @@ func (h *Handler) Edit(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p}) 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 // ResetEdit removes the editing parameters of a photo together with the
// edited version, returning it to its original look. // edited version, returning it to its original look.
func (h *Handler) ResetEdit(c *gin.Context) { func (h *Handler) ResetEdit(c *gin.Context) {
+3 -10
View File
@@ -211,8 +211,7 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
if err != nil { if err != nil {
w.failEdit(ctx, photoID, "build edited variants: "+err.Error()) w.failEdit(ctx, photoID, "build edited variants: "+err.Error())
return 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. // with the copies of the same original.
owner := []string{photoID} owner := []string{photoID}
if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil { if err := w.createVariant(ctx, owner, RoleEditThumb, thumb); err != nil {
@@ -228,16 +227,10 @@ func (w *Worker) generateEdit(ctx context.Context, photoID string) {
return return
} }
// The visible variants are ready: notify right away, so the grid and the // 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) 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.svc.store.CompleteEditJob(ctx, photoID)
w.notify(ctx, photoID)
} }
func (w *Worker) failEdit(ctx context.Context, photoID, msg string) { func (w *Worker) failEdit(ctx context.Context, photoID, msg string) {
+142 -16
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte'; import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte'; import { viewer } from '$lib/stores/viewer.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit'; import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
@@ -33,6 +34,7 @@
let viewerEl: HTMLDivElement | undefined = $state(); let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state(); let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state(); let imgEl: HTMLImageElement | undefined = $state();
let frameEl: HTMLDivElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state(); let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state(); let bottomEl: HTMLDivElement | undefined = $state();
@@ -92,20 +94,33 @@
// Версия фото входит в зависимость: после рендера правок превью // Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать. // меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? ''; current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
if (!id) return; if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`) api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo)) .then((data) => (fresh = data.photo))
.catch(() => {}); .catch(() => {});
}); });
// Состояние просмотра сбрасывается только при смене фото: зум не должен
// сбрасываться, когда приходит отрендеренная версия правок.
let lastPhotoId = '';
$effect(() => {
const id = current?.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
clearRegion();
});
// In zoom mode, preload the full-resolution original and swap it in once loaded. // In zoom mode, preload the full-resolution original and swap it in once loaded.
$effect(() => { $effect(() => {
if (!zoomed || hiResLoaded) return; if (!zoomed || hiResLoaded) return;
// Для отредактированных фото зум собирается из кусков, полная версия
// не нужна.
if (edited) return;
const url = fullSrc; const url = fullSrc;
if (!url) return; if (!url) return;
const im = new Image(); const im = new Image();
@@ -128,6 +143,85 @@
return () => el.removeEventListener('wheel', handler); return () => el.removeEventListener('wheel', handler);
}); });
// Фото с правками в зуме показываем кусками: сервер рендерит только видимую
// область, это быстрее, чем кодировать всю картинку целиком.
const edited = $derived(!!current?.edit_params && Object.keys(current.edit_params).length > 0);
const frameWidth = $derived(zoomed ? baseWidth() * scale : 0);
const frameHeight = $derived(zoomed ? baseHeight() * scale : 0);
let regionURL = $state('');
let regionRect = $state<{ left: number; top: number; width: number; height: number } | null>(
null
);
let regionTimer: ReturnType<typeof setTimeout> | null = null;
let regionToken = 0;
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
function clearRegion() {
regionToken += 1;
if (regionTimer) {
clearTimeout(regionTimer);
regionTimer = null;
}
if (regionURL) {
URL.revokeObjectURL(regionURL);
regionURL = '';
}
regionRect = null;
}
async function loadRegion() {
const el = scroller;
const id = current?.id;
if (!el || !id || !zoomed || !edited) return;
const fw = frameWidth;
const fh = frameHeight;
if (!fw || !fh) return;
const x = clamp01(el.scrollLeft / fw);
const y = clamp01(el.scrollTop / fh);
// Берём область с запасом, чтобы мелкая прокрутка не требовала нового
// запроса к серверу.
const w = Math.min(1, Math.max(0.05, (el.clientWidth / fw) * 1.5));
const h = Math.min(1, Math.max(0.05, (el.clientHeight / fh) * 1.5));
const rx = clamp01(x - (w - el.clientWidth / fw) / 2);
const ry = clamp01(y - (h - el.clientHeight / fh) / 2);
const token = ++regionToken;
try {
const res = await fetch(
`/api/photos/${id}/edit/region?x=${rx.toFixed(4)}&y=${ry.toFixed(4)}&w=${w.toFixed(4)}&h=${h.toFixed(4)}`,
{ headers: auth.access ? { Authorization: `Bearer ${auth.access}` } : {} }
);
if (!res.ok) {
// Без доступа к области показываем обычное фото.
clearRegion();
return;
}
const blob = await res.blob();
if (token !== regionToken) return;
const url = URL.createObjectURL(blob);
if (regionURL) URL.revokeObjectURL(regionURL);
regionURL = url;
regionRect = { left: rx * fw, top: ry * fh, width: w * fw, height: h * fh };
} catch {
/* офлайн: остаётся прежний кусок */
}
}
function scheduleRegion() {
if (!zoomed || !edited) {
clearRegion();
return;
}
if (regionTimer) clearTimeout(regionTimer);
regionTimer = setTimeout(() => {
regionTimer = null;
void loadRegion();
}, 120);
}
$effect(() => { $effect(() => {
const onFs = () => { const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl; isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
@@ -211,6 +305,11 @@
return fresh?.height || current?.height || imgEl?.naturalHeight || 0; return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
} }
/** Элемент, занимающий весь кадр фото: картинка или рамка с куском. */
function frameElement(): HTMLElement | undefined {
return frameEl ?? imgEl ?? undefined;
}
function prev() { function prev() {
if (index > 0) onIndexChange(index - 1); if (index > 0) onIndexChange(index - 1);
} }
@@ -230,7 +329,8 @@
*/ */
function zoomBy(factor: number) { function zoomBy(factor: number) {
const el = scroller; const el = scroller;
if (!el || !imgEl) return; const frame = frameElement();
if (!el || !frame) return;
const natW = baseWidth(); const natW = baseWidth();
const natH = baseHeight(); const natH = baseHeight();
if (!natW || !natH) return; if (!natW || !natH) return;
@@ -240,24 +340,26 @@
const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor)); const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor));
if (Math.abs(next - cur) < 1e-4) return; if (Math.abs(next - cur) < 1e-4) return;
const relX = imgEl.offsetWidth const relX = frame.offsetWidth
? (el.scrollLeft + el.clientWidth / 2 - imgEl.offsetLeft) / imgEl.offsetWidth ? (el.scrollLeft + el.clientWidth / 2 - frame.offsetLeft) / frame.offsetWidth
: 0.5; : 0.5;
const relY = imgEl.offsetHeight const relY = frame.offsetHeight
? (el.scrollTop + el.clientHeight / 2 - imgEl.offsetTop) / imgEl.offsetHeight ? (el.scrollTop + el.clientHeight / 2 - frame.offsetTop) / frame.offsetHeight
: 0.5; : 0.5;
zoomed = next > fit * 1.001; zoomed = next > fit * 1.001;
scale = next; scale = next;
tick().then(() => { tick().then(() => {
const iw = imgEl?.offsetWidth ?? 0; const frame = frameElement();
const ih = imgEl?.offsetHeight ?? 0; const iw = frame?.offsetWidth ?? 0;
const ih = frame?.offsetHeight ?? 0;
if (!iw || !ih) return; if (!iw || !ih) return;
const offX = Math.max(0, (el.clientWidth - iw) / 2); const offX = Math.max(0, (el.clientWidth - iw) / 2);
const offY = Math.max(0, (el.clientHeight - ih) / 2); const offY = Math.max(0, (el.clientHeight - ih) / 2);
el.scrollLeft = offX + relX * iw - el.clientWidth / 2; el.scrollLeft = offX + relX * iw - el.clientWidth / 2;
el.scrollTop = offY + relY * ih - el.clientHeight / 2; el.scrollTop = offY + relY * ih - el.clientHeight / 2;
scheduleRegion();
}); });
} }
function next() { function next() {
@@ -268,6 +370,7 @@
if (zoomed) { if (zoomed) {
zoomed = false; zoomed = false;
scale = 1; scale = 1;
clearRegion();
if (scroller) { if (scroller) {
scroller.scrollLeft = 0; scroller.scrollLeft = 0;
scroller.scrollTop = 0; scroller.scrollTop = 0;
@@ -279,12 +382,14 @@
if (!scroller) return; if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2; scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2; scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
scheduleRegion();
}); });
} }
} }
function onWheel(ev: WheelEvent) { function onWheel(ev: WheelEvent) {
if (!current || !scroller || !imgEl) return; const frame = frameElement();
if (!current || !scroller || !frame) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -298,9 +403,9 @@
const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor)); const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return; if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect(); const r = frame.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width; const fx = r.width ? (ev.clientX - r.left) / r.width : 0.5;
const fy = (ev.clientY - r.top) / r.height; const fy = r.height ? (ev.clientY - r.top) / r.height : 0.5;
zoomed = true; zoomed = true;
scale = newScale; scale = newScale;
@@ -310,6 +415,7 @@
const sr = scroller.getBoundingClientRect(); const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left); scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top); scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
scheduleRegion();
}); });
} }
@@ -340,6 +446,7 @@
} catch { } catch {
/* ignore */ /* ignore */
} }
scheduleRegion();
} }
async function toggleViewerFullscreen() { async function toggleViewerFullscreen() {
@@ -559,8 +666,26 @@
oncontextmenu={onContextMenu} oncontextmenu={onContextMenu}
ontouchstart={onTouchStart} ontouchstart={onTouchStart}
ontouchend={onTouchEnd} ontouchend={onTouchEnd}
onscroll={scheduleRegion}
> >
{#if current} {#if current}
{#if regionURL && regionRect}
<!-- Отредактированное фото в зуме: сервер отрендерил только
видимую область, остальное дорисовывается при прокрутке. -->
<div
bind:this={frameEl}
class="relative m-auto shrink-0"
style="width: {frameWidth}px; height: {frameHeight}px;"
>
<img
src={regionURL}
alt={current.original_filename}
draggable="false"
class="absolute max-w-none select-none"
style="left: {regionRect.left}px; top: {regionRect.top}px; width: {regionRect.width}px; height: {regionRect.height}px;"
/>
</div>
{:else}
<img <img
bind:this={imgEl} bind:this={imgEl}
src={displaySrc} src={displaySrc}
@@ -572,6 +697,7 @@
: 'max-h-full max-w-full object-contain'}" : 'max-h-full max-w-full object-contain'}"
/> />
{/if} {/if}
{/if}
</div> </div>
{/if} {/if}
+10
View File
@@ -309,3 +309,13 @@ SHALL храниться в метаданных фото, а отредакти
#### Scenario: Редактирование видео #### Scenario: Редактирование видео
- **WHEN** пользователь пытается отредактировать видео - **WHEN** пользователь пытается отредактировать видео
- **THEN** система отклоняет запрос - **THEN** система отклоняет запрос
### Requirement: Рендер видимой области правки
Для фото с правками просмотр SHALL получать изображение по запросу отдельной области:
система SHALL рендерить только видимую часть кадра с применёнными правками, а не готовить
полноразмерную отредактированную версию. Область SHALL задаваться относительными
координатами, чтобы запрос не зависел от размера оригинала.
#### Scenario: Зум отредактированного фото
- **WHEN** пользователь увеличивает фото с правками
- **THEN** система отдаёт отрендеренную видимую область, не готовя полную версию
+4
View File
@@ -794,3 +794,7 @@ SHALL показывать отредактированную версию фо
#### Scenario: Сброс правок #### Scenario: Сброс правок
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование» - **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду - **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
#### Scenario: Зум при правках
- **WHEN** фото отредактировано, и пользователь увеличивает его
- **THEN** просмотр показывает отрендеренную сервером видимую область, а зум не сбрасывается при обновлении фото