feat(web): правки применяются на фронте, фильтры в WASM
Новый алгоритм редактирования: обработка идёт только на клиенте. - Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ): он строит тональные кривые и обрабатывает пиксели. При недоступности модуля используется JS-реализация с тем же результатом — расхождение проверяется тестом. - Просмотр рисует видимую область фото с правками на canvas поверх исходной версии, поэтому изменения видны сразу, без ожидания рендера. - Правки уходят на сервер не сразу: через десять секунд без изменений или при переключении фото. Сервер сохраняет параметры и готовит варианты сетки и полноразмерную версию в фоне, о готовности сообщает через SSE — клиент подтягивает готовую версию.
This commit is contained in:
@@ -5,6 +5,8 @@ backend/static/*
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/build/
|
frontend/build/
|
||||||
frontend/.svelte-kit/
|
frontend/.svelte-kit/
|
||||||
|
# Сборка WASM-модуля фильтров (исходники и готовый модуль в репозитории).
|
||||||
|
frontend/wasm/light/target/
|
||||||
node_modules/
|
node_modules/
|
||||||
*.log
|
*.log
|
||||||
nohup.out
|
nohup.out
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ SvelteKit через `go:embed`, запуск через systemd.
|
|||||||
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
|
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
|
||||||
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS,
|
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS,
|
||||||
service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий.
|
service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий.
|
||||||
|
Фильтры правок считает WASM-модуль на Rust (`frontend/wasm/light`): пересборка
|
||||||
|
нужна только при правке самого модуля, в репозитории лежит готовый `light.wasm`.
|
||||||
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
|
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
|
||||||
`rwcarlsen/goexif` (всё чистый Go).
|
`rwcarlsen/goexif` (всё чистый Go).
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ 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)
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -137,24 +137,6 @@ 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) {
|
||||||
|
|||||||
@@ -227,10 +227,17 @@ 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. Полноразмерная версия не готовится: в зуме
|
// viewer show the edit. Полноразмерная версия готовится следом: пока она
|
||||||
// просмотр собирает только видимую область отдельным запросом.
|
// не готова, браузер рисует видимую область сам.
|
||||||
w.notify(ctx, photoID)
|
w.notify(ctx, photoID)
|
||||||
|
if full, err := encodeEdited(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) {
|
||||||
|
|||||||
Generated
+18
@@ -12,6 +12,7 @@
|
|||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -1227,6 +1228,16 @@
|
|||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
||||||
|
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||||
@@ -2259,6 +2270,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"wasm": "node scripts/build-wasm.mjs",
|
||||||
|
"build": "npm run wasm && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Сборка WASM-модуля фильтров «Свет».
|
||||||
|
//
|
||||||
|
// Модуль лежит в wasm/light (Rust, no_std) и копируется в static/light.wasm,
|
||||||
|
// откуда его забирает браузер. Если cargo недоступен, используется уже
|
||||||
|
// собранный модуль из репозитория.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { copyFileSync, existsSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const wasmDir = resolve(root, 'wasm/light');
|
||||||
|
const target = resolve(wasmDir, 'target/wasm32-unknown-unknown/release/light.wasm');
|
||||||
|
const out = resolve(root, 'static/light.wasm');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('cargo', ['build', '--release', '--target', 'wasm32-unknown-unknown'], {
|
||||||
|
cwd: wasmDir,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
copyFileSync(target, out);
|
||||||
|
console.log('wasm: static/light.wasm собран');
|
||||||
|
} catch (err) {
|
||||||
|
if (!existsSync(out)) {
|
||||||
|
console.error('wasm: не удалось собрать модуль и нет готового static/light.wasm');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.warn('wasm: cargo недоступен, используется готовый static/light.wasm');
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { photosApi } from '$lib/api';
|
import { photosApi } from '$lib/api';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
|
import { edit } from '$lib/stores/edit.svelte';
|
||||||
import {
|
import {
|
||||||
CHANNEL_COLORS,
|
CHANNEL_COLORS,
|
||||||
CHANNEL_LABELS,
|
CHANNEL_LABELS,
|
||||||
@@ -31,8 +32,9 @@
|
|||||||
let channel = $state<CurveChannel>('rgb');
|
let channel = $state<CurveChannel>('rgb');
|
||||||
|
|
||||||
const saved = $derived(readEditParams(photo));
|
const saved = $derived(readEditParams(photo));
|
||||||
const hasEdits = $derived(!isEmptyEditParams(saved));
|
|
||||||
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
|
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
|
||||||
|
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
|
||||||
|
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params));
|
||||||
// Правки сохранены, но отредактированная версия ещё рендерится.
|
// Правки сохранены, но отредактированная версия ещё рендерится.
|
||||||
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
||||||
|
|
||||||
@@ -43,15 +45,55 @@
|
|||||||
if (id === lastPhotoId) return;
|
if (id === lastPhotoId) return;
|
||||||
lastPhotoId = id;
|
lastPhotoId = id;
|
||||||
params = readEditParams(photo);
|
params = readEditParams(photo);
|
||||||
|
edit.set(id, params);
|
||||||
error = '';
|
error = '';
|
||||||
|
return () => {
|
||||||
|
// Фото переключилось: отправляем накопленные правки.
|
||||||
|
flushSave();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save(next: EditParams) {
|
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
|
||||||
|
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
|
||||||
|
const SAVE_DELAY = 10_000;
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingId = $state('');
|
||||||
|
let pendingParams = $state<EditParams | null>(null);
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
pendingId = photo.id;
|
||||||
|
pendingParams = params;
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
void flushSave();
|
||||||
|
}, SAVE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushSave() {
|
||||||
|
if (!saveTimer && !pendingParams) return;
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
}
|
||||||
|
const id = pendingId;
|
||||||
|
const next = pendingParams ? normalizeEditParams(pendingParams) : null;
|
||||||
|
pendingId = '';
|
||||||
|
pendingParams = null;
|
||||||
|
if (!id || !next) return;
|
||||||
|
if (isEmptyEditParams(next)) {
|
||||||
|
await resetEdit(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveTo(id, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTo(id: string, next: EditParams) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await photosApi.edit(photo.id, next);
|
const res = await photosApi.edit(id, next);
|
||||||
onUpdated(res.photo);
|
onUpdated(res.photo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
error = (e as Error).message;
|
||||||
@@ -62,17 +104,17 @@
|
|||||||
|
|
||||||
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
|
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
|
||||||
params = { ...params, light: { ...params.light, [key]: value } };
|
params = { ...params, light: { ...params.light, [key]: value } };
|
||||||
|
// Живые параметры нужны просмотрщику: он рисует видимую область фото
|
||||||
|
// с правками, пока сервер не отрендерил версию.
|
||||||
|
edit.set(photo.id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSliderCommit() {
|
function onSliderCommit() {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const next = normalizeEditParams(params);
|
params = normalizeEditParams(params);
|
||||||
params = next;
|
edit.set(photo.id, params);
|
||||||
if (isEmptyEditParams(next)) {
|
// На сервер отправляем не сразу: ждём, пока пользователь закончит.
|
||||||
reset();
|
scheduleSave();
|
||||||
return;
|
|
||||||
}
|
|
||||||
void save(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setChannel(next: CurveChannel) {
|
function setChannel(next: CurveChannel) {
|
||||||
@@ -123,6 +165,7 @@
|
|||||||
...params,
|
...params,
|
||||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
||||||
};
|
};
|
||||||
|
edit.set(photo.id, params);
|
||||||
dragging = index;
|
dragging = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +179,7 @@
|
|||||||
...params,
|
...params,
|
||||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
||||||
};
|
};
|
||||||
|
edit.set(photo.id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointUp() {
|
function onPointUp() {
|
||||||
@@ -167,6 +211,7 @@
|
|||||||
...params,
|
...params,
|
||||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
||||||
};
|
};
|
||||||
|
edit.set(photo.id, params);
|
||||||
onSliderCommit();
|
onSliderCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,16 +222,18 @@
|
|||||||
...params,
|
...params,
|
||||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
|
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
|
||||||
};
|
};
|
||||||
|
edit.set(photo.id, params);
|
||||||
onSliderCommit();
|
onSliderCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function resetEdit(id: string) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await photosApi.resetEdit(photo.id);
|
const res = await photosApi.resetEdit(id);
|
||||||
params = readEditParams(res.photo);
|
params = readEditParams(res.photo);
|
||||||
|
edit.set(id, params);
|
||||||
onUpdated(res.photo);
|
onUpdated(res.photo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
error = (e as Error).message;
|
||||||
@@ -198,7 +245,13 @@
|
|||||||
// Сброс доступен, когда у фото есть правки.
|
// Сброс доступен, когда у фото есть правки.
|
||||||
async function toggleReset() {
|
async function toggleReset() {
|
||||||
if (!hasEdits) return;
|
if (!hasEdits) return;
|
||||||
await reset();
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
}
|
||||||
|
pendingId = '';
|
||||||
|
pendingParams = null;
|
||||||
|
await resetEdit(photo.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
|
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
|
||||||
@@ -334,16 +387,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<p class="text-text-subtle text-xs">Сохраняем…</p>
|
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
||||||
|
{:else if dirty && !busy}
|
||||||
|
<p class="text-text-subtle text-xs">
|
||||||
|
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
|
||||||
|
</p>
|
||||||
{:else if rendering}
|
{:else if rendering}
|
||||||
<p class="text-text-subtle text-xs">Рендерим правки…</p>
|
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-danger text-xs">{error}</p>
|
<p class="text-danger text-xs">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dirty && !busy}
|
|
||||||
<p class="text-text-subtle text-xs">Есть несохранённые изменения</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,14 @@
|
|||||||
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 { edit } from '$lib/stores/edit.svelte';
|
||||||
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
|
import { applyLight, initLightWasm } from '$lib/light-wasm';
|
||||||
|
import {
|
||||||
|
isEmptyEditParams,
|
||||||
|
photoFullUrl,
|
||||||
|
photoPreviewUrl,
|
||||||
|
readEditParams
|
||||||
|
} from '$lib/edit';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -34,7 +40,6 @@
|
|||||||
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();
|
||||||
|
|
||||||
@@ -71,7 +76,31 @@
|
|||||||
(current ? photoFullUrl(current) : undefined) ??
|
(current ? photoFullUrl(current) : undefined) ??
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
|
const originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||||||
|
|
||||||
|
// Пока сервер не отрендерил текущие правки, видимую область фото рисует
|
||||||
|
// сам браузер: правки видны сразу, без ожидания рендера.
|
||||||
|
const liveParams = $derived(
|
||||||
|
edit.photoId === current?.id ? edit.params : readEditParams(current)
|
||||||
|
);
|
||||||
|
const hasEdits = $derived(!isEmptyEditParams(liveParams));
|
||||||
|
const editUrl = $derived(fresh?.edit_url ?? current?.edit_url ?? '');
|
||||||
|
const savedParams = $derived(readEditParams(current));
|
||||||
|
const pending = $derived(
|
||||||
|
edit.photoId === current?.id &&
|
||||||
|
JSON.stringify(edit.params) !== JSON.stringify(savedParams)
|
||||||
|
);
|
||||||
|
const needClientRender = $derived(hasEdits && (pending || !editUrl));
|
||||||
|
const clientPreview = $derived(zoomed && needClientRender);
|
||||||
|
|
||||||
|
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
|
||||||
|
const displaySrc = $derived(
|
||||||
|
clientPreview
|
||||||
|
? originalSrc || previewSrc
|
||||||
|
: zoomed && hiResLoaded && fullSrc
|
||||||
|
? fullSrc
|
||||||
|
: previewSrc
|
||||||
|
);
|
||||||
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
||||||
const pano = $derived(panoTag && !panoFailed);
|
const pano = $derived(panoTag && !panoFailed);
|
||||||
const panoSrc = $derived(fullSrc || previewSrc);
|
const panoSrc = $derived(fullSrc || previewSrc);
|
||||||
@@ -84,6 +113,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
|
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
|
||||||
viewer.open = true;
|
viewer.open = true;
|
||||||
|
// Заранее поднимаем WASM-модуль фильтров: предпросмотр правок должен
|
||||||
|
// быть готов сразу.
|
||||||
|
void initLightWasm();
|
||||||
return () => {
|
return () => {
|
||||||
viewer.open = false;
|
viewer.open = false;
|
||||||
};
|
};
|
||||||
@@ -112,15 +144,11 @@
|
|||||||
scale = 1;
|
scale = 1;
|
||||||
hiResLoaded = false;
|
hiResLoaded = false;
|
||||||
panoFailed = 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();
|
||||||
@@ -143,85 +171,67 @@
|
|||||||
return () => el.removeEventListener('wheel', handler);
|
return () => el.removeEventListener('wheel', handler);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Фото с правками в зуме показываем кусками: сервер рендерит только видимую
|
// Пока сервер не отрендерил отредактированную версию, видимую область
|
||||||
// область, это быстрее, чем кодировать всю картинку целиком.
|
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
|
||||||
const edited = $derived(!!current?.edit_params && Object.keys(current.edit_params).length > 0);
|
let canvas: HTMLCanvasElement | undefined = $state();
|
||||||
const frameWidth = $derived(zoomed ? baseWidth() * scale : 0);
|
let viewportFrame: number | null = null;
|
||||||
const frameHeight = $derived(zoomed ? baseHeight() * scale : 0);
|
|
||||||
|
|
||||||
let regionURL = $state('');
|
/** Рисует видимую область фото с применёнными правками. */
|
||||||
let regionRect = $state<{ left: number; top: number; width: number; height: number } | null>(
|
function renderViewport() {
|
||||||
null
|
const c = canvas;
|
||||||
);
|
const img = imgEl;
|
||||||
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 el = scroller;
|
||||||
const id = current?.id;
|
if (!c || !img || !el) return;
|
||||||
if (!el || !id || !zoomed || !edited) return;
|
const natural = img.naturalWidth;
|
||||||
const fw = frameWidth;
|
if (!natural) return;
|
||||||
const fh = frameHeight;
|
const ratio = img.clientWidth / natural;
|
||||||
if (!fw || !fh) return;
|
if (!ratio) return;
|
||||||
const x = clamp01(el.scrollLeft / fw);
|
const width = el.clientWidth;
|
||||||
const y = clamp01(el.scrollTop / fh);
|
const height = el.clientHeight;
|
||||||
// Берём область с запасом, чтобы мелкая прокрутка не требовала нового
|
if (!width || !height) return;
|
||||||
// запроса к серверу.
|
if (c.width !== width || c.height !== height) {
|
||||||
const w = Math.min(1, Math.max(0.05, (el.clientWidth / fw) * 1.5));
|
c.width = width;
|
||||||
const h = Math.min(1, Math.max(0.05, (el.clientHeight / fh) * 1.5));
|
c.height = height;
|
||||||
const rx = clamp01(x - (w - el.clientWidth / fw) / 2);
|
}
|
||||||
const ry = clamp01(y - (h - el.clientHeight / fh) / 2);
|
c.style.width = `${width}px`;
|
||||||
const token = ++regionToken;
|
c.style.height = `${height}px`;
|
||||||
try {
|
const ctx = c.getContext('2d');
|
||||||
const res = await fetch(
|
if (!ctx) return;
|
||||||
`/api/photos/${id}/edit/region?x=${rx.toFixed(4)}&y=${ry.toFixed(4)}&w=${w.toFixed(4)}&h=${h.toFixed(4)}`,
|
ctx.clearRect(0, 0, width, height);
|
||||||
{ headers: auth.access ? { Authorization: `Bearer ${auth.access}` } : {} }
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
el.scrollLeft / ratio,
|
||||||
|
el.scrollTop / ratio,
|
||||||
|
width / ratio,
|
||||||
|
height / ratio,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
const data = ctx.getImageData(0, 0, width, height);
|
||||||
// Без доступа к области показываем обычное фото.
|
applyLight(data.data, liveParams.light);
|
||||||
clearRegion();
|
ctx.putImageData(data, 0, 0);
|
||||||
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() {
|
function scheduleViewport() {
|
||||||
if (!zoomed || !edited) {
|
if (!clientPreview) return;
|
||||||
clearRegion();
|
if (viewportFrame !== null) return;
|
||||||
return;
|
viewportFrame = requestAnimationFrame(() => {
|
||||||
}
|
viewportFrame = null;
|
||||||
if (regionTimer) clearTimeout(regionTimer);
|
renderViewport();
|
||||||
regionTimer = setTimeout(() => {
|
});
|
||||||
regionTimer = null;
|
|
||||||
void loadRegion();
|
|
||||||
}, 120);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Перерисовываем вьюпорт при смене зума, правок или источника фото.
|
||||||
|
zoomed;
|
||||||
|
scale;
|
||||||
|
liveParams.light;
|
||||||
|
displaySrc;
|
||||||
|
scheduleViewport();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const onFs = () => {
|
const onFs = () => {
|
||||||
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||||||
@@ -305,9 +315,9 @@
|
|||||||
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
|
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Элемент, занимающий весь кадр фото: картинка или рамка с куском. */
|
/** Элемент, занимающий весь кадр фото. */
|
||||||
function frameElement(): HTMLElement | undefined {
|
function frameElement(): HTMLElement | undefined {
|
||||||
return frameEl ?? imgEl ?? undefined;
|
return imgEl ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
@@ -359,7 +369,7 @@
|
|||||||
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();
|
scheduleViewport();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function next() {
|
function next() {
|
||||||
@@ -370,7 +380,6 @@
|
|||||||
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;
|
||||||
@@ -382,7 +391,7 @@
|
|||||||
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();
|
scheduleViewport();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,7 +424,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();
|
scheduleViewport();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +455,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
scheduleRegion();
|
scheduleViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleViewerFullscreen() {
|
async function toggleViewerFullscreen() {
|
||||||
@@ -652,6 +661,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="relative flex min-h-0 flex-1">
|
||||||
<div
|
<div
|
||||||
bind:this={scroller}
|
bind:this={scroller}
|
||||||
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
||||||
@@ -666,26 +676,9 @@
|
|||||||
oncontextmenu={onContextMenu}
|
oncontextmenu={onContextMenu}
|
||||||
ontouchstart={onTouchStart}
|
ontouchstart={onTouchStart}
|
||||||
ontouchend={onTouchEnd}
|
ontouchend={onTouchEnd}
|
||||||
onscroll={scheduleRegion}
|
onscroll={scheduleViewport}
|
||||||
>
|
>
|
||||||
{#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}
|
||||||
@@ -697,6 +690,14 @@
|
|||||||
: 'max-h-full max-w-full object-contain'}"
|
: 'max-h-full max-w-full object-contain'}"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if clientPreview}
|
||||||
|
<!-- Правки видны сразу: браузер рисует видимую область, пока
|
||||||
|
сервер готовит отредактированную версию фото. -->
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
class="pointer-events-none absolute inset-0 z-10 select-none"
|
||||||
|
></canvas>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -183,3 +183,80 @@ export function photoPreviewUrl(photo: Photo): string | undefined {
|
|||||||
export function photoFullUrl(photo: Photo): string | undefined {
|
export function photoFullUrl(photo: Photo): string | undefined {
|
||||||
return photo.edit_url ?? photo.original_url;
|
return photo.edit_url ?? photo.original_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика
|
||||||
|
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
|
||||||
|
* в браузере совпадает с результатом рендера.
|
||||||
|
*/
|
||||||
|
export function buildCurveLUT(points?: CurvePoint[]): Uint8Array {
|
||||||
|
const lut = new Uint8Array(256);
|
||||||
|
const pts = normalizeCurve(points);
|
||||||
|
if (!pts || pts.length < 2) {
|
||||||
|
for (let i = 0; i < 256; i++) lut[i] = i;
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255);
|
||||||
|
}
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
|
||||||
|
function curveEval(points: CurvePoint[], x: number): number {
|
||||||
|
if (x <= points[0].x) return points[0].y;
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (x >= last.x) return last.y;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
if (x > points[i].x) continue;
|
||||||
|
const prev = points[i - 1];
|
||||||
|
const cur = points[i];
|
||||||
|
if (cur.x - prev.x < 1e-9) return cur.y;
|
||||||
|
const t = (x - prev.x) / (cur.x - prev.x);
|
||||||
|
return prev.y + t * (cur.y - prev.y);
|
||||||
|
}
|
||||||
|
return last.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustChannel(
|
||||||
|
value: number,
|
||||||
|
light: LightParams,
|
||||||
|
master: Uint8Array,
|
||||||
|
channel: Uint8Array
|
||||||
|
): number {
|
||||||
|
// Экспозиция (стопы).
|
||||||
|
let v = value * Math.pow(2, light.exposure);
|
||||||
|
// Контраст вокруг средней точки.
|
||||||
|
const k = 1 + light.contrast / 100;
|
||||||
|
v = 128 + (v - 128) * k;
|
||||||
|
// Света: верхняя часть диапазона.
|
||||||
|
let w = Math.pow(clamp01((v - 128) / 127), 2);
|
||||||
|
v += (light.highlights / 100) * 80 * w;
|
||||||
|
// Тени: нижняя часть диапазона.
|
||||||
|
w = Math.pow(clamp01((128 - v) / 128), 2);
|
||||||
|
v += (light.shadows / 100) * 80 * w;
|
||||||
|
// Белые: самый верх диапазона.
|
||||||
|
w = clamp01((v - 192) / 63);
|
||||||
|
v += (light.whites / 100) * 80 * w;
|
||||||
|
// Чёрные: самый низ диапазона.
|
||||||
|
w = clamp01((64 - v) / 64);
|
||||||
|
v += (light.blacks / 100) * 80 * w;
|
||||||
|
// Тоновая кривая: общая, затем канальная.
|
||||||
|
let idx = Math.min(255, Math.max(0, Math.round(v)));
|
||||||
|
v = master[idx];
|
||||||
|
idx = Math.min(255, Math.max(0, Math.round(v)));
|
||||||
|
return channel[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применяет правки «Свет» к пикселям изображения (в том же массиве). */
|
||||||
|
export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams): void {
|
||||||
|
const master = buildCurveLUT(light.curve?.rgb);
|
||||||
|
const red = buildCurveLUT(light.curve?.red);
|
||||||
|
const green = buildCurveLUT(light.curve?.green);
|
||||||
|
const blue = buildCurveLUT(light.curve?.blue);
|
||||||
|
for (let i = 0; i + 3 < data.length; i += 4) {
|
||||||
|
if (data[i + 3] === 0) continue;
|
||||||
|
data[i] = adjustChannel(data[i], light, master, red);
|
||||||
|
data[i + 1] = adjustChannel(data[i + 1], light, master, green);
|
||||||
|
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
|
||||||
|
|
||||||
|
interface LightExports {
|
||||||
|
memory: WebAssembly.Memory;
|
||||||
|
build_lut(points: number, count: number, channel: number): void;
|
||||||
|
apply(
|
||||||
|
data: number,
|
||||||
|
len: number,
|
||||||
|
exposureMul: number,
|
||||||
|
contrastK: number,
|
||||||
|
highlights: number,
|
||||||
|
shadows: number,
|
||||||
|
whites: number,
|
||||||
|
blacks: number
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasm: LightExports;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
||||||
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
|
wasm = instance.exports as unknown as LightExports;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Применяет правки модулем WASM к копии пикселей. */
|
||||||
|
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
||||||
|
const points = light.curve?.rgb ?? [];
|
||||||
|
const floats = new Float32Array(points.length * 2);
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
floats[i * 2] = p.x;
|
||||||
|
floats[i * 2 + 1] = p.y;
|
||||||
|
});
|
||||||
|
const mem = wasm.memory;
|
||||||
|
const need = Math.ceil((4096 + pixels.length) / 65536);
|
||||||
|
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536);
|
||||||
|
new Float32Array(mem.buffer, 0, floats.length).set(floats);
|
||||||
|
wasm.build_lut(0, points.length, 0);
|
||||||
|
wasm.build_lut(0, 0, 1);
|
||||||
|
wasm.build_lut(0, 0, 2);
|
||||||
|
wasm.build_lut(0, 0, 3);
|
||||||
|
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels);
|
||||||
|
wasm.apply(
|
||||||
|
4096,
|
||||||
|
pixels.length,
|
||||||
|
Math.pow(2, light.exposure),
|
||||||
|
1 + light.contrast / 100,
|
||||||
|
(light.highlights / 100) * 80,
|
||||||
|
(light.shadows / 100) * 80,
|
||||||
|
(light.whites / 100) * 80,
|
||||||
|
(light.blacks / 100) * 80
|
||||||
|
);
|
||||||
|
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WASM-модуль фильтров', () => {
|
||||||
|
it('совпадает с JS-реализацией', () => {
|
||||||
|
const light: LightParams = {
|
||||||
|
...emptyEditParams().light,
|
||||||
|
exposure: 0.8,
|
||||||
|
contrast: 25,
|
||||||
|
highlights: -30,
|
||||||
|
shadows: 40,
|
||||||
|
whites: 10,
|
||||||
|
blacks: -15,
|
||||||
|
curve: { rgb: [{ x: 0, y: 0 }, { x: 0.5, y: 0.4 }, { x: 1, y: 1 }] }
|
||||||
|
};
|
||||||
|
const source = new Uint8ClampedArray(4 * 64);
|
||||||
|
for (let i = 0; i < source.length; i += 4) {
|
||||||
|
source[i] = (i * 7) % 256;
|
||||||
|
source[i + 1] = (i * 13) % 256;
|
||||||
|
source[i + 2] = (i * 29) % 256;
|
||||||
|
source[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const expected = source.slice();
|
||||||
|
applyLightToPixels(expected, light);
|
||||||
|
const actual = applyWasm(source.slice(), light);
|
||||||
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не меняет прозрачные пиксели', () => {
|
||||||
|
const light: LightParams = { ...emptyEditParams().light, exposure: 2 };
|
||||||
|
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||||
|
const out = applyWasm(pixels, light);
|
||||||
|
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM-модуль фильтров «Свет».
|
||||||
|
*
|
||||||
|
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
||||||
|
* Он считает правки по пикселям — это горячий путь предпросмотра в
|
||||||
|
* просмотрщике. Если модуль недоступен, используется JS-реализация.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POINTS_OFFSET = 0;
|
||||||
|
const PIXELS_OFFSET = 4096;
|
||||||
|
const PAGE = 65536;
|
||||||
|
|
||||||
|
interface LightExports {
|
||||||
|
memory: WebAssembly.Memory;
|
||||||
|
build_lut(points: number, count: number, channel: number): void;
|
||||||
|
apply(
|
||||||
|
data: number,
|
||||||
|
len: number,
|
||||||
|
exposureMul: number,
|
||||||
|
contrastK: number,
|
||||||
|
highlights: number,
|
||||||
|
shadows: number,
|
||||||
|
whites: number,
|
||||||
|
blacks: number
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exportsRef: LightExports | null = null;
|
||||||
|
let loading: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
/** Загружает и инстанцирует модуль. Повторные вызовы переиспользуют его. */
|
||||||
|
export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
|
||||||
|
if (exportsRef) return Promise.resolve(true);
|
||||||
|
if (!loading) {
|
||||||
|
loading = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const bytes = await res.arrayBuffer();
|
||||||
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
|
exportsRef = instance.exports as unknown as LightExports;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lightWasmReady(): boolean {
|
||||||
|
return exportsRef !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расширяет память модуля до нужного размера. */
|
||||||
|
function ensureMemory(bytes: number): WebAssembly.Memory | null {
|
||||||
|
const ex = exportsRef;
|
||||||
|
if (!ex) return null;
|
||||||
|
const mem = ex.memory;
|
||||||
|
const need = Math.ceil(bytes / PAGE);
|
||||||
|
const have = mem.buffer.byteLength / PAGE;
|
||||||
|
if (need > have) mem.grow(need - have);
|
||||||
|
return mem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */
|
||||||
|
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void {
|
||||||
|
const ex = exportsRef;
|
||||||
|
if (!ex) return;
|
||||||
|
const floats = new Float32Array(points.length * 2);
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
floats[i * 2] = points[i].x;
|
||||||
|
floats[i * 2 + 1] = points[i].y;
|
||||||
|
}
|
||||||
|
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats);
|
||||||
|
ex.build_lut(POINTS_OFFSET, points.length, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет правки к пикселям. Возвращает false, если модуль не готов и
|
||||||
|
* вызывающий код должен использовать JS-реализацию.
|
||||||
|
*/
|
||||||
|
export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boolean {
|
||||||
|
const ex = exportsRef;
|
||||||
|
if (!ex) return false;
|
||||||
|
const bytes = PIXELS_OFFSET + data.length;
|
||||||
|
const mem = ensureMemory(bytes);
|
||||||
|
if (!mem) return false;
|
||||||
|
|
||||||
|
const curve = light.curve ?? {};
|
||||||
|
buildLut(mem, curve.rgb ?? [], 0);
|
||||||
|
buildLut(mem, curve.red ?? [], 1);
|
||||||
|
buildLut(mem, curve.green ?? [], 2);
|
||||||
|
buildLut(mem, curve.blue ?? [], 3);
|
||||||
|
|
||||||
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||||
|
ex.apply(
|
||||||
|
PIXELS_OFFSET,
|
||||||
|
data.length,
|
||||||
|
Math.pow(2, light.exposure),
|
||||||
|
1 + light.contrast / 100,
|
||||||
|
(light.highlights / 100) * 80,
|
||||||
|
(light.shadows / 100) * 80,
|
||||||
|
(light.whites / 100) * 80,
|
||||||
|
(light.blacks / 100) * 80
|
||||||
|
);
|
||||||
|
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
||||||
|
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||||
|
if (applyLightWasm(data, light)) return;
|
||||||
|
applyLightToPixels(data, light);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { emptyEditParams, type EditParams } from '$lib/edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Живые параметры правок текущего фото.
|
||||||
|
*
|
||||||
|
* Панель редактирования пишет сюда значения ползунков, а просмотрщик берёт их,
|
||||||
|
* чтобы показать правки в видимой области, пока сервер не отрендерил фото.
|
||||||
|
*/
|
||||||
|
class EditStore {
|
||||||
|
/** Фото, к которому относятся параметры. */
|
||||||
|
photoId = $state('');
|
||||||
|
/** Параметры, которые пользователь меняет прямо сейчас. */
|
||||||
|
params = $state<EditParams>(emptyEditParams());
|
||||||
|
|
||||||
|
set(photoId: string, params: EditParams) {
|
||||||
|
this.photoId = photoId;
|
||||||
|
this.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.photoId = '';
|
||||||
|
this.params = emptyEditParams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const edit = new EditStore();
|
||||||
Executable
BIN
Binary file not shown.
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "light"
|
||||||
|
version = "0.1.0"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "light"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
codegen-units = 1
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка.
|
||||||
|
//!
|
||||||
|
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
||||||
|
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
||||||
|
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go).
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
use core::panic::PanicInfo;
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn panic(_info: &PanicInfo) -> ! {
|
||||||
|
loop {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
||||||
|
const CHANNELS: usize = 4;
|
||||||
|
|
||||||
|
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS];
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn clamp01(v: f32) -> f32 {
|
||||||
|
if v < 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if v > 1.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
|
||||||
|
if v < lo {
|
||||||
|
lo
|
||||||
|
} else if v > hi {
|
||||||
|
hi
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строит таблицу соответствия 0..255 по точкам кривой.
|
||||||
|
///
|
||||||
|
/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x.
|
||||||
|
/// Точки вне диапазона обрезаются, дубликаты по x пропускаются.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) {
|
||||||
|
unsafe {
|
||||||
|
let luts = &mut *core::ptr::addr_of_mut!(LUTS);
|
||||||
|
let ch = (channel as usize).min(CHANNELS - 1);
|
||||||
|
let lut = &mut luts[ch];
|
||||||
|
if count < 2 {
|
||||||
|
for (i, slot) in lut.iter_mut().enumerate() {
|
||||||
|
*slot = i as u8;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = count as usize;
|
||||||
|
let mut seg = 0usize;
|
||||||
|
for v in 0..256 {
|
||||||
|
let x = v as f32 / 255.0;
|
||||||
|
while seg + 1 < n && x > *points.add((seg + 1) * 2) {
|
||||||
|
seg += 1;
|
||||||
|
}
|
||||||
|
let x0 = *points.add(seg * 2);
|
||||||
|
let y0 = *points.add(seg * 2 + 1);
|
||||||
|
let x1 = *points.add((seg + 1) * 2);
|
||||||
|
let y1 = *points.add((seg + 1) * 2 + 1);
|
||||||
|
let y = if x <= x0 {
|
||||||
|
y0
|
||||||
|
} else if x >= x1 {
|
||||||
|
y1
|
||||||
|
} else if x1 - x0 < 1e-6 {
|
||||||
|
y1
|
||||||
|
} else {
|
||||||
|
let t = (x - x0) / (x1 - x0);
|
||||||
|
y0 + t * (y1 - y0)
|
||||||
|
};
|
||||||
|
lut[v] = (clamp01(y) * 255.0 + 0.5) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
||||||
|
///
|
||||||
|
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental
|
||||||
|
/// функции, поэтому работает без математической библиотеки.
|
||||||
|
#[no_mangle]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub extern "C" fn apply(
|
||||||
|
data: *mut u8,
|
||||||
|
len: u32,
|
||||||
|
exposure_mul: f32,
|
||||||
|
contrast_k: f32,
|
||||||
|
highlights: f32,
|
||||||
|
shadows: f32,
|
||||||
|
whites: f32,
|
||||||
|
blacks: f32,
|
||||||
|
) {
|
||||||
|
unsafe {
|
||||||
|
let luts = &*core::ptr::addr_of!(LUTS);
|
||||||
|
let master = &luts[0];
|
||||||
|
let red = &luts[1];
|
||||||
|
let green = &luts[2];
|
||||||
|
let blue = &luts[3];
|
||||||
|
let buf = core::slice::from_raw_parts_mut(data, len as usize);
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 < buf.len() {
|
||||||
|
if buf[i + 3] != 0 {
|
||||||
|
buf[i] = adjust(
|
||||||
|
buf[i] as f32,
|
||||||
|
exposure_mul,
|
||||||
|
contrast_k,
|
||||||
|
highlights,
|
||||||
|
shadows,
|
||||||
|
whites,
|
||||||
|
blacks,
|
||||||
|
master,
|
||||||
|
red,
|
||||||
|
);
|
||||||
|
buf[i + 1] = adjust(
|
||||||
|
buf[i + 1] as f32,
|
||||||
|
exposure_mul,
|
||||||
|
contrast_k,
|
||||||
|
highlights,
|
||||||
|
shadows,
|
||||||
|
whites,
|
||||||
|
blacks,
|
||||||
|
master,
|
||||||
|
green,
|
||||||
|
);
|
||||||
|
buf[i + 2] = adjust(
|
||||||
|
buf[i + 2] as f32,
|
||||||
|
exposure_mul,
|
||||||
|
contrast_k,
|
||||||
|
highlights,
|
||||||
|
shadows,
|
||||||
|
whites,
|
||||||
|
blacks,
|
||||||
|
master,
|
||||||
|
blue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
||||||
|
/// и обе тональные кривые (общая, затем канальная).
|
||||||
|
#[inline(always)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn adjust(
|
||||||
|
value: f32,
|
||||||
|
exposure_mul: f32,
|
||||||
|
contrast_k: f32,
|
||||||
|
highlights: f32,
|
||||||
|
shadows: f32,
|
||||||
|
whites: f32,
|
||||||
|
blacks: f32,
|
||||||
|
master: &[u8; 256],
|
||||||
|
channel: &[u8; 256],
|
||||||
|
) -> u8 {
|
||||||
|
let mut v = value * exposure_mul;
|
||||||
|
v = 128.0 + (v - 128.0) * contrast_k;
|
||||||
|
let mut w = clamp01((v - 128.0) / 127.0);
|
||||||
|
v += highlights * w * w;
|
||||||
|
w = clamp01((128.0 - v) / 128.0);
|
||||||
|
v += shadows * w * w;
|
||||||
|
w = clamp01((v - 192.0) / 63.0);
|
||||||
|
v += whites * w;
|
||||||
|
w = clamp01((64.0 - v) / 64.0);
|
||||||
|
v += blacks * w;
|
||||||
|
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
||||||
|
let v = master[idx] as f32;
|
||||||
|
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
||||||
|
channel[idx]
|
||||||
|
}
|
||||||
@@ -294,6 +294,10 @@ SHALL храниться в метаданных фото, а отредакти
|
|||||||
- **WHEN** пользователь сохраняет правки фото
|
- **WHEN** пользователь сохраняет правки фото
|
||||||
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
- **THEN** параметры сохраняются в метаданных, отредактированная версия рендерится в фоне, а после рендера фото получает URL отредактированных вариантов
|
||||||
|
|
||||||
|
#### Scenario: Фоновая подготовка версий
|
||||||
|
- **WHEN** правки фото сохранены
|
||||||
|
- **THEN** система готовит варианты сетки и полноразмерную версию в фоне и сообщает о готовности
|
||||||
|
|
||||||
#### Scenario: Неразрушачность
|
#### Scenario: Неразрушачность
|
||||||
- **WHEN** фото отредактировано
|
- **WHEN** фото отредактировано
|
||||||
- **THEN** исходный объект и его варианты остаются неизменными
|
- **THEN** исходный объект и его варианты остаются неизменными
|
||||||
@@ -309,13 +313,3 @@ SHALL храниться в метаданных фото, а отредакти
|
|||||||
#### Scenario: Редактирование видео
|
#### Scenario: Редактирование видео
|
||||||
- **WHEN** пользователь пытается отредактировать видео
|
- **WHEN** пользователь пытается отредактировать видео
|
||||||
- **THEN** система отклоняет запрос
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
### Requirement: Рендер видимой области правки
|
|
||||||
Для фото с правками просмотр SHALL получать изображение по запросу отдельной области:
|
|
||||||
система SHALL рендерить только видимую часть кадра с применёнными правками, а не готовить
|
|
||||||
полноразмерную отредактированную версию. Область SHALL задаваться относительными
|
|
||||||
координатами, чтобы запрос не зависел от размера оригинала.
|
|
||||||
|
|
||||||
#### Scenario: Зум отредактированного фото
|
|
||||||
- **WHEN** пользователь увеличивает фото с правками
|
|
||||||
- **THEN** система отдаёт отрендеренную видимую область, не готовя полную версию
|
|
||||||
|
|||||||
@@ -779,9 +779,14 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
### Requirement: Вкладка «Редактирование»
|
### Requirement: Вкладка «Редактирование»
|
||||||
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
|
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
|
||||||
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
|
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
|
||||||
с выбором канала. Правки SHALL сохраняться без отдельной кнопки, а переключатель
|
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
||||||
«Сброс» SHALL удалять параметры правок и отредактированную версию фото. Сетка и просмотр
|
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
||||||
SHALL показывать отредактированную версию фото, когда она отрендерена.
|
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
||||||
|
показывать отредактированную версию фото, когда она отрендерена.
|
||||||
|
|
||||||
|
#### Scenario: Отложенная отправка
|
||||||
|
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
||||||
|
- **THEN** правки отправляются на сервер, а сервер рендерит фото в фоне
|
||||||
|
|
||||||
#### Scenario: Вкладки панели
|
#### Scenario: Вкладки панели
|
||||||
- **WHEN** открыта правая панель фото
|
- **WHEN** открыта правая панель фото
|
||||||
@@ -791,10 +796,18 @@ SHALL показывать отредактированную версию фо
|
|||||||
- **WHEN** пользователь отпускает ползунок
|
- **WHEN** пользователь отпускает ползунок
|
||||||
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
- **THEN** правки сохраняются, а после рендера фото в сетке и просмотре показывается отредактированная версия
|
||||||
|
|
||||||
|
#### Scenario: Правки видны сразу
|
||||||
|
- **WHEN** пользователь меняет ползунок, а сервер ещё не отрендерил фото
|
||||||
|
- **THEN** видимая область фото в просмотре показывает правки без ожидания рендера
|
||||||
|
|
||||||
|
#### Scenario: Фильтры считаются в браузере
|
||||||
|
- **WHEN** браузер рисует предпросмотр правок
|
||||||
|
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом
|
||||||
|
|
||||||
#### Scenario: Сброс правок
|
#### Scenario: Сброс правок
|
||||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||||
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
- **THEN** параметры правок и отредактированная версия фото удаляются, а фото возвращается к исходному виду
|
||||||
|
|
||||||
#### Scenario: Зум при правках
|
#### Scenario: Зум при правках
|
||||||
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
- **WHEN** фото отредактировано, и пользователь увеличивает его
|
||||||
- **THEN** просмотр показывает отрендеренную сервером видимую область, а зум не сбрасывается при обновлении фото
|
- **THEN** браузер рисует видимую область с правками, пока сервер не отрендерил фото, а зум не сбрасывается при обновлении фото
|
||||||
|
|||||||
Reference in New Issue
Block a user