From 4d2069d0791b22c770ec8393586307b2e581f0d4 Mon Sep 17 00:00:00 2001 From: Iu Date: Tue, 20 Oct 2026 23:34:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(photos):=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B8=D0=BC=D0=BE=D0=B9=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B7=D1=83=D0=BC=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера только видимую область кадра: координаты относительные, поэтому запрос не зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с для всей картинки, а полноразмерная версия больше не готовится вовсе. Зум больше не сбрасывается, когда приходит отрендеренная версия правок: состояние просмотра сбрасывается только при смене самого фото. --- backend/internal/app/app.go | 1 + backend/internal/photos/edit_region.go | 137 +++++++++++++++++ backend/internal/photos/edit_render.go | 5 +- backend/internal/photos/edit_test.go | 2 +- backend/internal/photos/handlers.go | 18 +++ backend/internal/photos/worker.go | 13 +- frontend/src/lib/components/Viewer.svelte | 178 ++++++++++++++++++---- openspec/specs/photos/spec.md | 10 ++ openspec/specs/ui-workspace/spec.md | 4 + 9 files changed, 328 insertions(+), 40 deletions(-) create mode 100644 backend/internal/photos/edit_region.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 39013a8..871061e 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) diff --git a/backend/internal/photos/edit_region.go b/backend/internal/photos/edit_region.go new file mode 100644 index 0000000..4ce219a --- /dev/null +++ b/backend/internal/photos/edit_region.go @@ -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() +} diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go index a3f3650..3769ce2 100644 --- a/backend/internal/photos/edit_render.go +++ b/backend/internal/photos/edit_render.go @@ -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 diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go index b15e82c..b072d55 100644 --- a/backend/internal/photos/edit_test.go +++ b/backend/internal/photos/edit_test.go @@ -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) } diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index c4f4dc6..e64fea5 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -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) { diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index 064f6c1..90cd02a 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -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) { diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 4b42f55..ab9a6f2 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -9,6 +9,7 @@ import { transfer } from '$lib/stores/transfer.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { viewer } from '$lib/stores/viewer.svelte'; + import { auth } from '$lib/stores/auth.svelte'; import { photoFullUrl, photoPreviewUrl } from '$lib/edit'; import type { Photo } from '$lib/types'; @@ -33,6 +34,7 @@ let viewerEl: HTMLDivElement | undefined = $state(); let scroller: HTMLDivElement | undefined = $state(); let imgEl: HTMLImageElement | undefined = $state(); + let frameEl: HTMLDivElement | undefined = $state(); let topEl: HTMLDivElement | undefined = $state(); let bottomEl: HTMLDivElement | undefined = $state(); @@ -92,20 +94,33 @@ // Версия фото входит в зависимость: после рендера правок превью // меняется, и данные нужно перечитать. current?.edit_preview_url ?? current?.preview_url ?? ''; - fresh = null; - zoomed = false; - scale = 1; - hiResLoaded = false; - panoFailed = false; if (!id) return; api<{ photo: Photo }>(`/photos/${id}`) .then((data) => (fresh = data.photo)) .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. $effect(() => { if (!zoomed || hiResLoaded) return; + // Для отредактированных фото зум собирается из кусков, полная версия + // не нужна. + if (edited) return; const url = fullSrc; if (!url) return; const im = new Image(); @@ -128,6 +143,85 @@ 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 | 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(() => { const onFs = () => { isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl; @@ -211,6 +305,11 @@ return fresh?.height || current?.height || imgEl?.naturalHeight || 0; } + /** Элемент, занимающий весь кадр фото: картинка или рамка с куском. */ + function frameElement(): HTMLElement | undefined { + return frameEl ?? imgEl ?? undefined; + } + function prev() { if (index > 0) onIndexChange(index - 1); } @@ -230,7 +329,8 @@ */ function zoomBy(factor: number) { const el = scroller; - if (!el || !imgEl) return; + const frame = frameElement(); + if (!el || !frame) return; const natW = baseWidth(); const natH = baseHeight(); if (!natW || !natH) return; @@ -240,24 +340,26 @@ const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor)); if (Math.abs(next - cur) < 1e-4) return; - const relX = imgEl.offsetWidth - ? (el.scrollLeft + el.clientWidth / 2 - imgEl.offsetLeft) / imgEl.offsetWidth + const relX = frame.offsetWidth + ? (el.scrollLeft + el.clientWidth / 2 - frame.offsetLeft) / frame.offsetWidth : 0.5; - const relY = imgEl.offsetHeight - ? (el.scrollTop + el.clientHeight / 2 - imgEl.offsetTop) / imgEl.offsetHeight + const relY = frame.offsetHeight + ? (el.scrollTop + el.clientHeight / 2 - frame.offsetTop) / frame.offsetHeight : 0.5; zoomed = next > fit * 1.001; scale = next; tick().then(() => { - const iw = imgEl?.offsetWidth ?? 0; - const ih = imgEl?.offsetHeight ?? 0; + const frame = frameElement(); + const iw = frame?.offsetWidth ?? 0; + const ih = frame?.offsetHeight ?? 0; if (!iw || !ih) return; const offX = Math.max(0, (el.clientWidth - iw) / 2); const offY = Math.max(0, (el.clientHeight - ih) / 2); el.scrollLeft = offX + relX * iw - el.clientWidth / 2; el.scrollTop = offY + relY * ih - el.clientHeight / 2; + scheduleRegion(); }); } function next() { @@ -268,6 +370,7 @@ if (zoomed) { zoomed = false; scale = 1; + clearRegion(); if (scroller) { scroller.scrollLeft = 0; scroller.scrollTop = 0; @@ -279,12 +382,14 @@ if (!scroller) return; scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2; scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2; + scheduleRegion(); }); } } function onWheel(ev: WheelEvent) { - if (!current || !scroller || !imgEl) return; + const frame = frameElement(); + if (!current || !scroller || !frame) return; ev.preventDefault(); ev.stopPropagation(); @@ -298,9 +403,9 @@ const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor)); if (Math.abs(newScale - curScale) < 1e-4) return; - const r = imgEl.getBoundingClientRect(); - const fx = (ev.clientX - r.left) / r.width; - const fy = (ev.clientY - r.top) / r.height; + const r = frame.getBoundingClientRect(); + const fx = r.width ? (ev.clientX - r.left) / r.width : 0.5; + const fy = r.height ? (ev.clientY - r.top) / r.height : 0.5; zoomed = true; scale = newScale; @@ -310,6 +415,7 @@ const sr = scroller.getBoundingClientRect(); scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left); scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top); + scheduleRegion(); }); } @@ -340,6 +446,7 @@ } catch { /* ignore */ } + scheduleRegion(); } async function toggleViewerFullscreen() { @@ -559,18 +666,37 @@ oncontextmenu={onContextMenu} ontouchstart={onTouchStart} ontouchend={onTouchEnd} + onscroll={scheduleRegion} > {#if current} - {current.original_filename} + {#if regionURL && regionRect} + +
+ {current.original_filename} +
+ {:else} + {current.original_filename} + {/if} {/if} {/if} diff --git a/openspec/specs/photos/spec.md b/openspec/specs/photos/spec.md index c525a24..ecb82c3 100644 --- a/openspec/specs/photos/spec.md +++ b/openspec/specs/photos/spec.md @@ -309,3 +309,13 @@ SHALL храниться в метаданных фото, а отредакти #### Scenario: Редактирование видео - **WHEN** пользователь пытается отредактировать видео - **THEN** система отклоняет запрос + +### Requirement: Рендер видимой области правки +Для фото с правками просмотр SHALL получать изображение по запросу отдельной области: +система SHALL рендерить только видимую часть кадра с применёнными правками, а не готовить +полноразмерную отредактированную версию. Область SHALL задаваться относительными +координатами, чтобы запрос не зависел от размера оригинала. + +#### Scenario: Зум отредактированного фото +- **WHEN** пользователь увеличивает фото с правками +- **THEN** система отдаёт отрендеренную видимую область, не готовя полную версию diff --git a/openspec/specs/ui-workspace/spec.md b/openspec/specs/ui-workspace/spec.md index 26ec41d..22db24a 100644 --- a/openspec/specs/ui-workspace/spec.md +++ b/openspec/specs/ui-workspace/spec.md @@ -794,3 +794,7 @@ SHALL показывать отредактированную версию фо #### Scenario: Сброс правок - **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование» - **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду + +#### Scenario: Зум при правках +- **WHEN** фото отредактировано, и пользователь увеличивает его +- **THEN** просмотр показывает отрендеренную сервером видимую область, а зум не сбрасывается при обновлении фото