diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c0b6012..47436884 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,6 @@ name: "publish" -# This will trigger the action on each push to the `release` branch. +# Triggers on push to the `release` branch. on: push: branches: @@ -11,7 +11,7 @@ jobs: permissions: contents: write strategy: - fail-fast: true + fail-fast: false matrix: platform: [macos-latest, windows-latest] @@ -35,7 +35,7 @@ jobs: run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: setup pnpm cache with: path: ${{ env.STORE_PATH }} @@ -46,20 +46,30 @@ jobs: - name: install Rust stable uses: dtolnay/rust-toolchain@stable - - name: install dependencies (ubuntu only) - if: matrix.platform == 'ubuntu-22.04' - run: | - sudo apt-get update - sudo apt-get install -y libjavascriptcoregtk-4.1-0 libsoup-3.0-0 libglib2.0-dev libgtk-3-dev build-essential libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + - name: install uv (Python package manager) + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: cache pyfing PyInstaller venv + uses: actions/cache@v4 + with: + path: | + .venv-pyfing + build-pyinstaller + key: ${{ runner.os }}-pyfing-${{ hashFiles('scripts/pyfing-requirements.txt', 'scripts/pyfing_enhance.spec', 'scripts/pyfing_enhance.py') }} - name: install frontend dependencies - run: pnpm install # change this to npm or pnpm depending on which one you use + run: pnpm install + + - name: build pyfing sidecar + run: pnpm sidecar:build:pyfing - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version + tagName: app-v__VERSION__ releaseName: "App v__VERSION__" releaseBody: "See the assets to download this version and install." releaseDraft: false diff --git a/.gitignore b/.gitignore index 1bb27594..a4943423 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,18 @@ next-env.d.ts /public/images/*.json # vite cache .vite/ + +# pyfing sidecar build artifacts +/.venv-pyfing/ +/dist-pyinstaller/ +/build-pyinstaller/ +/scripts/__pycache__/ + +# pyfing sidecar binaries (built in CI, too large for git) +/src-tauri/bin/pyfing_enhance*.exe +/src-tauri/bin/pyfing_enhance +/src-tauri/bin/pyfing_enhance-* + +# local installer / toolchain artifacts (not project files) +/Setup.msix +/rustup-init.exe diff --git a/package.json b/package.json index 6f3d0479..d56a340e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "tauri": "tauri", "tauri:dev": "node ./scripts/tauri-dev.cjs", "sidecar:sync": "node ./scripts/sidecar-sync.cjs", + "sidecar:build:pyfing": "node ./scripts/build-pyfing-sidecar.cjs", + "sidecar:ensure:pyfing": "node ./scripts/ensure-pyfing-sidecar.cjs", "tsc:check": "tsc --noEmit", "tsc:watch": "tsc --noEmit --watch", "eslint:fix": "eslint --fix .", diff --git a/scripts/build-pyfing-sidecar.cjs b/scripts/build-pyfing-sidecar.cjs new file mode 100644 index 00000000..971ef192 --- /dev/null +++ b/scripts/build-pyfing-sidecar.cjs @@ -0,0 +1,206 @@ +// Build PyInstaller sidecar for pyfing_enhance using uv. +// Works on Windows, macOS (Intel + Apple Silicon) and Linux. +// +// Usage: pnpm sidecar:build:pyfing +// +// Steps: +// 1. Detect Rust target triple via `rustc -vV` (host: ...). +// 2. Ensure uv is available (install hint if missing). +// 3. Create venv at .venv-pyfing with Python 3.11 via `uv venv --python 3.11`. +// 4. Install requirements with `uv pip install -r ...`. +// 5. Strip unused heavy ML deps that PyInstaller would otherwise scan. +// 6. Run PyInstaller using scripts/pyfing_enhance.spec. +// 7. Copy dist/pyfing_enhance(.exe) -> src-tauri/bin/pyfing_enhance-(.exe) +// and a generic copy without triple suffix (for Command.sidecar dev fallback). +// 8. Smoke-test with `--check` (exit 0). + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, ".."); +const VENV_DIR = path.join(ROOT, ".venv-pyfing"); +const REQUIREMENTS = path.join(ROOT, "scripts", "pyfing-requirements.txt"); +const SPEC_FILE = path.join(ROOT, "scripts", "pyfing_enhance.spec"); +const DIST_DIR = path.join(ROOT, "dist-pyinstaller"); +const WORK_DIR = path.join(ROOT, "build-pyinstaller"); +const BIN_DIR = path.join(ROOT, "src-tauri", "bin"); +const IS_WINDOWS = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const EXE_EXT = IS_WINDOWS ? ".exe" : ""; +const PYTHON_VERSION = process.env.PYFING_PYTHON_VERSION || "3.11"; + +function run(cmd, args, opts = {}) { + const printable = `${cmd} ${args.join(" ")}`; + console.log(`> ${printable}`); + const result = spawnSync(cmd, args, { + stdio: "inherit", + shell: false, + ...opts, + }); + if (result.status !== 0) { + throw new Error(`Command failed (exit ${result.status}): ${printable}`); + } + return result; +} + +function captureOutput(cmd, args) { + const result = spawnSync(cmd, args, { encoding: "utf8", shell: false }); + if (result.status !== 0) { + throw new Error( + `Command failed (exit ${result.status}): ${cmd} ${args.join(" ")}\n${result.stderr || ""}` + ); + } + return result.stdout || ""; +} + +function detectTargetTriple() { + try { + const out = captureOutput("rustc", ["-vV"]); + const match = out.match(/host:\s*(\S+)/); + if (match) return match[1]; + } catch (err) { + console.warn( + "WARN: rustc not found, defaulting target triple", + err.message + ); + } + if (IS_WINDOWS) return "x86_64-pc-windows-msvc"; + if (IS_MAC) { + return process.arch === "arm64" + ? "aarch64-apple-darwin" + : "x86_64-apple-darwin"; + } + return process.arch === "arm64" + ? "aarch64-unknown-linux-gnu" + : "x86_64-unknown-linux-gnu"; +} + +function ensureUv() { + const probe = spawnSync("uv", ["--version"], { + encoding: "utf8", + shell: false, + }); + if (probe.status === 0) { + console.log(`uv detected: ${(probe.stdout || "").trim()}`); + return; + } + throw new Error( + "uv is not installed or not on PATH. Install it first:\n" + + " Windows PowerShell: irm https://astral.sh/uv/install.ps1 | iex\n" + + " macOS / Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n" + + " pip: pip install uv\n" + + "Then re-run this script." + ); +} + +function venvPython() { + if (IS_WINDOWS) { + return path.join(VENV_DIR, "Scripts", "python.exe"); + } + return path.join(VENV_DIR, "bin", "python"); +} + +function ensureVenv() { + if (fs.existsSync(venvPython())) { + console.log(`venv exists at ${VENV_DIR}`); + return; + } + console.log(`creating venv at ${VENV_DIR} with Python ${PYTHON_VERSION}`); + run("uv", ["venv", "--python", PYTHON_VERSION, VENV_DIR]); +} + +function installDeps() { + const py = venvPython(); + run("uv", ["pip", "install", "--python", py, "-r", REQUIREMENTS]); + + // Strip ML deps that may have been pulled transitively but PyInstaller + // would otherwise try to bytecode-scan them (extra time + occasional bugs). + const heavyPackages = [ + "torch", + "torchvision", + "torchaudio", + "jax", + "jaxlib", + ]; + for (const pkg of heavyPackages) { + const result = spawnSync( + "uv", + ["pip", "uninstall", "--python", py, pkg], + { stdio: "inherit", shell: false } + ); + if (result.status !== 0) { + console.log(`(skipped uninstall: ${pkg} not present)`); + } + } +} + +function buildExe() { + const py = venvPython(); + if (fs.existsSync(DIST_DIR)) { + fs.rmSync(DIST_DIR, { recursive: true, force: true }); + } + if (fs.existsSync(WORK_DIR)) { + fs.rmSync(WORK_DIR, { recursive: true, force: true }); + } + run(py, [ + "-m", + "PyInstaller", + "--noconfirm", + "--distpath", + DIST_DIR, + "--workpath", + WORK_DIR, + SPEC_FILE, + ]); +} + +function copySidecar(triple) { + if (!fs.existsSync(BIN_DIR)) { + fs.mkdirSync(BIN_DIR, { recursive: true }); + } + const built = path.join(DIST_DIR, `pyfing_enhance${EXE_EXT}`); + if (!fs.existsSync(built)) { + throw new Error(`built binary not found: ${built}`); + } + const targets = [ + path.join(BIN_DIR, `pyfing_enhance-${triple}${EXE_EXT}`), + path.join(BIN_DIR, `pyfing_enhance${EXE_EXT}`), + ]; + for (const target of targets) { + fs.copyFileSync(built, target); + if (!IS_WINDOWS) { + // Tauri requires sidecar binaries to be executable on Unix. + fs.chmodSync(target, 0o755); + } + console.log(`copied -> ${target}`); + } +} + +function smokeTest() { + const exe = path.join(BIN_DIR, `pyfing_enhance${EXE_EXT}`); + console.log(`smoke test: ${exe} --check`); + const result = spawnSync(exe, ["--check"], { stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`smoke test failed (exit ${result.status})`); + } +} + +function main() { + const triple = detectTargetTriple(); + console.log(`target triple: ${triple}`); + ensureUv(); + ensureVenv(); + installDeps(); + buildExe(); + copySidecar(triple); + smokeTest(); + console.log("OK: pyfing sidecar built"); +} + +try { + main(); +} catch (err) { + console.error(err.message || err); + process.exit(1); +} diff --git a/scripts/ensure-pyfing-sidecar.cjs b/scripts/ensure-pyfing-sidecar.cjs new file mode 100644 index 00000000..7735c026 --- /dev/null +++ b/scripts/ensure-pyfing-sidecar.cjs @@ -0,0 +1,31 @@ +// Idempotent: ensures src-tauri/bin/pyfing_enhance.exe exists. +// If missing, runs build-pyfing-sidecar.cjs to build it. +// +// Used as a pre-build step so `pnpm tauri build` "just works" on a fresh clone. + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.resolve(__dirname, ".."); +const IS_WINDOWS = process.platform === "win32"; +const EXE_EXT = IS_WINDOWS ? ".exe" : ""; +const SIDECAR = path.join( + ROOT, + "src-tauri", + "bin", + `pyfing_enhance${EXE_EXT}` +); + +if (fs.existsSync(SIDECAR)) { + console.log(`pyfing sidecar already present: ${SIDECAR}`); + process.exit(0); +} + +console.log("pyfing sidecar missing, building it now..."); +const result = spawnSync( + "node", + [path.join(__dirname, "build-pyfing-sidecar.cjs")], + { stdio: "inherit", shell: false } +); +process.exit(result.status === null ? 1 : result.status); diff --git a/scripts/pyfing-requirements.txt b/scripts/pyfing-requirements.txt new file mode 100644 index 00000000..2955a1bf --- /dev/null +++ b/scripts/pyfing-requirements.txt @@ -0,0 +1,15 @@ +pyfing==0.6 +opencv-python-headless==4.10.0.84 +numpy==1.26.4 +keras==3.5.0 + +# TensorFlow varies per platform: +# - Windows (x86_64) and Linux (x86_64): tensorflow-cpu wheels are available +# - Intel macOS: tensorflow-cpu also available +# - Apple Silicon macOS (arm64): only `tensorflow` provides arm64 wheels +tensorflow-cpu==2.17.0; sys_platform == "win32" +tensorflow-cpu==2.17.0; sys_platform == "linux" +tensorflow-cpu==2.17.0; sys_platform == "darwin" and platform_machine == "x86_64" +tensorflow==2.17.0; sys_platform == "darwin" and platform_machine == "arm64" + +pyinstaller==6.10.0 diff --git a/scripts/pyfing_enhance.py b/scripts/pyfing_enhance.py new file mode 100644 index 00000000..7e2313ca --- /dev/null +++ b/scripts/pyfing_enhance.py @@ -0,0 +1,180 @@ +"""pyfing_enhance - CLI wrapper for pyfing fingerprint enhancement. + +Usage: + pyfing_enhance --input --output --method GBFEN|SNFEN [--dpi 500] + pyfing_enhance --check + pyfing_enhance --version + +Method pipelines: + GBFEN -> GMFS + GBFOE + XSFFE + GBFEN (classical, Gabor) + SNFEN -> SUFS + SNFOE + SNFFE + SNFEN (neural, requires TensorFlow + bundled weights) + +Exit codes: + 0 success + 1 input error (missing/unreadable image) + 2 dependency missing (pyfing/cv2 not importable) + 3 processing error (pyfing pipeline raised) + 4 output write error + 5 method not available in this build (e.g. SNFEN without tensorflow) +""" + +import os +import sys +import argparse + +# Hush TensorFlow CPU/oneDNN advisory output and pre-pick a backend. +# If TF is bundled we use it (so SNFEN works); else fall back to a numpy backend +# that's good enough for the GBFEN pipeline. +os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") +os.environ.setdefault("TF_ENABLE_ONEDNN_OPTS", "0") +try: + import tensorflow # noqa: F401 pylint: disable=unused-import + os.environ.setdefault("KERAS_BACKEND", "tensorflow") + _TF_AVAILABLE = True +except Exception: # noqa: BLE001 + os.environ.setdefault("KERAS_BACKEND", "numpy") + _TF_AVAILABLE = False + +VERSION = "0.1.0" + +CLASSIC_PIPELINE = { + "segmentation": "GMFS", + "orientation": "GBFOE", + "frequency": "XSFFE", + "enhancement": "GBFEN", +} + +NEURAL_PIPELINE = { + "segmentation": "SUFS", + "orientation": "SNFOE", + "frequency": "SNFFE", + "enhancement": "SNFEN", +} + + +def _emit(stage: str) -> None: + print(f"STAGE: {stage}", file=sys.stderr, flush=True) + + +def _check_core_deps() -> int: + try: + import cv2 # noqa: F401 + import pyfing # noqa: F401 + except ImportError as exc: + print( + f"ERROR: missing core dependency: {exc}. Install: pip install pyfing opencv-python-headless", + file=sys.stderr, + ) + return 2 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Fingerprint enhancement using pyfing" + ) + parser.add_argument("--input", help="Input grayscale fingerprint image path") + parser.add_argument("--output", help="Output enhanced image path") + parser.add_argument( + "--method", + choices=["GBFEN", "SNFEN"], + default="GBFEN", + help="Enhancement method", + ) + parser.add_argument("--dpi", type=int, default=500, help="Image DPI (default: 500)") + parser.add_argument( + "--check", + action="store_true", + help="Verify dependencies and exit (0=ok, 2=missing)", + ) + parser.add_argument( + "--version", + action="store_true", + help="Print version and exit", + ) + args = parser.parse_args() + + if args.version: + print(VERSION) + return 0 + + if args.check: + status = _check_core_deps() + if status != 0: + return status + print( + f"INFO: tensorflow_available={_TF_AVAILABLE} keras_backend={os.environ.get('KERAS_BACKEND')}", + file=sys.stderr, + ) + return 0 + + if not args.input or not args.output: + print("ERROR: --input and --output required", file=sys.stderr) + return 1 + + _emit("ready") + + dep_status = _check_core_deps() + if dep_status != 0: + return dep_status + + if args.method == "SNFEN" and not _TF_AVAILABLE: + print( + "ERROR: SNFEN requires tensorflow which is not available in this build. Use GBFEN.", + file=sys.stderr, + ) + return 5 + + pipeline = NEURAL_PIPELINE if args.method == "SNFEN" else CLASSIC_PIPELINE + + import cv2 as cv + import pyfing as pf + + fp = cv.imread(args.input, cv.IMREAD_GRAYSCALE) + if fp is None: + print(f"ERROR: cannot read image: {args.input}", file=sys.stderr) + return 1 + + print( + f"INFO: input={args.input} shape={fp.shape[1]}x{fp.shape[0]} method={args.method} dpi={args.dpi} pipeline={pipeline}", + file=sys.stderr, + flush=True, + ) + + try: + _emit("segmentation") + mask = pf.fingerprint_segmentation( + fp, dpi=args.dpi, method=pipeline["segmentation"] + ) + + _emit("orientation") + orientations = pf.orientation_field_estimation( + fp, mask, dpi=args.dpi, method=pipeline["orientation"] + ) + + _emit("frequency") + frequencies = pf.frequency_estimation( + fp, orientations, mask, dpi=args.dpi, method=pipeline["frequency"] + ) + + _emit("enhancement") + enhanced = pf.fingerprint_enhancement( + fp, orientations, frequencies, mask, + dpi=args.dpi, method=pipeline["enhancement"] + ) + except Exception as exc: # noqa: BLE001 + print(f"ERROR: pyfing pipeline failed: {exc}", file=sys.stderr) + return 3 + + _emit("writing") + success = cv.imwrite(args.output, enhanced) + if not success: + print(f"ERROR: failed to write output: {args.output}", file=sys.stderr) + return 4 + + print(f"DONE: {args.output}", file=sys.stderr, flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pyfing_enhance.spec b/scripts/pyfing_enhance.spec new file mode 100644 index 00000000..313f1f97 --- /dev/null +++ b/scripts/pyfing_enhance.spec @@ -0,0 +1,89 @@ +# PyInstaller spec for pyfing_enhance sidecar +# Build: pyinstaller scripts/pyfing_enhance.spec +# +# Notes: +# - keras + tensorflow-cpu MUST stay bundled because SNFEN is a hard requirement +# (uses pyfing's neural pipeline: SUFS + SNFOE + SNFFE + SNFEN). +# - pyfing/models/*.weights.h5 must be collected as data files; they are loaded +# at runtime via importlib.resources from the pyfing package directory. +# - torch / jax / matplotlib / Qt are excluded — pyfing doesn't need them. +# pylint: disable=undefined-variable + +import os +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +EXCLUDED_MODULES = [ + "torch", + "torchvision", + "torchaudio", + "jax", + "jaxlib", + "tkinter", + "matplotlib", + "PIL", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "pytest", + "IPython", + "jupyter", + "notebook", + "pandas", + "sklearn", + "tensorboard", +] + +# Collect all bundled neural-network weight files shipped inside pyfing. +pyfing_datas = collect_data_files("pyfing", includes=["models/*"]) + +# Make sure all pyfing submodules end up in the bundle (it's a small package). +pyfing_hidden = collect_submodules("pyfing") + +a = Analysis( + ['pyfing_enhance.py'], + pathex=[], + binaries=[], + datas=pyfing_datas, + hiddenimports=[ + 'cv2', + 'numpy', + 'keras', + 'tensorflow', + 'tensorflow_intel', + ] + pyfing_hidden, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=EXCLUDED_MODULES, + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='pyfing_enhance', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 10908835..d5a0b5ad 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -51,6 +51,11 @@ "name": "bin/sourceafis_cli", "sidecar": true, "args": true + }, + { + "name": "bin/pyfing_enhance", + "sidecar": true, + "args": true } ] }, diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 73d49ced..7c7d4a21 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -52,6 +52,11 @@ "args": true, "name": "bin/sourceafis_cli", "sidecar": true + }, + { + "args": true, + "name": "bin/pyfing_enhance", + "sidecar": true } ] }, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a669f9b7..56f28036 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.6.10", "identifier": "com.biometrics-studio.app", "build": { - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "pnpm sidecar:ensure:pyfing && pnpm build", "beforeDevCommand": "pnpm dev", "frontendDist": "../dist", "devUrl": "http://localhost:1420" @@ -53,7 +53,7 @@ "category": "DeveloperTool", "copyright": "", "targets": "all", - "externalBin": ["bin/sourceafis_cli"], + "externalBin": ["bin/sourceafis_cli", "bin/pyfing_enhance"], "icon": [ "../public/logo_32.png", "../public/logo_128.png", @@ -76,4 +76,3 @@ } } } - diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 0ab36db3..cbc0ceed 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -2,8 +2,6 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { WindowControls } from "@/components/menu/window-controls"; import { Menubar } from "@/components/ui/menubar"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils/shadcn"; import { ICON } from "@/lib/utils/const"; @@ -14,21 +12,114 @@ import { basename, extname, join, dirname } from "@tauri-apps/api/path"; import { toast } from "sonner"; import { useSettingsSync } from "@/lib/hooks/useSettingsSync"; import ImageDpiControls from "@/components/edit-window/dpi/image-dpi-controls"; -import ImageFftControls from "@/components/edit-window/fft/image-fft-controls"; +import { + AnyModifier, + EnhancementModifier, + EnhancementParams, + ModifierType, + isEnhancementModifier, +} from "@/lib/imageModifiers/types"; +import { + MODIFIER_REGISTRY, + buildCssFilter, +} from "@/lib/imageModifiers/registry"; +import { applyPipelineToImage } from "@/lib/imageModifiers/pipeline"; +import { AddModifierButton } from "@/components/edit-window/modifiers/AddModifierButton"; +import { ModifierList } from "@/components/edit-window/modifiers/ModifierList"; +import { ModifierSettingsDialog } from "@/components/edit-window/modifiers/ModifierSettingsDialog"; +import { + runPyfingEnhancement, + PyfingMethod, +} from "@/lib/external-tools/pyfing/runPyfingEnhancement"; + +// ─── File helpers (unchanged from old implementation) ───────────────────────── + +async function findUniqueFilePath( + directory: string, + baseName: string, + timestamp: string, + extension: string, + initialPath: string +): Promise { + let fileExists = false; + try { + fileExists = await exists(initialPath); + } catch { + return initialPath; + } + if (!fileExists) return initialPath; + + const maxAttempts = 100; + const pathsToCheck: Promise<{ path: string; exists: boolean }>[] = []; + for (let i = 1; i <= maxAttempts; i += 1) { + const numberedFilename = `${baseName}_edited_${timestamp}_${i}${extension}`; + const numberedPathPromise = join(directory, numberedFilename); + pathsToCheck.push( + numberedPathPromise.then(path => + exists(path) + .then(e => ({ path, exists: e })) + .catch(() => ({ path, exists: false })) + ) + ); + } + const results = await Promise.all(pathsToCheck); + const firstAvailable = results.find(r => !r.exists); + return ( + firstAvailable?.path ?? results[results.length - 1]?.path ?? initialPath + ); +} + +async function generateFilename(p: string) { + const originalFilename = await basename(p); + const extension = await extname(p); + const extWithDot = extension + ? extension.startsWith(".") + ? extension + : `.${extension}` + : ".png"; + const lastDotIndex = originalFilename.lastIndexOf("."); + const nameWithoutExt = + lastDotIndex > 0 + ? originalFilename.slice(0, lastDotIndex) + : originalFilename; + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + return { nameWithoutExt, extWithDot, timestamp }; +} + +async function pathToBlobUrl(path: string): Promise { + const bytes = await readFile(path); + // The TS DOM lib types Blob's BlobPart with ArrayBuffer (not ArrayBufferLike) + // which conflicts with Tauri's Uint8Array. The cast through + // unknown is safe because Blob accepts any TypedArray at runtime. + const blob = new Blob([bytes as unknown as ArrayBuffer], { + type: "image/png", + }); + return URL.createObjectURL(blob); +} + +function pyfingMethodFromType(type: "gbfen" | "snfen"): PyfingMethod { + return type === "gbfen" ? "GBFEN" : "SNFEN"; +} + +// ─── Component ──────────────────────────────────────────────────────────────── export function EditWindow() { const { t } = useTranslation(["tooltip", "keywords"]); useSettingsSync(); + // ── Image state ────────────────────────────────────────────────────────── const [imagePath, setImagePath] = useState(null); - const [imageUrl, setImageUrl] = useState(null); + const [originalUrl, setOriginalUrl] = useState(null); const [imageName, setImageName] = useState(null); const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>( null ); const [error, setError] = useState(null); - const [brightness, setBrightness] = useState(100); - const [contrast, setContrast] = useState(100); + + // ── View state ─────────────────────────────────────────────────────────── const [zoom, setZoom] = useState(1); const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); @@ -37,168 +128,73 @@ export function EditWindow() { y: 0, }); + // ── Modifier pipeline state ────────────────────────────────────────────── + const [modifiers, setModifiers] = useState([]); + const [editingModifierId, setEditingModifierId] = useState( + null + ); + const imageRef = useRef(null); const containerRef = useRef(null); const canvasRef = useRef(null); - const fftCanvasRef = useRef(null); const TRANSFORM_ORIGIN = "center center"; - const findUniqueFilePath = async ( - directory: string, - baseName: string, - timestamp: string, - extension: string, - initialPath: string - ): Promise => { - let fileExists = false; - try { - fileExists = await exists(initialPath); - } catch { - return initialPath; - } + // ── CSS filter (live, lightweight) ─────────────────────────────────────── + const cssFilter = buildCssFilter(modifiers); - if (!fileExists) { - return initialPath; - } + // ── Display URL: active enhancement (if any) or the original ───────────── + const activeEnhancement = [...modifiers] + .reverse() + .find( + (m): m is EnhancementModifier => + isEnhancementModifier(m) && + m.enabled && + m.params.status === "ready" && + Boolean(m.params.runtimeOutputUrl) + ); - const maxAttempts = 100; - const pathsToCheck: Promise<{ path: string; exists: boolean }>[] = []; - - for (let i = 1; i <= maxAttempts; i += 1) { - const numberedFilename = `${baseName}_edited_${timestamp}_${i}${extension}`; - const numberedPathPromise = join(directory, numberedFilename); - pathsToCheck.push( - numberedPathPromise.then(path => - exists(path) - .then(exists => ({ path, exists })) - .catch(() => ({ path, exists: false })) - ) - ); - } + const displayUrl = + activeEnhancement?.params.runtimeOutputUrl ?? originalUrl; - const results = await Promise.all(pathsToCheck); - const firstAvailable = results.find(result => !result.exists); + // ── Image loading ──────────────────────────────────────────────────────── - if (firstAvailable) { - return firstAvailable.path; - } - - return results[results.length - 1]?.path ?? initialPath; - }; - - const processImageWithFilters = async ( - imgRef: React.RefObject, - brightnessValue: number, - contrastValue: number - ): Promise => { - if (!imgRef.current) throw new Error("Image not loaded"); - const img = imgRef.current; - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Failed to get canvas context"); - } - - canvas.width = img.naturalWidth || img.width; - canvas.height = img.naturalHeight || img.height; - - if (brightnessValue !== 100 || contrastValue !== 100) { - ctx.filter = `brightness(${brightnessValue / 100}) contrast(${contrastValue / 100})`; - } - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - ctx.filter = "none"; - - const editedBlob = await new Promise((resolve, reject) => { - canvas.toBlob( - blob => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to convert canvas to blob")); - } - }, - "image/png", - 1.0 - ); - }); - - const arrayBuffer = await editedBlob.arrayBuffer(); - return new Uint8Array(arrayBuffer); - }; - - const generateFilename = async ( - imagePath: string - ): Promise<{ - nameWithoutExt: string; - extWithDot: string; - timestamp: string; - }> => { - const originalFilename = await basename(imagePath); - const extension = await extname(imagePath); - - const extWithDot = extension - ? extension.startsWith(".") - ? extension - : `.${extension}` - : ".png"; - - const lastDotIndex = originalFilename.lastIndexOf("."); - const nameWithoutExt = - lastDotIndex > 0 - ? originalFilename.slice(0, lastDotIndex) - : originalFilename; - - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - - return { nameWithoutExt, extWithDot, timestamp }; - }; - - const loadImage = async (path: string) => { + const loadImage = useCallback(async (path: string) => { try { setError(null); - setImageUrl(null); - const imageBytes = await readFile(path); - const blob = new Blob([imageBytes]); - const url = URL.createObjectURL(blob); - setImageUrl(url); + setOriginalUrl(null); + const url = await pathToBlobUrl(path); + setOriginalUrl(url); setImageName(await basename(path)); setZoom(1); setPan({ x: 0, y: 0 }); } catch (err) { - const errorMessage = + const msg = err instanceof Error ? err.message : "Failed to load image"; - setError(`${errorMessage} (Path: ${path})`); - setImageUrl(null); + setError(`${msg} (Path: ${path})`); + setOriginalUrl(null); } - }; + }, []); - const handleWheel = (e: React.WheelEvent) => { - if (!imageUrl || !containerRef.current || !imageRef.current) return; + // ── Wheel / pan handlers ───────────────────────────────────────────────── + const handleWheel = (e: React.WheelEvent) => { + if (!displayUrl || !containerRef.current || !imageRef.current) return; e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.1, Math.min(10, zoom * delta)); - const containerRect = containerRef.current.getBoundingClientRect(); - - const containerCenterX = containerRect.width / 2; - const containerCenterY = containerRect.height / 2; - const mouseX = e.clientX - containerRect.left; - const mouseY = e.clientY - containerRect.top; - - const imageX = (mouseX - containerCenterX - pan.x) / zoom; - const imageY = (mouseY - containerCenterY - pan.y) / zoom; - - const newPanX = mouseX - containerCenterX - imageX * newZoom; - const newPanY = mouseY - containerCenterY - imageY * newZoom; - + const cx = containerRect.width / 2; + const cy = containerRect.height / 2; + const mx = e.clientX - containerRect.left; + const my = e.clientY - containerRect.top; + const imageX = (mx - cx - pan.x) / zoom; + const imageY = (my - cy - pan.y) / zoom; setZoom(newZoom); - setPan({ x: newPanX, y: newPanY }); + setPan({ + x: mx - cx - imageX * newZoom, + y: my - cy - imageY * newZoom, + }); }; const handleMouseDown = (e: React.MouseEvent) => { @@ -215,48 +211,35 @@ export function EditWindow() { }); }; - const handleMouseUp = () => { - setIsDragging(false); - }; - - /** Forward wheel events from the FFT overlay to zoom the image */ - const fftHandleWheel = useCallback((e: WheelEvent) => { - if (!containerRef.current || !imageRef.current) return; - const delta = e.deltaY > 0 ? 0.9 : 1.1; - setZoom(prev => { - const newZoom = Math.max(0.1, Math.min(10, prev * delta)); - const containerRect = containerRef.current!.getBoundingClientRect(); - const cx = containerRect.width / 2; - const cy = containerRect.height / 2; - const mx = e.clientX - containerRect.left; - const my = e.clientY - containerRect.top; - setPan(p => { - const imgX = (mx - cx - p.x) / prev; - const imgY = (my - cy - p.y) / prev; - return { - x: mx - cx - imgX * newZoom, - y: my - cy - imgY * newZoom, - }; - }); - return newZoom; - }); - }, []); - - /** Forward middle-button drag from FFT overlay to pan the image */ - const fftHandleMiddleDrag = useCallback((dx: number, dy: number) => { - setPan(prev => ({ x: prev.x + dx, y: prev.y + dy })); - }, []); + const handleMouseUp = () => setIsDragging(false); const handleDoubleClick = () => { setZoom(1); setPan({ x: 0, y: 0 }); }; - const resetZoom = () => { setZoom(1); setPan({ x: 0, y: 0 }); }; + // ── Canvas sync (DPI overlay) ──────────────────────────────────────────── + + function syncCanvasToImage(img: HTMLImageElement, cvs: HTMLCanvasElement) { + const width = img.naturalWidth; + const height = img.naturalHeight; + Object.assign(cvs, { width, height }); + Object.assign(cvs.style, { + width: `${img.width}px`, + height: `${img.height}px`, + position: "absolute", + zIndex: "10", + }); + const ctx = cvs.getContext("2d")!; + ctx.setTransform(1, 0, 0, 1, 0, 0); + } + + // ── Effects ────────────────────────────────────────────────────────────── + useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const pathFromUrl = urlParams.get("imagePath"); @@ -268,16 +251,12 @@ export function EditWindow() { loadImage(normalizedPath); } - const setupListener = async () => { - return listen("image-path-changed", event => { - setImagePath(event.payload); - loadImage(event.payload); - }); - }; - let unlistenPromise: Promise<() => void> | null = null; - setupListener().then(unlisten => { - unlistenPromise = Promise.resolve(unlisten); + listen("image-path-changed", event => { + setImagePath(event.payload); + loadImage(event.payload); + }).then(u => { + unlistenPromise = Promise.resolve(u); }); return () => { @@ -285,15 +264,32 @@ export function EditWindow() { unlistenPromise.then(fn => fn()); } }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Revoke the original blob URL when the source image changes / unmounts. useEffect(() => { return () => { - if (imageUrl) { - URL.revokeObjectURL(imageUrl); + if (originalUrl) { + URL.revokeObjectURL(originalUrl); } }; - }, [imageUrl]); + }, [originalUrl]); + + // Revoke enhancement output URLs when their modifiers go away. + useEffect(() => { + const liveUrls = new Set( + modifiers + .filter(isEnhancementModifier) + .map(m => m.params.runtimeOutputUrl) + .filter((u): u is string => Boolean(u)) + ); + return () => { + // Only run on unmount; per-modifier cleanup happens in handleRemoveModifier. + liveUrls.forEach(u => URL.revokeObjectURL(u)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { const img = imageRef.current; @@ -304,25 +300,7 @@ export function EditWindow() { if (img.complete && img.naturalWidth) updateSize(); img.addEventListener("load", updateSize); return () => img.removeEventListener("load", updateSize); - }, [imageUrl]); - - function syncCanvasToImage(img: HTMLImageElement, cvs: HTMLCanvasElement) { - if (!img || !cvs) return; - - const width = img.naturalWidth; - const height = img.naturalHeight; - - Object.assign(cvs, { width, height }); - Object.assign(cvs.style, { - width: `${img.width}px`, - height: `${img.height}px`, - position: "absolute", - zIndex: "10", - }); - - const ctx = cvs.getContext("2d")!; - ctx.setTransform(1, 0, 0, 1, 0, 0); - } + }, [displayUrl]); useEffect(() => { const img = imageRef.current; @@ -344,58 +322,201 @@ export function EditWindow() { img.removeEventListener("load", sync); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUrl]); + }, [displayUrl]); + + // ── Pyfing run ─────────────────────────────────────────────────────────── + + const updateModifierParams = useCallback( + (id: string, params: Partial) => { + setModifiers(prev => + prev.map(m => + m.id === id + ? ({ + ...m, + params: { ...m.params, ...params }, + } as AnyModifier) + : m + ) + ); + }, + [] + ); - /* Sync FFT canvas CSS position to the image (without clearing internal dims) */ - useEffect(() => { - const img = imageRef.current; - const fftCanvas = fftCanvasRef.current; - if (!img || !fftCanvas) return undefined; - - const syncFft = () => { - requestAnimationFrame(() => { - if (!fftCanvas || !img) return; - Object.assign(fftCanvas.style, { - width: `${img.width}px`, - height: `${img.height}px`, - position: "absolute", - zIndex: "11", + const runEnhancement = useCallback( + async (modifierId: string, type: "gbfen" | "snfen", dpi: number) => { + if (!imagePath) { + toast.error("No source image loaded"); + return; + } + + const method = pyfingMethodFromType(type); + + // Mark as processing + updateModifierParams(modifierId, { + status: "processing", + errorMessage: null, + } satisfies Partial as Partial< + AnyModifier["params"] + >); + + try { + const { nameWithoutExt, extWithDot, timestamp } = + await generateFilename(imagePath); + const newFilename = `${nameWithoutExt}_${method}_${timestamp}${extWithDot}`; + const imageDir = await dirname(imagePath); + const outputPath = await join(imageDir, newFilename); + + const result = await runPyfingEnhancement({ + imagePath, + outputPath, + method, + dpi, + }); + + const url = await pathToBlobUrl(result.outputPath); + + updateModifierParams(modifierId, { + status: "ready", + outputPath: result.outputPath, + durationMs: result.durationMs, + errorMessage: null, + runtimeOutputUrl: url, + } satisfies Partial as Partial< + AnyModifier["params"] + >); + + const toastKey = + type === "gbfen" + ? "Enhancement: GBFEN done in {{seconds}}s" + : "Enhancement: SNFEN done in {{seconds}}s"; + toast.success( + t(toastKey, { + ns: "tooltip", + seconds: (result.durationMs / 1000).toFixed(1), + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + updateModifierParams(modifierId, { + status: "failed", + errorMessage: msg, + outputPath: null, + runtimeOutputUrl: null, + } satisfies Partial as Partial< + AnyModifier["params"] + >); + toast.error( + t("Enhancement failed: {{error}}", { + ns: "tooltip", + error: msg, + }) + ); + } + }, + [imagePath, t, updateModifierParams] + ); + + // ── Modifier helpers ───────────────────────────────────────────────────── + + const handleAddModifier = useCallback( + (type: ModifierType) => { + const def = MODIFIER_REGISTRY.find(d => d.type === type); + if (!def) return; + const newMod = def.create() as AnyModifier; + setModifiers(prev => [...prev, newMod]); + + if (type === "gbfen" || type === "snfen") { + // Fire the external tool; modifier params will be updated as it progresses. + const { dpi } = newMod.params as EnhancementParams; + runEnhancement(newMod.id, type, dpi).catch(() => { + /* errors are surfaced via toast/state by runEnhancement itself */ }); + return; + } + + // For non-enhancement modifiers, open the settings dialog automatically. + // Use a timeout so the DropdownMenu doesn't intercept the click-outside + // and immediately dismiss the dialog. + setTimeout(() => setEditingModifierId(newMod.id), 50); + }, + [runEnhancement] + ); + + const handleUpdateModifier = useCallback( + (id: string, params: Partial) => { + updateModifierParams(id, params); + }, + [updateModifierParams] + ); + + const handleToggleModifier = useCallback((id: string) => { + setModifiers(prev => + prev.map(m => (m.id === id ? { ...m, enabled: !m.enabled } : m)) + ); + }, []); + + const handleRemoveModifier = useCallback((id: string) => { + setModifiers(prev => { + const target = prev.find(m => m.id === id); + if (target && isEnhancementModifier(target)) { + const url = target.params.runtimeOutputUrl; + if (url) URL.revokeObjectURL(url); + } + return prev.filter(m => m.id !== id); + }); + setEditingModifierId(prev => (prev === id ? null : prev)); + }, []); + + const handleReorderModifiers = useCallback( + (fromIndex: number, toIndex: number) => { + setModifiers(prev => { + const next = [...prev]; + const [removed] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, removed!); + return next; }); - }; + }, + [] + ); - const resizeObserver = new ResizeObserver(syncFft); - resizeObserver.observe(img); + const handleRerunEnhancement = useCallback( + (id: string) => { + const target = modifiers.find(m => m.id === id); + if (!target || !isEnhancementModifier(target)) return; + // Revoke the previous URL so we don't leak. + if (target.params.runtimeOutputUrl) { + URL.revokeObjectURL(target.params.runtimeOutputUrl); + updateModifierParams(id, { + runtimeOutputUrl: null, + } satisfies Partial as Partial< + AnyModifier["params"] + >); + } + runEnhancement(id, target.type, target.params.dpi).catch(() => { + /* errors are surfaced via toast/state by runEnhancement itself */ + }); + }, + [modifiers, runEnhancement, updateModifierParams] + ); - if (img.complete) syncFft(); - img.addEventListener("load", syncFft); + const editingModifier = + modifiers.find(m => m.id === editingModifierId) ?? null; - return () => { - resizeObserver.disconnect(); - img.removeEventListener("load", syncFft); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUrl]); + // ── Save ───────────────────────────────────────────────────────────────── const saveEditedImage = async () => { - if (!imageUrl || !imagePath) { - return; - } - + if (!displayUrl || !imagePath || !imageRef.current) return; try { - const uint8Array = await processImageWithFilters( - imageRef, - brightness, - contrast + const uint8Array = await applyPipelineToImage( + imageRef.current, + modifiers ); const { nameWithoutExt, extWithDot, timestamp } = await generateFilename(imagePath); const newFilename = `${nameWithoutExt}_edited_${timestamp}${extWithDot}`; - const imageDir = await dirname(imagePath); const newImagePath = await join(imageDir, newFilename); - const finalPath = await findUniqueFilePath( imageDir, nameWithoutExt, @@ -405,36 +526,35 @@ export function EditWindow() { ); await writeFile(finalPath, uint8Array); - const fileWasWritten = await exists(finalPath); - if (!fileWasWritten) { + if (!fileWasWritten) throw new Error(`File was not created at path: ${finalPath}`); - } await emit("image-reload-requested", { originalPath: imagePath, newPath: finalPath, }); - setImagePath(finalPath); - setImageName(await basename(finalPath)); - const blob = new Blob([uint8Array], { type: "image/png" }); - const url = URL.createObjectURL(blob); - setImageUrl(url); - toast.success(t("Image saved successfully", { ns: "tooltip" })); } catch (err) { - const errorMessage = - err instanceof Error ? err.message : String(err); + const msg = err instanceof Error ? err.message : String(err); toast.error( t("Failed to save image: {{error}}", { ns: "tooltip", - error: errorMessage, + error: msg, }) ); } }; + // ───────────────────────────────────────────────────────────────────────── + + const enhancing = modifiers.some( + m => + isEnhancementModifier(m) && + (m.params.status === "processing" || m.params.status === "pending") + ); + return (
+ {/* ── Image viewer ─────────────────────────────────────────── */}
{error ? (
@@ -478,7 +599,7 @@ export function EditWindow() {

- ) : imageUrl ? ( + ) : displayUrl ? (
- {zoom !== 1 && (
-
- {imageName && ( -
+ + {/* ── Sidebar ───────────────────────────────────────────────── */} +
+
+ {/* Image info */} + {imageName && ( +
+

+ Info +

+

+ {imageName} +

+ {imageSize && ( +

+ {imageSize.w} × {imageSize.h} px +

+ )} +
+ )} + +
+ + {/* Modifier pipeline */} +

- Info + {t("Adjustments", { ns: "keywords" })}

-

- {imageName} -

- {imageSize && ( -

- {imageSize.w} × {imageSize.h} px + + + {enhancing && ( +

+ {t("Enhancing image...", { ns: "tooltip" })}

)}
- )} -
+
+ + {/* DPI controls (unchanged) */} +
+

+ DPI +

+ +
+
-
-

- {t("Tools", { ns: "keywords" })} -

+ {/* Fixed bottom save button */} +
- -
- -
-

- {t("Adjustments", { ns: "keywords" })} -

-
- -
- - setBrightness(Number(e.target.value)) - } - className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - disabled={!imageUrl} - /> - - {brightness}% - -
-
-
- -
- - setContrast(Number(e.target.value)) - } - className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - disabled={!imageUrl} - /> - - {contrast}% - -
-
-
- -
- -
-

- DPI -

- -
- -
- -
-

- FFT -

- { - if (imageUrl && imageUrl.startsWith("blob:")) { - URL.revokeObjectURL(imageUrl); - } - setImageUrl(dataUrl); - }} - onWheel={fftHandleWheel} - onMiddleDrag={fftHandleMiddleDrag} - /> -
+ + {/* Modifier settings dialog (rendered outside the sidebar for correct stacking) */} + setEditingModifierId(null)} + onUpdate={handleUpdateModifier} + onRerunEnhancement={handleRerunEnhancement} + />
); } diff --git a/src/components/edit-window/modifiers/AddModifierButton.tsx b/src/components/edit-window/modifiers/AddModifierButton.tsx new file mode 100644 index 00000000..58a6f5f3 --- /dev/null +++ b/src/components/edit-window/modifiers/AddModifierButton.tsx @@ -0,0 +1,164 @@ +import React, { useState } from "react"; +import { Plus, Info, Sparkles, ChevronDown, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ICON } from "@/lib/utils/const"; +import { MODIFIER_REGISTRY } from "@/lib/imageModifiers/registry"; +import { ModifierType } from "@/lib/imageModifiers/types"; +import { cn } from "@/lib/utils/shadcn"; + +import { ModifierIcon } from "./ModifierList"; + +interface AddModifierButtonProps { + onAdd: (type: ModifierType) => void; + disabled?: boolean; +} + +export function AddModifierButton({ onAdd, disabled }: AddModifierButtonProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + const [enhancementOpen, setEnhancementOpen] = useState(false); + + const defaultDefs = MODIFIER_REGISTRY.filter( + d => (d.group ?? "default") === "default" + ); + const enhancementDefs = MODIFIER_REGISTRY.filter( + d => d.group === "enhancement" + ); + + return ( + setEnhancementOpen(false)}> + + + + + {defaultDefs.map(def => ( + onAdd(def.type)} + className="flex items-center gap-2 cursor-pointer" + > + + + {t(def.labelKey as never, { + ns: "tooltip", + defaultValue: def.labelKey, + })} + +
e.stopPropagation()} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + > + +
+
+ ))} + + {enhancementDefs.length > 0 && } + + {enhancementDefs.length > 0 && ( + { + e.preventDefault(); + setEnhancementOpen(v => !v); + }} + > + + + {t("Image enhancement", { + ns: "tooltip", + defaultValue: "Image enhancement", + })} + + {enhancementOpen ? ( + + ) : ( + + )} + + )} + + {enhancementOpen && + enhancementDefs.map(def => ( + onAdd(def.type)} + > + + + {t(def.labelKey as never, { + ns: "tooltip", + defaultValue: def.labelKey, + })} + +
e.stopPropagation()} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + > + +
+
+ ))} +
+
+ ); +} diff --git a/src/components/edit-window/modifiers/ModifierList.tsx b/src/components/edit-window/modifiers/ModifierList.tsx new file mode 100644 index 00000000..c418e3d5 --- /dev/null +++ b/src/components/edit-window/modifiers/ModifierList.tsx @@ -0,0 +1,412 @@ +import React, { useCallback, useRef, useState } from "react"; +import { + GripVertical, + Pencil, + Trash2, + Eye, + EyeOff, + Sun, + Contrast, + Waves, + Sparkles, + Wand2, + Brain, + Loader2, + AlertTriangle, + CheckCircle2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils/shadcn"; +import { ICON } from "@/lib/utils/const"; +import { + AnyModifier, + EnhancementModifier, + isEnhancementModifier, +} from "@/lib/imageModifiers/types"; + +// ─── Icon per modifier type ─────────────────────────────────────────────────── + +export function ModifierIcon({ + type, + size, +}: { + type: AnyModifier["type"]; + size?: number; +}) { + const cls = "shrink-0 text-primary"; + const s = size ?? ICON.SIZE - 2; + if (type === "brightness") + return ; + if (type === "contrast") + return ( + + ); + if (type === "gbfen") + return ( + + ); + if (type === "snfen") + return ( + + ); + if (type === "fft") + return ( + + ); + return ( + + ); +} + +function EnhancementStatusBadge({ + modifier, +}: { + modifier: EnhancementModifier; +}) { + const { status } = modifier.params; + if (status === "processing" || status === "pending") { + return ( + + ); + } + if (status === "failed") { + return ( + + ); + } + return ( + + ); +} + +// ─── Single item ────────────────────────────────────────────────────────────── + +interface ModifierItemProps { + modifier: AnyModifier; + isDragging: boolean; + onEdit: () => void; + onToggle: () => void; + onRemove: () => void; + /** Called from the grip handle only */ + onGripMouseDown: (e: React.MouseEvent) => void; +} + +interface ItemActionsProps { + modifier: AnyModifier; + toggleDisabled: boolean; + removeDisabled: boolean; + onEdit: () => void; + onToggle: () => void; + onRemove: () => void; +} + +function ItemActions({ + modifier, + toggleDisabled, + removeDisabled, + onEdit, + onToggle, + onRemove, +}: ItemActionsProps) { + const { t } = useTranslation(["keywords"]); + return ( +
+ + + +
+ ); +} + +function ModifierItem({ + modifier, + isDragging, + onEdit, + onToggle, + onRemove, + onGripMouseDown, +}: ModifierItemProps) { + const { t } = useTranslation(["tooltip"]); + + let label: string; + switch (modifier.type) { + case "brightness": + label = t("Brightness", { ns: "tooltip" }); + break; + case "contrast": + label = t("Contrast", { ns: "tooltip" }); + break; + case "fft": + label = t("FFT Filter", { ns: "tooltip" }); + break; + case "gbfen": + label = t("GBFEN", { ns: "tooltip" }); + break; + default: + label = t("SNFEN", { ns: "tooltip" }); + break; + } + + const enhancement = isEnhancementModifier(modifier) ? modifier : null; + const isProcessing = + enhancement !== null && + (enhancement.params.status === "processing" || + enhancement.params.status === "pending"); + const toggleDisabled = + enhancement !== null && enhancement.params.status !== "ready"; + + const itemTitle = enhancement?.params.errorMessage + ? enhancement.params.errorMessage + : enhancement + ? `${label} (${enhancement.params.status})` + : label; + + return ( +
+
+ +
+ + + + + {label} + + + {enhancement && } + + +
+ ); +} + +// ─── List with mouse-based DnD ──────────────────────────────────────────────── +// +// Native HTML5 draggable DnD does not work reliably in Tauri WebView2 on +// Windows (shows "no-drop" cursor and never fires drop events). +// We implement reordering with plain mouse events on the document instead. + +interface ModifierListProps { + modifiers: AnyModifier[]; + onEdit: (id: string) => void; + onToggle: (id: string) => void; + onRemove: (id: string) => void; + /** fromIndex and toIndex are final positions in the array (post-splice). */ + onReorder: (fromIndex: number, toIndex: number) => void; +} + +export function ModifierList({ + modifiers, + onEdit, + onToggle, + onRemove, + onReorder, +}: ModifierListProps) { + // dragging: which item + where it started + const [dragging, setDragging] = useState<{ + id: string; + fromIdx: number; + } | null>(null); + // dropIndex: visual insertion point (0 = before first, length = after last) + const [dropIndex, setDropIndex] = useState(-1); + + // Refs so the document-level handlers see current values without closures + const draggingRef = useRef<{ id: string; fromIdx: number } | null>(null); + const dropIndexRef = useRef(-1); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const modifiersLengthRef = useRef(modifiers.length); + modifiersLengthRef.current = modifiers.length; + + const startDrag = useCallback( + (e: React.MouseEvent, id: string, fromIdx: number) => { + // Only left-button + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const drag = { id, fromIdx }; + draggingRef.current = drag; + dropIndexRef.current = fromIdx; + setDragging(drag); + setDropIndex(fromIdx); + + function handleMove(ev: MouseEvent) { + const len = modifiersLengthRef.current; + let newDrop = len; // default: after last item + + for (let i = 0; i < len; i += 1) { + const el = itemRefs.current[i]; + if (el) { + const rect = el.getBoundingClientRect(); + if (ev.clientY < rect.top + rect.height / 2) { + newDrop = i; + break; + } + } + } + + dropIndexRef.current = newDrop; + setDropIndex(newDrop); + } + + function handleUp() { + const drag2 = draggingRef.current; + const drop = dropIndexRef.current; + + if (drag2 !== null && drop !== -1) { + const { fromIdx: from } = drag2; + const to = drop > from ? drop - 1 : drop; + if (to !== from) { + onReorder(from, to); + } + } + + draggingRef.current = null; + dropIndexRef.current = -1; + setDragging(null); + setDropIndex(-1); + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + } + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + }, + [onReorder] + ); + + if (modifiers.length === 0) { + return ( +

