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) {
|
||||
|
||||
@@ -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<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(() => {
|
||||
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}
|
||||
<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 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
|
||||
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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -309,3 +309,13 @@ SHALL храниться в метаданных фото, а отредакти
|
||||
#### Scenario: Редактирование видео
|
||||
- **WHEN** пользователь пытается отредактировать видео
|
||||
- **THEN** система отклоняет запрос
|
||||
|
||||
### Requirement: Рендер видимой области правки
|
||||
Для фото с правками просмотр SHALL получать изображение по запросу отдельной области:
|
||||
система SHALL рендерить только видимую часть кадра с применёнными правками, а не готовить
|
||||
полноразмерную отредактированную версию. Область SHALL задаваться относительными
|
||||
координатами, чтобы запрос не зависел от размера оригинала.
|
||||
|
||||
#### Scenario: Зум отредактированного фото
|
||||
- **WHEN** пользователь увеличивает фото с правками
|
||||
- **THEN** система отдаёт отрендеренную видимую область, не готовя полную версию
|
||||
|
||||
@@ -794,3 +794,7 @@ SHALL показывать отредактированную версию фо
|
||||
#### Scenario: Сброс правок
|
||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
||||
|
||||
#### Scenario: Зум при правках
|
||||
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
||||
- **THEN** просмотр показывает отрендеренную сервером видимую область, а зум не сбрасывается при обновлении фото
|
||||
|
||||
Reference in New Issue
Block a user