From 520886a11d43a1e827e5bb4931435a50967b57b4 Mon Sep 17 00:00:00 2001 From: Iu Date: Wed, 21 Oct 2026 00:06:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BD=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B2?= =?UTF-8?q?=20WASM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый алгоритм редактирования: обработка идёт только на клиенте. - Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ): он строит тональные кривые и обрабатывает пиксели. При недоступности модуля используется JS-реализация с тем же результатом — расхождение проверяется тестом. - Просмотр рисует видимую область фото с правками на canvas поверх исходной версии, поэтому изменения видны сразу, без ожидания рендера. - Правки уходят на сервер не сразу: через десять секунд без изменений или при переключении фото. Сервер сохраняет параметры и готовит варианты сетки и полноразмерную версию в фоне, о готовности сообщает через SSE — клиент подтягивает готовую версию. --- .gitignore | 2 + README.md | 2 + backend/internal/app/app.go | 1 - backend/internal/photos/edit_region.go | 137 ---------- backend/internal/photos/handlers.go | 18 -- backend/internal/photos/worker.go | 11 +- frontend/package-lock.json | 18 ++ frontend/package.json | 4 +- frontend/scripts/build-wasm.mjs | 29 +++ .../src/lib/components/PhotoEditPanel.svelte | 90 +++++-- frontend/src/lib/components/Viewer.svelte | 243 +++++++++--------- frontend/src/lib/edit.ts | 77 ++++++ frontend/src/lib/light-wasm.test.ts | 89 +++++++ frontend/src/lib/light-wasm.ts | 119 +++++++++ frontend/src/lib/stores/edit.svelte.ts | 26 ++ frontend/static/light.wasm | Bin 0 -> 1482 bytes frontend/wasm/light/Cargo.lock | 7 + frontend/wasm/light/Cargo.toml | 14 + frontend/wasm/light/src/lib.rs | 178 +++++++++++++ openspec/specs/photos/spec.md | 14 +- openspec/specs/ui-workspace/spec.md | 21 +- 21 files changed, 788 insertions(+), 312 deletions(-) delete mode 100644 backend/internal/photos/edit_region.go create mode 100644 frontend/scripts/build-wasm.mjs create mode 100644 frontend/src/lib/light-wasm.test.ts create mode 100644 frontend/src/lib/light-wasm.ts create mode 100644 frontend/src/lib/stores/edit.svelte.ts create mode 100755 frontend/static/light.wasm create mode 100644 frontend/wasm/light/Cargo.lock create mode 100644 frontend/wasm/light/Cargo.toml create mode 100644 frontend/wasm/light/src/lib.rs diff --git a/.gitignore b/.gitignore index b3ae723..c0512bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ backend/static/* frontend/node_modules/ frontend/build/ frontend/.svelte-kit/ +# Сборка WASM-модуля фильтров (исходники и готовый модуль в репозитории). +frontend/wasm/light/target/ node_modules/ *.log nohup.out diff --git a/README.md b/README.md index f97d1a1..a75bc67 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ SvelteKit через `go:embed`, запуск через systemd. `s3.localnet`, path-style), нативный SSE, golang-migrate, slog. - **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS, service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий. + Фильтры правок считает WASM-модуль на Rust (`frontend/wasm/light`): пересборка + нужна только при правке самого модуля, в репозитории лежит готовый `light.wasm`. - **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` + `rwcarlsen/goexif` (всё чистый Go). diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 871061e..39013a8 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -181,7 +181,6 @@ func (s *Server) Router() *gin.Engine { authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag) authed.PATCH("/photos/:id/edit", photosHandler.Edit) authed.DELETE("/photos/:id/edit", photosHandler.ResetEdit) - authed.GET("/photos/:id/edit/region", photosHandler.EditRegion) authed.GET("/tags", photosHandler.Tags) authed.POST("/photo-batch/delete", photosHandler.DeleteBatch) diff --git a/backend/internal/photos/edit_region.go b/backend/internal/photos/edit_region.go deleted file mode 100644 index 4ce219a..0000000 --- a/backend/internal/photos/edit_region.go +++ /dev/null @@ -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() -} diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index e64fea5..c4f4dc6 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -137,24 +137,6 @@ func (h *Handler) Edit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"photo": p}) } -// EditRegion renders the visible part of an edited photo: only the pixels the -// user actually sees are processed, which is much faster than encoding the -// whole image. -func (h *Handler) EditRegion(c *gin.Context) { - var region EditRegion - if err := c.ShouldBindQuery(®ion); err != nil { - httpx.RespondError(c, httpx.BadRequest("invalid_request", "region is required")) - return - } - data, err := h.svc.RenderEditRegion(c.Request.Context(), c.Param("id"), auth.UserID(c), region) - if err != nil { - httpx.RespondError(c, err) - return - } - c.Header("Cache-Control", "private, max-age=300") - c.Data(http.StatusOK, "image/webp", data) -} - // ResetEdit removes the editing parameters of a photo together with the // edited version, returning it to its original look. func (h *Handler) ResetEdit(c *gin.Context) { diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index 90cd02a..b416e90 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -227,10 +227,17 @@ 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. Полноразмерная версия не готовится: в зуме - // просмотр собирает только видимую область отдельным запросом. + // viewer show the edit. Полноразмерная версия готовится следом: пока она + // не готова, браузер рисует видимую область сам. 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.notify(ctx, photoID) } func (w *Worker) failEdit(ctx context.Context, photoID, msg string) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 606d3a0..9c24a87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^26.6.5", "svelte": "^5.1.0", "svelte-check": "^4.1.0", "tailwindcss": "^4.0.0", @@ -1227,6 +1228,16 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "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": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -2259,6 +2270,13 @@ "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": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2db82a3..40af28b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "wasm": "node scripts/build-wasm.mjs", + "build": "npm run wasm && vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run" @@ -15,6 +16,7 @@ "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^26.6.5", "svelte": "^5.1.0", "svelte-check": "^4.1.0", "tailwindcss": "^4.0.0", diff --git a/frontend/scripts/build-wasm.mjs b/frontend/scripts/build-wasm.mjs new file mode 100644 index 0000000..45101f3 --- /dev/null +++ b/frontend/scripts/build-wasm.mjs @@ -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'); +} diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index b9d3860..035b863 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -1,6 +1,7 @@