+ No modifiers yet +

+ ); + } + + // Decide if a drop-line indicator should be shown. + // No-op positions: dropping at fromIdx or fromIdx+1 yields no change. + const fromIdx = dragging?.fromIdx ?? -1; + const isNoop = + dragging === null || dropIndex === fromIdx || dropIndex === fromIdx + 1; + + return ( +
+ {modifiers.map((mod, idx) => { + // Show a blue top line at the insertion point + const showTopLine = !isNoop && dropIndex === idx; + // Show a blue bottom line when inserting after the last item + const showBottomLine = + !isNoop && + idx === modifiers.length - 1 && + dropIndex === modifiers.length; + + return ( +
{ + // eslint-disable-next-line security/detect-object-injection + itemRefs.current[idx] = el; + }} + className={cn( + "relative", + showTopLine && + "before:absolute before:top-[-3px] before:inset-x-0 before:h-[2px] before:bg-primary before:rounded-full before:z-10", + showBottomLine && + "after:absolute after:bottom-[-3px] after:inset-x-0 after:h-[2px] after:bg-primary after:rounded-full after:z-10" + )} + > + onEdit(mod.id)} + onToggle={() => onToggle(mod.id)} + onRemove={() => onRemove(mod.id)} + onGripMouseDown={ev => startDrag(ev, mod.id, idx)} + /> +
+ ); + })} +
+ ); +} diff --git a/src/components/edit-window/modifiers/ModifierSettingsDialog.tsx b/src/components/edit-window/modifiers/ModifierSettingsDialog.tsx new file mode 100644 index 00000000..4df1eb38 --- /dev/null +++ b/src/components/edit-window/modifiers/ModifierSettingsDialog.tsx @@ -0,0 +1,835 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Trash2, + Waves, + Sun, + Contrast, + Wand2, + Brain, + X, + Play, + RefreshCw, +} from "lucide-react"; +import { ICON } from "@/lib/utils/const"; +import { ImageFFT } from "@/lib/fftProcessor"; +import { useTranslation } from "react-i18next"; +import { + AnyModifier, + BrightnessModifier, + ContrastModifier, + EnhancementModifier, + FftModifier, + isEnhancementModifier, +} from "@/lib/imageModifiers/types"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogPortal, + DialogClose, +} from "@/components/ui/dialog"; + +// ─── Shared Styles ──────────────────────────────────────────────────────────── + +const SLIDER_THUMB_CLASS = + "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-[0_0_10px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:hover:scale-125 [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-background " + + "[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:shadow-[0_0_10px_rgba(0,0,0,0.5)] [&::-moz-range-thumb]:hover:scale-125 [&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:ring-2 [&::-moz-range-thumb]:ring-background [&::-moz-range-thumb]:border-none"; + +const SLIDER_TRACK_CLASS = + "bg-secondary rounded-lg appearance-none cursor-pointer border border-border/40 shadow-inner"; + +// ─── Brightness ─────────────────────────────────────────────────────────────── + +function BrightnessSettings({ + modifier, + onChange, +}: { + modifier: BrightnessModifier; + onChange: (params: BrightnessModifier["params"]) => void; +}) { + const { t } = useTranslation(["tooltip"]); + return ( +
+
+ +
+ + onChange({ value: Number(e.target.value) }) + } + className={`flex-1 h-2.5 ${SLIDER_TRACK_CLASS} ${SLIDER_THUMB_CLASS}`} + /> + + {modifier.params.value}% + +
+
+
+ ); +} + +// ─── Contrast ───────────────────────────────────────────────────────────────── + +function ContrastSettings({ + modifier, + onChange, +}: { + modifier: ContrastModifier; + onChange: (params: ContrastModifier["params"]) => void; +}) { + const { t } = useTranslation(["tooltip"]); + return ( +
+
+ +
+ + onChange({ value: Number(e.target.value) }) + } + className={`flex-1 h-2.5 ${SLIDER_TRACK_CLASS} ${SLIDER_THUMB_CLASS}`} + /> + + {modifier.params.value}% + +
+
+
+ ); +} + +// ─── FFT ────────────────────────────────────────────────────────────────────── + +type FftViewMode = "edit" | "preview"; +type FftStatus = "idle" | "loading" | "ready" | "processing"; + +function FftSettings({ + modifier, + imageRef, + onChange, +}: { + modifier: FftModifier; + imageRef: React.RefObject; + onChange: (params: Partial) => void; +}) { + const { t } = useTranslation(["keywords", "tooltip"]); + + const canvasRef = useRef(null); + const [status, setStatus] = useState(() => + // If already computed from a previous open, go straight to ready + // eslint-disable-next-line no-underscore-dangle + modifier.params._processor && modifier.params._fftResult + ? "ready" + : "idle" + ); + const [viewMode, setViewMode] = useState("edit"); + + const [brushSize, setBrushSize] = useState(modifier.params.brushSize); + const [spectrumOpacity, setSpectrumOpacity] = useState( + modifier.params.spectrumOpacity + ); + + const brushSizeRef = useRef(brushSize); + const spectrumOpacityRef = useRef(spectrumOpacity); + const isDrawingRef = useRef(false); + + // eslint-disable-next-line no-underscore-dangle + const processorRef = useRef(modifier.params._processor ?? null); + // eslint-disable-next-line no-underscore-dangle + const fftResultRef = useRef(modifier.params._fftResult ?? null); + // eslint-disable-next-line no-underscore-dangle + const maskCanvasRef = useRef(modifier.params._maskCanvas ?? null); + const specCanvasRef = useRef(null); + + useEffect(() => { + brushSizeRef.current = brushSize; + }, [brushSize]); + useEffect(() => { + spectrumOpacityRef.current = spectrumOpacity; + }, [spectrumOpacity]); + + // ── Redraw overlay ──────────────────────────────────────────────────────── + const redrawOverlay = useCallback(() => { + const canvas = canvasRef.current; + const specCvs = specCanvasRef.current; + if (!canvas || !specCvs) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.globalAlpha = spectrumOpacityRef.current / 100; + ctx.drawImage(specCvs, 0, 0, canvas.width, canvas.height); + ctx.globalAlpha = 1; + const maskCvs = maskCanvasRef.current; + if (maskCvs) ctx.drawImage(maskCvs, 0, 0, canvas.width, canvas.height); + }, []); + + // ── Restore overlay if already computed ────────────────────────────────── + useEffect(() => { + if ( + processorRef.current && + fftResultRef.current && + specCanvasRef.current === null + ) { + // Rebuild specCanvasRef from the stored spectrum data + const result = fftResultRef.current; + const specCvs = document.createElement("canvas"); + specCvs.width = result.width; + specCvs.height = result.height; + const ctx = specCvs.getContext("2d"); + if (ctx) { + ctx.putImageData( + new ImageData( + new Uint8ClampedArray(result.spectrum.buffer), + result.width, + result.height + ), + 0, + 0 + ); + } + specCanvasRef.current = specCvs; + + // Size the overlay canvas and draw + const canvas = canvasRef.current; + if (canvas) { + const img = imageRef.current; + if (img) { + // eslint-disable-next-line no-param-reassign + canvas.width = img.naturalWidth; + // eslint-disable-next-line no-param-reassign + canvas.height = img.naturalHeight; + } + redrawOverlay(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── Compute FFT (manual trigger) ───────────────────────────────────────── + const computeFft = useCallback(() => { + const img = imageRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) return; + + setStatus("loading"); + + // Defer so the "loading…" UI can paint before we block the thread + setTimeout(() => { + try { + const imageW = img.naturalWidth; + const imageH = img.naturalHeight; + + const tmp = document.createElement("canvas"); + tmp.width = imageW; + tmp.height = imageH; + const tmpCtx = tmp.getContext("2d", { + willReadFrequently: true, + }); + if (!tmpCtx) throw new Error("no ctx"); + tmpCtx.drawImage(img, 0, 0); + const imageData = tmpCtx.getImageData(0, 0, imageW, imageH); + + const processor = new ImageFFT(imageW, imageH); + const result = processor.forward(imageData); + + processorRef.current = processor; + fftResultRef.current = result; + + if (!maskCanvasRef.current) { + const maskCvs = document.createElement("canvas"); + maskCvs.width = result.width; + maskCvs.height = result.height; + maskCanvasRef.current = maskCvs; + } + + const specCvs = document.createElement("canvas"); + specCvs.width = result.width; + specCvs.height = result.height; + const specCtx = specCvs.getContext("2d"); + if (specCtx) { + specCtx.putImageData( + new ImageData( + new Uint8ClampedArray(result.spectrum.buffer), + result.width, + result.height + ), + 0, + 0 + ); + } + specCanvasRef.current = specCvs; + + // eslint-disable-next-line no-param-reassign + canvas.width = imageW; + // eslint-disable-next-line no-param-reassign + canvas.height = imageH; + + redrawOverlay(); + setStatus("ready"); + + onChange({ + _processor: processor, + _fftResult: result, + _maskCanvas: maskCanvasRef.current, + }); + } catch { + setStatus("idle"); + } + }, 50); + }, [imageRef, redrawOverlay, onChange]); + + // ── Mouse paint ─────────────────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + const fftResult = fftResultRef.current; + const maskCvs = maskCanvasRef.current; + if ( + !canvas || + !fftResult || + !maskCvs || + status !== "ready" || + viewMode !== "edit" + ) + return undefined; + + const getCoords = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + return { + cx: (e.clientX - rect.left) * (canvas.width / rect.width), + cy: (e.clientY - rect.top) * (canvas.height / rect.height), + }; + }; + + const paintAt = (cx: number, cy: number) => { + const maskCtx = maskCvs.getContext("2d"); + if (!maskCtx) return; + const scaleX = fftResult.width / canvas.width; + const scaleY = fftResult.height / canvas.height; + maskCtx.globalCompositeOperation = "source-over"; + maskCtx.fillStyle = "#c00000"; + maskCtx.beginPath(); + maskCtx.arc( + cx * scaleX, + cy * scaleY, + brushSizeRef.current * Math.max(scaleX, scaleY), + 0, + Math.PI * 2 + ); + maskCtx.fill(); + redrawOverlay(); + }; + + const onDown = (e: MouseEvent) => { + if (e.button !== 0) return; + isDrawingRef.current = true; + const { cx, cy } = getCoords(e); + paintAt(cx, cy); + }; + const onMove = (e: MouseEvent) => { + if (!isDrawingRef.current) return; + const { cx, cy } = getCoords(e); + paintAt(cx, cy); + }; + const onUp = () => { + isDrawingRef.current = false; + onChange({ _maskCanvas: maskCanvasRef.current }); + }; + + canvas.addEventListener("mousedown", onDown); + canvas.addEventListener("mousemove", onMove); + canvas.addEventListener("mouseup", onUp); + canvas.addEventListener("mouseleave", onUp); + + return () => { + canvas.removeEventListener("mousedown", onDown); + canvas.removeEventListener("mousemove", onMove); + canvas.removeEventListener("mouseup", onUp); + canvas.removeEventListener("mouseleave", onUp); + }; + }, [status, viewMode, redrawOverlay, onChange]); + + // ── Toggle preview ──────────────────────────────────────────────────────── + const togglePreview = useCallback(() => { + const canvas = canvasRef.current; + const processor = processorRef.current; + const fftResult = fftResultRef.current; + const maskCvs = maskCanvasRef.current; + if (!canvas || !processor || !fftResult || !maskCvs) return; + + if (viewMode === "edit") { + setStatus("processing"); + setTimeout(() => { + const maskCtx = maskCvs.getContext("2d"); + if (!maskCtx) return; + const maskImgData = maskCtx.getImageData( + 0, + 0, + fftResult.width, + fftResult.height + ); + const filteredData = processor.applyMask( + fftResult.complexData, + maskImgData.data + ); + const resultImage = processor.inverse( + filteredData, + canvas.width, + canvas.height + ); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.putImageData(resultImage, 0, 0); + } + setViewMode("preview"); + setStatus("ready"); + }, 50); + } else { + redrawOverlay(); + setViewMode("edit"); + } + }, [viewMode, redrawOverlay]); + + const clearMask = useCallback(() => { + const maskCvs = maskCanvasRef.current; + if (!maskCvs) return; + const ctx = maskCvs.getContext("2d"); + ctx?.clearRect(0, 0, maskCvs.width, maskCvs.height); + onChange({ _maskCanvas: maskCvs, maskDataUrl: null }); + if (viewMode === "edit") redrawOverlay(); + }, [viewMode, redrawOverlay, onChange]); + + const canvasStyle: React.CSSProperties = { + maxWidth: "100%", + maxHeight: "340px", + objectFit: "contain", + cursor: + viewMode === "edit" && status === "ready" ? "crosshair" : "default", + border: "1px solid hsl(var(--border))", + borderRadius: "0.375rem", + display: "block", + }; + + return ( +
+ {/* Spectrum canvas (always present for sizing; blank when idle) */} +
+ + {status === "idle" && ( +
+

+ Click "Compute" to analyse the frequency + spectrum +

+ +
+ )} +
+ + {status === "loading" && ( + + {t("Loading...", { ns: "keywords" })} + + )} + {status === "processing" && ( + + {t("Processing...", { ns: "keywords" })} + + )} + + {status === "ready" && viewMode === "edit" && ( + <> +

+ {t("Paint over bright spots to filter them out", { + ns: "tooltip", + })} +

+
+ + { + const v = Number(e.target.value); + setBrushSize(v); + brushSizeRef.current = v; + onChange({ brushSize: v }); + }} + className={`h-2.5 w-full ${SLIDER_TRACK_CLASS} ${SLIDER_THUMB_CLASS}`} + /> +
+
+ + { + const v = Number(e.target.value); + setSpectrumOpacity(v); + spectrumOpacityRef.current = v; + onChange({ spectrumOpacity: v }); + redrawOverlay(); + }} + className={`h-2.5 w-full ${SLIDER_TRACK_CLASS} ${SLIDER_THUMB_CLASS}`} + /> +
+ + )} + + {status === "ready" && ( +
+ + {viewMode === "edit" && ( + + )} +
+ )} +
+ ); +} + +// ─── Enhancement (GBFEN / SNFEN) ───────────────────────────────────────────── + +function EnhancementSettings({ + modifier, + onChange, + onRerun, +}: { + modifier: EnhancementModifier; + onChange: (params: Partial) => void; + onRerun?: (id: string) => void; +}) { + const { t } = useTranslation(["tooltip"]); + const { dpi, status, outputPath, errorMessage, durationMs } = + modifier.params; + const isBusy = status === "processing" || status === "pending"; + + const methodLabel = + modifier.type === "gbfen" + ? t("GBFEN — Gabor-based enhancement", { ns: "tooltip" }) + : t("SNFEN — Neural enhancement", { ns: "tooltip" }); + + const descriptionKey: "gbfen_desc" | "snfen_desc" = + modifier.type === "gbfen" ? "gbfen_desc" : "snfen_desc"; + + const statusLabel = + status === "pending" + ? t("Enhancement: pending", { ns: "tooltip" }) + : status === "processing" + ? t("Enhancement: processing", { ns: "tooltip" }) + : status === "ready" + ? t("Enhancement: ready", { ns: "tooltip" }) + : t("Enhancement: failed", { ns: "tooltip" }); + + return ( +
+
+ + {t("Method", { ns: "tooltip" })} + + {methodLabel} +

