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.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)
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(®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
|
// 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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,18 +666,37 @@
|
|||||||
oncontextmenu={onContextMenu}
|
oncontextmenu={onContextMenu}
|
||||||
ontouchstart={onTouchStart}
|
ontouchstart={onTouchStart}
|
||||||
ontouchend={onTouchEnd}
|
ontouchend={onTouchEnd}
|
||||||
|
onscroll={scheduleRegion}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<img
|
{#if regionURL && regionRect}
|
||||||
bind:this={imgEl}
|
<!-- Отредактированное фото в зуме: сервер отрендерил только
|
||||||
src={displaySrc}
|
видимую область, остальное дорисовывается при прокрутке. -->
|
||||||
alt={current.original_filename}
|
<div
|
||||||
draggable="false"
|
bind:this={frameEl}
|
||||||
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
class="relative m-auto shrink-0"
|
||||||
class="m-auto block {zoomed
|
style="width: {frameWidth}px; height: {frameHeight}px;"
|
||||||
? 'max-w-none shrink-0 select-none'
|
>
|
||||||
: 'max-h-full max-w-full object-contain'}"
|
<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
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={displaySrc}
|
||||||
|
alt={current.original_filename}
|
||||||
|
draggable="false"
|
||||||
|
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
||||||
|
class="m-auto block {zoomed
|
||||||
|
? 'max-w-none shrink-0 select-none'
|
||||||
|
: 'max-h-full max-w-full object-contain'}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -309,3 +309,13 @@ SHALL храниться в метаданных фото, а отредакти
|
|||||||
#### Scenario: Редактирование видео
|
#### Scenario: Редактирование видео
|
||||||
- **WHEN** пользователь пытается отредактировать видео
|
- **WHEN** пользователь пытается отредактировать видео
|
||||||
- **THEN** система отклоняет запрос
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
### Requirement: Рендер видимой области правки
|
||||||
|
Для фото с правками просмотр SHALL получать изображение по запросу отдельной области:
|
||||||
|
система SHALL рендерить только видимую часть кадра с применёнными правками, а не готовить
|
||||||
|
полноразмерную отредактированную версию. Область SHALL задаваться относительными
|
||||||
|
координатами, чтобы запрос не зависел от размера оригинала.
|
||||||
|
|
||||||
|
#### Scenario: Зум отредактированного фото
|
||||||
|
- **WHEN** пользователь увеличивает фото с правками
|
||||||
|
- **THEN** система отдаёт отрендеренную видимую область, не готовя полную версию
|
||||||
|
|||||||
@@ -794,3 +794,7 @@ SHALL показывать отредактированную версию фо
|
|||||||
#### Scenario: Сброс правок
|
#### Scenario: Сброс правок
|
||||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||||
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
||||||
|
|
||||||
|
#### Scenario: Зум при правках
|
||||||
|
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
||||||
|
- **THEN** просмотр показывает отрендеренную сервером видимую область, а зум не сбрасывается при обновлении фото
|
||||||
|
|||||||
Reference in New Issue
Block a user