+ {t(descriptionKey, { ns: "tooltip" })} +

+
+ +
+ + { + const v = Number(e.target.value); + if (Number.isFinite(v) && v > 0) { + onChange({ dpi: v }); + } + }} + className="h-9 px-2 rounded-md border border-border/40 bg-background text-sm" + /> + + {t("Enhancement DPI hint", { ns: "tooltip" })} + +
+ +
+ + {t("Enhancement status", { ns: "tooltip" })} + + + {statusLabel} + + {durationMs !== null && status === "ready" && ( + + {t("Took {{seconds}} s", { + ns: "tooltip", + seconds: (durationMs / 1000).toFixed(1), + })} + + )} + {errorMessage && status === "failed" && ( +

+ {errorMessage} +

+ )} + {outputPath && status === "ready" && ( +

+ {outputPath} +

+ )} +
+ + {onRerun && ( + + )} +
+ ); +} + +// ─── Dialog icon per type ───────────────────────────────────────────────────── + +function TitleIcon({ type }: { type: AnyModifier["type"] }) { + const cls = "text-primary shrink-0"; + if (type === "brightness") + return ( + + ); + if (type === "contrast") + return ( + + ); + if (type === "gbfen") + return ( + + ); + if (type === "snfen") + return ( + + ); + return ( + + ); +} + +// ─── Main dialog ────────────────────────────────────────────────────────────── + +interface ModifierSettingsDialogProps { + modifier: AnyModifier | null; + imageRef: React.RefObject; + open: boolean; + onClose: () => void; + onUpdate: (id: string, params: Partial) => void; + onRerunEnhancement?: (id: string) => void; +} + +export function ModifierSettingsDialog({ + modifier, + imageRef, + open, + onClose, + onUpdate, + onRerunEnhancement, +}: ModifierSettingsDialogProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + + if (!modifier) return null; + + const handleChange = (params: Partial) => { + onUpdate(modifier.id, params); + }; + + const title = + modifier.type === "brightness" + ? t("Brightness", { ns: "tooltip" }) + : modifier.type === "contrast" + ? t("Contrast", { ns: "tooltip" }) + : modifier.type === "fft" + ? t("FFT Filter", { ns: "tooltip" }) + : modifier.type === "gbfen" + ? t("GBFEN", { ns: "tooltip" }) + : t("SNFEN", { ns: "tooltip" }); + + return ( + /* + * modal={false} — critical fix: + * The dialog does NOT trap focus and does NOT block pointer-events + * on the rest of the UI. This prevents the "frozen window" symptom + * where the overlay intercepted all clicks but the dialog content was + * not interactable or not visible. + */ + { + if (!v) onClose(); + }} + modal={false} + > + + {/* No DialogOverlay — non-modal dialogs don't need a backdrop */} + e.preventDefault()} + onInteractOutside={e => e.preventDefault()} + > + {/* Title row with explicit close button */} +
+ + + {title} + + + + +
+ + {modifier.type === "brightness" && ( + handleChange(p)} + /> + )} + {modifier.type === "contrast" && ( + handleChange(p)} + /> + )} + {modifier.type === "fft" && ( + handleChange(p)} + /> + )} + {isEnhancementModifier(modifier) && ( + + handleChange( + p as Partial + ) + } + onRerun={onRerunEnhancement} + /> + )} + + {/* ── Zapisz (Save / Done) ───────────────────────── */} +
+ +
+
+
+
+ ); +} diff --git a/src/lib/external-tools/pyfing/runPyfingEnhancement.ts b/src/lib/external-tools/pyfing/runPyfingEnhancement.ts new file mode 100644 index 00000000..d0c97a35 --- /dev/null +++ b/src/lib/external-tools/pyfing/runPyfingEnhancement.ts @@ -0,0 +1,194 @@ +import { Command } from "@tauri-apps/plugin-shell"; +import { exists } from "@tauri-apps/plugin-fs"; +import { + ExternalRunOptions, + ExternalToolLogger, +} from "@/lib/external-tools/core/core"; +import { + ExternalToolError, + ExternalToolTimeoutError, +} from "@/lib/external-tools/core/errors"; + +const PYFING_SIDECAR_NAME = "bin/pyfing_enhance"; +const PYFING_TIMEOUT_MS = 120_000; // 2 min — enhancement can be slow +const LOG_PREFIX = "[Pyfing ExternalTool]"; + +export type PyfingMethod = "GBFEN" | "SNFEN"; + +export type PyfingRunRequest = { + imagePath: string; + outputPath: string; + method: PyfingMethod; + dpi?: number; +}; + +export type PyfingRunResult = { + outputPath: string; + durationMs: number; + stderr: string; +}; + +type ProcessOutcome = { + code: number | null; + stdout: string; + stderr: string; +}; + +function log( + logger: ExternalToolLogger | undefined, + level: "info" | "error" | "debug", + message: string, + payload?: unknown +) { + logger?.[level]?.(LOG_PREFIX, message, payload); +} + +/** + * Spawn the sidecar and resolve when it exits naturally. + * + * If the timeout fires we kill the child process (so the OS reaps it + * immediately) and reject with `ExternalToolTimeoutError`. This is the key + * difference from `Command.execute()` racing a `setTimeout`: that pattern + * leaks the child process because the JS promise rejects but the native + * process keeps running. + */ +async function spawnWithTimeout( + command: Command, + timeoutMs: number, + logger: ExternalToolLogger | undefined +): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + + command.stdout.on("data", line => { + stdoutChunks.push(line); + }); + command.stderr.on("data", line => { + stderrChunks.push(line); + }); + + const child = await command.spawn(); + + return new Promise((resolve, reject) => { + let settled = false; + let timeoutHandle: ReturnType | null = null; + + const finish = (outcome: ProcessOutcome | Error) => { + if (settled) return; + settled = true; + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + if (outcome instanceof Error) { + reject(outcome); + } else { + resolve(outcome); + } + }; + + command.on("close", payload => { + const code = + typeof payload === "object" && + payload !== null && + "code" in payload + ? (payload as { code: number | null }).code ?? null + : null; + finish({ + code, + stdout: stdoutChunks.join("\n"), + stderr: stderrChunks.join("\n"), + }); + }); + + command.on("error", err => { + finish( + new ExternalToolError( + `pyfing process error: ${typeof err === "string" ? err : JSON.stringify(err)}` + ) + ); + }); + + timeoutHandle = setTimeout(() => { + log(logger, "error", "Process timed out, killing child", { + timeoutMs, + }); + child.kill().catch(killErr => { + log(logger, "error", "Failed to kill child after timeout", { + error: killErr, + }); + }); + finish( + new ExternalToolTimeoutError(PYFING_SIDECAR_NAME, timeoutMs) + ); + }, timeoutMs); + }); +} + +export async function runPyfingEnhancement( + request: PyfingRunRequest, + options?: ExternalRunOptions +): Promise { + const timeoutMs = options?.timeoutMs ?? PYFING_TIMEOUT_MS; + const logger = options?.logger; + const dpi = request.dpi ?? 500; + + const args = [ + "--input", + request.imagePath, + "--output", + request.outputPath, + "--method", + request.method, + "--dpi", + String(dpi), + ]; + + log(logger, "info", "Starting pyfing enhancement", { + method: request.method, + imagePath: request.imagePath, + outputPath: request.outputPath, + dpi, + timeoutMs, + }); + + const command = Command.sidecar(PYFING_SIDECAR_NAME, args); + const startedAt = Date.now(); + + try { + const output = await spawnWithTimeout(command, timeoutMs, logger); + const durationMs = Date.now() - startedAt; + + log(logger, "debug", "stderr", output.stderr); + log(logger, "info", "Process finished", { + code: output.code, + durationMs, + }); + + if (output.code !== 0) { + throw new ExternalToolError( + `pyfing enhancement failed (exit ${output.code}): ${output.stderr}` + ); + } + + const outputExists = await exists(request.outputPath); + if (!outputExists) { + throw new ExternalToolError( + `pyfing output file missing: ${request.outputPath}` + ); + } + + return { + outputPath: request.outputPath, + durationMs, + stderr: output.stderr, + }; + } catch (error) { + if (error instanceof ExternalToolTimeoutError) { + log(logger, "error", "Process timed out"); + } else { + log(logger, "error", "Process failed", error); + } + throw error; + } +} diff --git a/src/lib/imageModifiers/pipeline.ts b/src/lib/imageModifiers/pipeline.ts new file mode 100644 index 00000000..7445e896 --- /dev/null +++ b/src/lib/imageModifiers/pipeline.ts @@ -0,0 +1,99 @@ +import { ImageFFT } from "@/lib/fftProcessor"; +import { AnyModifier, FftModifier } from "./types"; + +async function applyFftModifier( + canvas: HTMLCanvasElement, + mod: FftModifier +): Promise { + const { _maskCanvas, _processor, _fftResult } = mod.params; + + // Without a painted mask there is nothing to filter + if (!_maskCanvas || !_processor || !_fftResult) return; + + const maskCtx = _maskCanvas.getContext("2d"); + if (!maskCtx) return; + + const maskImgData = maskCtx.getImageData( + 0, + 0, + _maskCanvas.width, + _maskCanvas.height + ); + + // Re-run forward FFT on the current canvas pixels so we respect upstream edits + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const processor = new ImageFFT(canvas.width, canvas.height); + const result = processor.forward(imageData); + const filtered = processor.applyMask(result.complexData, maskImgData.data); + const output = processor.inverse(filtered, canvas.width, canvas.height); + ctx.putImageData(output, 0, 0); +} + +/** + * Applies all enabled modifiers to `sourceImg` in sequence. + * Returns a `Uint8Array` of PNG bytes suitable for writing to disk. + * + * The pipeline works as follows: + * 1. Draw the source image to an offscreen canvas. + * 2. For each enabled modifier (in order): + * - CSS-based modifiers (brightness, contrast): apply via ctx.filter before drawing. + * - Canvas-based modifiers (FFT): perform in-place pixel manipulation. + * 3. Encode the final canvas as a PNG blob and return it. + */ +export async function applyPipelineToImage( + sourceImg: HTMLImageElement, + modifiers: AnyModifier[] +): Promise { + const w = sourceImg.naturalWidth || sourceImg.width; + const h = sourceImg.naturalHeight || sourceImg.height; + + // --- Stage: collect CSS-only modifiers into a single filter string --- + const cssFilterParts: string[] = []; + modifiers.forEach(mod => { + if (mod.enabled) { + if (mod.type === "brightness") { + cssFilterParts.push(`brightness(${mod.params.value / 100})`); + } else if (mod.type === "contrast") { + cssFilterParts.push(`contrast(${mod.params.value / 100})`); + } + } + }); + + // --- Stage 1: draw source with CSS filters applied --- + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas context unavailable"); + + if (cssFilterParts.length > 0) { + ctx.filter = cssFilterParts.join(" "); + } + ctx.drawImage(sourceImg, 0, 0, w, h); + ctx.filter = "none"; + + // --- Stage 2: apply canvas-based modifiers in order --- + for (let i = 0; i < modifiers.length; i += 1) { + const mod = modifiers[i]; + if (mod && mod.enabled && mod.type === "fft") { + // eslint-disable-next-line no-await-in-loop + await applyFftModifier(canvas, mod as FftModifier); + } + } + + // --- Encode to PNG --- + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + b => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))), + "image/png", + 1.0 + ); + }); + const buf = await blob.arrayBuffer(); + return new Uint8Array(buf); +} + +// (applyFftModifier moved up) diff --git a/src/lib/imageModifiers/registry.ts b/src/lib/imageModifiers/registry.ts new file mode 100644 index 00000000..1bad7147 --- /dev/null +++ b/src/lib/imageModifiers/registry.ts @@ -0,0 +1,160 @@ +import { + AnyModifier, + BrightnessModifier, + ContrastModifier, + EnhancementParams, + FftModifier, + GbfenModifier, + ModifierType, + SnfenModifier, +} from "./types"; + +// We use crypto.randomUUID where available, otherwise a simple timestamp id +function newId(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +// ─── Factory functions ─────────────────────────────────────────────────────── + +export function createBrightnessModifier(): BrightnessModifier { + return { + id: newId(), + type: "brightness", + label: "Brightness", + enabled: true, + params: { value: 100 }, + }; +} + +export function createContrastModifier(): ContrastModifier { + return { + id: newId(), + type: "contrast", + label: "Contrast", + enabled: true, + params: { value: 100 }, + }; +} + +export function createFftModifier(): FftModifier { + return { + id: newId(), + type: "fft", + label: "FFT Filter", + enabled: true, + params: { + brushSize: 30, + spectrumOpacity: 75, + maskDataUrl: null, + _maskCanvas: null, + _fftResult: null, + _processor: null, + }, + }; +} + +function defaultEnhancementParams(): EnhancementParams { + return { + dpi: 500, + status: "pending", + outputPath: null, + errorMessage: null, + durationMs: null, + runtimeOutputUrl: null, + }; +} + +export function createGbfenModifier(): GbfenModifier { + return { + id: newId(), + type: "gbfen", + label: "GBFEN", + enabled: true, + params: defaultEnhancementParams(), + }; +} + +export function createSnfenModifier(): SnfenModifier { + return { + id: newId(), + type: "snfen", + label: "SNFEN", + enabled: true, + params: defaultEnhancementParams(), + }; +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +export interface ModifierDefinition { + type: ModifierType; + /** i18n key for the label shown in the "Add" menu */ + labelKey: string; + /** Optional grouping for the dropdown – "default" appears first, "enhancement" goes under a separator */ + group?: "default" | "enhancement"; + create: () => AnyModifier; +} + +export const MODIFIER_REGISTRY: ModifierDefinition[] = [ + { + type: "brightness", + labelKey: "Brightness", + group: "default", + create: createBrightnessModifier, + }, + { + type: "contrast", + labelKey: "Contrast", + group: "default", + create: createContrastModifier, + }, + { + type: "fft", + labelKey: "FFT Filter", + group: "default", + create: createFftModifier, + }, + { + type: "gbfen", + labelKey: "GBFEN", + group: "enhancement", + create: createGbfenModifier, + }, + { + type: "snfen", + labelKey: "SNFEN", + group: "enhancement", + create: createSnfenModifier, + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Builds a CSS filter string from all lightweight (non-canvas) modifiers. + * Only enabled modifiers are included. + */ +export function buildCssFilter(modifiers: AnyModifier[]): string { + const parts: string[] = []; + modifiers.forEach(mod => { + if (mod.enabled) { + if (mod.type === "brightness") { + parts.push(`brightness(${mod.params.value / 100})`); + } else if (mod.type === "contrast") { + parts.push(`contrast(${mod.params.value / 100})`); + } + // FFT / GBFEN / SNFEN are pixel-based – not included here + } + }); + return parts.length > 0 ? parts.join(" ") : "none"; +} + +/** + * Returns true if ANY enabled modifier in the list requires canvas processing. + */ +export function hasCanvasModifiers(modifiers: AnyModifier[]): boolean { + return modifiers.some(m => m.enabled && m.type === "fft"); +} diff --git a/src/lib/imageModifiers/types.ts b/src/lib/imageModifiers/types.ts new file mode 100644 index 00000000..ee61a090 --- /dev/null +++ b/src/lib/imageModifiers/types.ts @@ -0,0 +1,113 @@ +import { ImageFFT, FFTResult } from "@/lib/fftProcessor"; + +// ─── Modifier type discriminants ─────────────────────────────────────────── + +export type ModifierType = + | "brightness" + | "contrast" + | "fft" + | "gbfen" + | "snfen"; + +export type EnhancementMethod = "gbfen" | "snfen"; + +// ─── Per-modifier param shapes ────────────────────────────────────────────── + +export interface BrightnessParams { + value: number; // 0-200, default 100 +} + +export interface ContrastParams { + value: number; // 0-200, default 100 +} + +export interface FftParams { + brushSize: number; + spectrumOpacity: number; + /** Serialised mask – a base64-encoded PNG data URL, or null if untouched */ + maskDataUrl: string | null; + /** Runtime-only: in-memory mask canvas (not persisted across re-renders) */ + _maskCanvas?: HTMLCanvasElement | null; + /** Runtime-only: cached FFT result so we don't recompute on every render */ + _fftResult?: FFTResult | null; + /** Runtime-only: cached processor */ + _processor?: ImageFFT | null; +} + +export type EnhancementStatus = "pending" | "processing" | "ready" | "failed"; + +export interface EnhancementParams { + /** DPI passed to pyfing (default 500) */ + dpi: number; + /** Lifecycle status of the external enhancement run */ + status: EnhancementStatus; + /** Absolute path of the enhanced PNG written by pyfing (set when ready) */ + outputPath: string | null; + /** Last error message returned by the pyfing run (set when failed) */ + errorMessage: string | null; + /** Total pyfing duration in milliseconds */ + durationMs: number | null; + /** Runtime-only: blob URL of the enhanced image (not persisted) */ + runtimeOutputUrl?: string | null; +} + +// ─── Discriminated union ───────────────────────────────────────────────────── + +export type ModifierParams = + | BrightnessParams + | ContrastParams + | FftParams + | EnhancementParams; + +export interface Modifier

{ + /** Stable unique identifier */ + id: string; + type: ModifierType; + /** Human-readable label (may be i18n key) */ + label: string; + enabled: boolean; + params: P; +} + +export type BrightnessModifier = Modifier & { + type: "brightness"; +}; +export type ContrastModifier = Modifier & { + type: "contrast"; +}; +export type FftModifier = Modifier & { type: "fft" }; +export type GbfenModifier = Modifier & { type: "gbfen" }; +export type SnfenModifier = Modifier & { type: "snfen" }; + +export type EnhancementModifier = GbfenModifier | SnfenModifier; + +export type AnyModifier = + | BrightnessModifier + | ContrastModifier + | FftModifier + | GbfenModifier + | SnfenModifier; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +export function isEnhancementModifier( + m: AnyModifier +): m is EnhancementModifier { + return m.type === "gbfen" || m.type === "snfen"; +} + +export function getEnhancementMethod( + m: EnhancementModifier +): EnhancementMethod { + return m.type; +} + +// ─── Pipeline render result ────────────────────────────────────────────────── + +/** What the pipeline hands back to EditWindow for display */ +export interface PipelineResult { + /** URL of the image after all canvas-based modifiers */ + processedUrl: string | null; + /** CSS filter string built from lightweight (non-canvas) modifiers */ + cssFilter: string; +} diff --git a/src/lib/locales/en/keywords.ts b/src/lib/locales/en/keywords.ts index 5fcb1fdc..e0f1f525 100644 --- a/src/lib/locales/en/keywords.ts +++ b/src/lib/locales/en/keywords.ts @@ -79,6 +79,8 @@ const d: Dictionary = { "No marking types found for the selected working mode", "Select a working mode to view marking types": "Select a working mode to view marking types", + Modifiers: "Modifiers", + "No modifiers yet": "No modifiers yet", }; export default d; diff --git a/src/lib/locales/en/tooltip.ts b/src/lib/locales/en/tooltip.ts index bc62b528..bd96fa95 100644 --- a/src/lib/locales/en/tooltip.ts +++ b/src/lib/locales/en/tooltip.ts @@ -58,6 +58,40 @@ const d: Dictionary = { "Preview ready. Return to edit or save.", "Polyline requires at least 2 segments": "Polyline requires at least 2 segments", + brightness_desc: "Adjust the overall brightness of the image", + contrast_desc: + "Adjust the difference between light and dark areas of the image", + fft_desc: + "Apply Fast Fourier Transform to filter out periodic noise and patterns", + "Image enhancement": "Image enhancement", + GBFEN: "GBFEN", + SNFEN: "SNFEN", + gbfen_desc: + "Classical Gabor-filter-based fingerprint enhancement. Runs locally without a neural network. Best for images with clear, regular ridge structure. Fast (~10–20 s) and deterministic.", + snfen_desc: + "Neural-network fingerprint enhancement (Spectral-Neural Fingerprint Enhancement Network). Slower than GBFEN but handles low-quality, noisy, or low-contrast prints better. Requires the bundled TensorFlow runtime.", + "Enhancement: GBFEN started": "GBFEN enhancement started...", + "Enhancement: SNFEN started": "SNFEN enhancement started...", + "Enhancement: GBFEN done in {{seconds}}s": + "GBFEN finished in {{seconds}} s", + "Enhancement: SNFEN done in {{seconds}}s": + "SNFEN finished in {{seconds}} s", + "Enhancement failed: {{error}}": "Enhancement failed: {{error}}", + "Enhancement DPI": "DPI", + "Enhancement DPI hint": + "Match the scan resolution. Re-run the enhancement after changing this value.", + "Enhancement status": "Status", + "Enhancement: pending": "Pending...", + "Enhancement: processing": "Processing...", + "Enhancement: ready": "Ready", + "Enhancement: failed": "Failed", + "Enhancement output path": "Output file", + "Re-run enhancement": "Re-run", + "Took {{seconds}} s": "Took {{seconds}} s", + Method: "Method", + "GBFEN — Gabor-based enhancement": "GBFEN — Gabor-based enhancement", + "SNFEN — Neural enhancement": "SNFEN — Neural enhancement", + "Enhancing image...": "Enhancing image...", }; export default d; diff --git a/src/lib/locales/pl/keywords.ts b/src/lib/locales/pl/keywords.ts index 79d4be16..ea96b585 100644 --- a/src/lib/locales/pl/keywords.ts +++ b/src/lib/locales/pl/keywords.ts @@ -79,6 +79,8 @@ const d: Dictionary = { "Nie znaleziono typów adnotacji dla wybranego trybu pracy", "Select a working mode to view marking types": "Wybierz tryb pracy, aby wyświetlić typy adnotacji", + Modifiers: "Modyfikatory", + "No modifiers yet": "Brak modyfikatorów", }; export default d; diff --git a/src/lib/locales/pl/tooltip.ts b/src/lib/locales/pl/tooltip.ts index a3f602f9..5a7eb1f4 100644 --- a/src/lib/locales/pl/tooltip.ts +++ b/src/lib/locales/pl/tooltip.ts @@ -60,6 +60,40 @@ const d: Dictionary = { "Podgląd gotowy. Wróć do edycji lub zapisz.", "Polyline requires at least 2 segments": "Linia łamana wymaga co najmniej 2 segmentów", + brightness_desc: "Dostosuj ogólną jasność obrazu", + contrast_desc: + "Dostosuj różnicę między jasnymi i ciemnymi obszarami obrazu", + fft_desc: + "Zastosuj szybką transformatę Fouriera (FFT), aby odfiltrować szum okresowy i wzorce", + "Image enhancement": "Wzmocnienie obrazu", + GBFEN: "GBFEN", + SNFEN: "SNFEN", + gbfen_desc: + "Klasyczne wzmocnienie linii papilarnych filtrami Gabora. Działa lokalnie, bez sieci neuronowej. Najlepsze dla obrazów o wyraźnej, regularnej teksturze grzbietów. Szybkie (~10–20 s) i deterministyczne.", + snfen_desc: + "Wzmocnienie linii papilarnych siecią neuronową (Spectral-Neural Fingerprint Enhancement Network). Działa wolniej niż GBFEN, ale lepiej radzi sobie z obrazami niskiej jakości, zaszumionymi lub o słabym kontraście grzbietów. Wymaga modułu TensorFlow dołączonego do aplikacji.", + "Enhancement: GBFEN started": "Wzmacnianie GBFEN rozpoczęte...", + "Enhancement: SNFEN started": "Wzmacnianie SNFEN rozpoczęte...", + "Enhancement: GBFEN done in {{seconds}}s": + "GBFEN ukończone w {{seconds}} s", + "Enhancement: SNFEN done in {{seconds}}s": + "SNFEN ukończone w {{seconds}} s", + "Enhancement failed: {{error}}": "Wzmocnienie nie powiodło się: {{error}}", + "Enhancement DPI": "Rozdzielczość (DPI)", + "Enhancement DPI hint": + "Ustaw zgodnie z rozdzielczością skanu. Po zmianie wartości uruchom wzmocnienie ponownie.", + "Enhancement status": "Status", + "Enhancement: pending": "Oczekiwanie...", + "Enhancement: processing": "Przetwarzanie...", + "Enhancement: ready": "Gotowe", + "Enhancement: failed": "Błąd", + "Enhancement output path": "Plik wyjściowy", + "Re-run enhancement": "Uruchom ponownie", + "Took {{seconds}} s": "Zajęło {{seconds}} s", + Method: "Metoda", + "GBFEN — Gabor-based enhancement": "GBFEN — wzmocnienie filtrami Gabora", + "SNFEN — Neural enhancement": "SNFEN — wzmocnienie siecią neuronową", + "Enhancing image...": "Wzmacnianie obrazu...", }; export default d; diff --git a/src/lib/locales/translation.ts b/src/lib/locales/translation.ts index 02014d98..256be781 100644 --- a/src/lib/locales/translation.ts +++ b/src/lib/locales/translation.ts @@ -86,6 +86,8 @@ export type i18nKeywords = Recordify< | "Select a working mode to view marking types" | "Select working mode" | "No marking types found for the selected working mode" + | "Modifiers" + | "No modifiers yet" >; export type i18nDescription = Recordify< @@ -198,6 +200,33 @@ export type i18nTooltip = Recordify< | "Paint over bright spots to filter them out" | "Preview ready. Return to edit or save." | "Polyline requires at least 2 segments" + | "brightness_desc" + | "contrast_desc" + | "fft_desc" + | "Image enhancement" + | "GBFEN" + | "SNFEN" + | "gbfen_desc" + | "snfen_desc" + | "Enhancement: GBFEN started" + | "Enhancement: SNFEN started" + | "Enhancement: GBFEN done in {{seconds}}s" + | "Enhancement: SNFEN done in {{seconds}}s" + | "Enhancement failed: {{error}}" + | "Enhancement DPI" + | "Enhancement DPI hint" + | "Enhancement status" + | "Enhancement: pending" + | "Enhancement: processing" + | "Enhancement: ready" + | "Enhancement: failed" + | "Enhancement output path" + | "Re-run enhancement" + | "Took {{seconds}} s" + | "Method" + | "GBFEN — Gabor-based enhancement" + | "SNFEN — Neural enhancement" + | "Enhancing image..." >; export type i18nDialog = Recordify<