commit a00ad62f6c05f51eb92d1a670a17ad2e15f5144e Author: asepharyana Date: Fri Jul 24 13:10:59 2026 +0700 feat: initial tools service with document scanner, image & PDF tools Self-hosted document scanner and media processing tools. - Rust Axum gateway + worker pool with NATS JetStream - Next.js 16 frontend with shadcn/ui - Scanner pipeline: edge detection, warp, binarization, OCR - Image tools: compress, resize, convert - PDF tools: merge, split, compress - CI/CD with Docker multi-stage build Co-Authored-By: Kilo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a4ad8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Rust +backend/target/ + +# Node +frontend/node_modules/ +frontend/.next/ +frontend/out/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..5795723 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Tools — Document Scanner & Media Processing + +Self-hosted, no-install document scanner dan media processing tools yang jalan di browser. Alternatif dari CamScanner, ilovepdf, compressjpeg — tanpa upload ke pihak ketiga. + +## Tech Stack + +- **Frontend**: Next.js 16 + TypeScript + shadcn/ui + Tailwind v4 + Framer Motion +- **Backend**: Rust (Axum gateway + worker pool with Tokio) +- **Queue**: NATS JetStream (job queue + progress pub/sub) +- **Cache**: Redis (job metadata, rate limiting) +- **Image Processing**: `image` + `imageproc` crates (edge detection, warp, binarization, deskew) +- **OCR**: Tesseract via `leptess` crate (optional feature) + +## Architecture + +``` +Browser → Next.js (frontend) → Rust Gateway (Axum) → NATS Queue → Workers (Tokio+Rayon) + ↕ ↕ + Redis Temp Storage +``` + +## Directory Structure + +``` +apps/tools/ +├── frontend/ # Next.js 16 SPA +│ ├── src/ +│ │ ├── app/ # Pages + API routes +│ │ ├── components/# shadcn/ui components +│ │ └── hooks/ # Custom hooks (useJobStatus, useUpload) +│ ├── package.json +│ └── next.config.ts +├── backend/ # Rust workspace +│ ├── common/ # Shared types, errors, NATS constants +│ ├── gateway/ # Axum API server (upload, job, WS, download) +│ ├── workers/ # Processing workers (scanner, image, PDF) +│ └── wasm/ # WASM image processing (future) +├── scripts/ +│ └── entrypoint.sh +└── Dockerfile +``` + +## Development + +```bash +# Start Redis + NATS +docker compose -f infra/compose/shared.yml -f infra/compose/nats.yml up -d + +# Start Rust workers +cd apps/tools/backend +REDIS_URL=redis://localhost:6379 NATS_URL=nats://localhost:4222 cargo run --bin workers + +# Start Rust gateway (another terminal) +REDIS_URL=redis://localhost:6379 NATS_URL=nats://localhost:4222 \ + STORAGE_PATH=/tmp/tools cargo run --bin gateway + +# Start Next.js frontend (another terminal) +cd apps/tools/frontend +bun dev --port 3002 +``` + +## Build + +```bash +docker build -f infra/docker/tools.Dockerfile -t tools:latest . +``` + +## Pipeline Stages (Document Scanner) + +1. **Preprocess** — Load, resize (max 2000px), grayscale +2. **Edge Detection** — Canny with adaptive threshold + morphological close +3. **Corner Detection** — Contour analysis with fallback chain +4. **Perspective Warp** — DLT homography + bilinear interpolation +5. **Shadow Removal** — Background subtraction + CLAHE +6. **Binarization** — Sauvola local threshold (integral image accelerated) +7. **Deskew** — Hough transform line detection +8. **Enhance** — Unsharp mask + contrast adjustment +9. **OCR** — Tesseract (English + Indonesian) +10. **PDF Generation** — Searchable PDF with invisible text layer diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..97944f6 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,3816 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-nats" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a798aab0c0203b31d67d501e5ed1f3ac6c36a329899ce47fc93c3bea53f3ae89" +dependencies = [ + "base64", + "bytes", + "futures", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.7", + "regex", + "ring", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.19", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4243e6031260db77ede97ad86c27e501d646a27ab57b59a574f725d98ab1fb4" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 1.0.109", + "which", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.7", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "leptess" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8964e3d3270be667dda2d0026e8c77011bafaad33936011b93750489987513" +dependencies = [ + "tesseract-plumbing", + "thiserror 1.0.69", +] + +[[package]] +name = "leptonica-plumbing" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7a74c43d6f090d39158d233f326f47cd8bba545217595c93662b4e31156f42" +dependencies = [ + "leptonica-sys", + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "leptonica-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da627c72b2499a8106f4dd33143843015e4a631f445d561f3481f7fba35b6151" +dependencies = [ + "bindgen", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fa2559e99ba0f26a12458aabc754432c805bbb8cba516c427825a997af1fb7" +dependencies = [ + "aes", + "bitflags 2.13.1", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "jiff", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.5", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror 2.0.19", + "time", + "weezl", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.7", + "signatory", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.7", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.19", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redis" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37ec3fd44bea2ec947ba6cc7634d7999a6590aca7c35827c250bc0de502bda6" +dependencies = [ + "arc-swap", + "backon", + "bytes", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tesseract-plumbing" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25fbbb95169954a9262a565fbfb001c4d9dad271d48142e6632a3e2b7314b35" +dependencies = [ + "leptonica-plumbing", + "tesseract-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "tesseract-sys" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd33f6f216124cfaf0fa86c2c0cdf04da39b6257bd78c5e44fa4fa98c3a5857b" +dependencies = [ + "bindgen", + "leptonica-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.7", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tools-common" +version = "0.1.0" +dependencies = [ + "async-nats", + "chrono", + "redis", + "serde", + "serde_json", + "thiserror 2.0.19", + "tracing", + "uuid", +] + +[[package]] +name = "tools-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "axum", + "chrono", + "futures", + "redis", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tools-common", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "tools-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "image", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "tools-workers" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "async-trait", + "chrono", + "futures", + "image", + "imageproc", + "leptess", + "lopdf", + "nalgebra", + "rayon", + "redis", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tools-common", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..49c57c9 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,37 @@ +[workspace] +resolver = "2" + +members = [ + "common", + "gateway", + "workers", + "wasm", +] + +default-members = [ + "common", + "gateway", + "workers", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } +thiserror = "2" +async-nats = "0.39" +redis = { version = "0.28", features = ["tokio-comp", "connection-manager", "aio"] } +image = "0.25" +imageproc = "0.25" +lopdf = "0.36" +reqwest = { version = "0.12", features = ["json"] } +anyhow = "1" \ No newline at end of file diff --git a/backend/common/Cargo.toml b/backend/common/Cargo.toml new file mode 100644 index 0000000..3534d35 --- /dev/null +++ b/backend/common/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tools-common" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +tracing.workspace = true +async-nats.workspace = true +redis.workspace = true \ No newline at end of file diff --git a/backend/common/src/error.rs b/backend/common/src/error.rs new file mode 100644 index 0000000..559d2a8 --- /dev/null +++ b/backend/common/src/error.rs @@ -0,0 +1,104 @@ +use thiserror::Error; + +/// Errors that can occur during file upload. +#[derive(Debug, Error)] +pub enum UploadError { + #[error("Invalid MIME type: {0}")] + InvalidMime(String), + + #[error("File too large: {0} bytes exceeds maximum of {1} bytes")] + FileTooLarge(u64, u64), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Virus or suspicious content detected")] + VirusDetected, + + #[error("Invalid tool: {0}")] + InvalidTool(String), + + #[error("Missing file in upload")] + MissingFile, + + #[error("Missing tool parameter")] + MissingTool, + + #[error("Serialization error: {0}")] + Serde(#[from] serde_json::Error), +} + +/// Errors during processing pipeline execution. +#[derive(Debug, Error)] +pub enum PipelineError { + #[error("Failed to load image: {0}")] + ImageLoad(String), + + #[error("Edge detection failed: {0}")] + EdgeDetection(String), + + #[error("Corner detection failed: {0}")] + CornerDetection(String), + + #[error("Perspective warp failed: {0}")] + Warp(String), + + #[error("Shadow removal failed: {0}")] + ShadowRemoval(String), + + #[error("Binarization failed: {0}")] + Binarization(String), + + #[error("OCR processing failed: {0}")] + Ocr(String), + + #[error("PDF generation failed: {0}")] + PdfGeneration(String), + + #[error("Pipeline timed out")] + Timeout, + + #[error("Internal error: {0}")] + Internal(String), +} + +/// Errors related to NATS messaging. +#[derive(Debug, Error)] +pub enum NatsError { + #[error("Failed to publish message: {0}")] + Publish(String), + + #[error("Failed to subscribe: {0}")] + Subscribe(String), + + #[error("JetStream error: {0}")] + JetStream(String), + + #[error("Connection timeout")] + Timeout, + + #[error("NATS connection error: {0}")] + Connection(String), +} + +/// Errors related to Redis operations. +#[derive(Debug, Error)] +pub enum RedisError { + #[error("Redis connection failed: {0}")] + Connection(String), + + #[error("Redis query failed: {0}")] + Query(String), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Key not found: {0}")] + NotFound(String), +} + +impl From for RedisError { + fn from(e: redis::RedisError) -> Self { + RedisError::Query(e.to_string()) + } +} \ No newline at end of file diff --git a/backend/common/src/lib.rs b/backend/common/src/lib.rs new file mode 100644 index 0000000..2d0bf26 --- /dev/null +++ b/backend/common/src/lib.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod nats; +pub mod types; \ No newline at end of file diff --git a/backend/common/src/nats.rs b/backend/common/src/nats.rs new file mode 100644 index 0000000..1ef2dc8 --- /dev/null +++ b/backend/common/src/nats.rs @@ -0,0 +1,104 @@ +/// NATS subject constants for the tools service. +/// +/// Subject naming convention: +/// tools..jobs.{job_id} — Job submission queue +/// tools..progress.{job_id} — Progress update fan-out +/// tools.scheduler.cleanup — Cron-triggered cleanup + +// ── Job Subjects ── + +pub const SCAN_JOBS: &str = "tools.scan.jobs"; +pub const SCAN_PROGRESS: &str = "tools.scan.progress"; +pub const IMAGE_JOBS: &str = "tools.image.jobs"; +pub const IMAGE_PROGRESS: &str = "tools.image.progress"; +pub const PDF_JOBS: &str = "tools.pdf.jobs"; +pub const PDF_PROGRESS: &str = "tools.pdf.progress"; +pub const VIDEO_JOBS: &str = "tools.video.jobs"; +pub const VIDEO_PROGRESS: &str = "tools.video.progress"; +pub const AUDIO_JOBS: &str = "tools.audio.jobs"; +pub const AUDIO_PROGRESS: &str = "tools.audio.progress"; + +// ── Scheduler Subjects ── + +pub const SCHEDULER_CLEANUP: &str = "tools.scheduler.cleanup"; + +// ── Stream Names ── + +pub const STREAM_JOBS: &str = "tools-jobs"; +pub const STREAM_PROGRESS: &str = "tools-progress"; + +// ── Stream Configuration ── + +/// Returns the stream configuration for jobs. +/// Max age: 24h, storage: file (persistent on disk). +pub fn jobs_stream_config() -> async_nats::jetstream::stream::Config { + use async_nats::jetstream::stream::Config; + Config { + name: STREAM_JOBS.to_string(), + subjects: vec![ + "tools.scan.jobs.*".to_string(), + "tools.image.jobs.*".to_string(), + "tools.pdf.jobs.*".to_string(), + "tools.video.jobs.*".to_string(), + "tools.audio.jobs.*".to_string(), + "tools.scheduler.>".to_string(), + ], + max_age: std::time::Duration::from_secs(24 * 3600), + storage: async_nats::jetstream::stream::StorageType::File, + ..Default::default() + } +} + +/// Returns the stream configuration for progress events. +/// Max age: 1h, storage: memory (no persistence needed). +pub fn progress_stream_config() -> async_nats::jetstream::stream::Config { + use async_nats::jetstream::stream::Config; + Config { + name: STREAM_PROGRESS.to_string(), + subjects: vec![ + "tools.scan.progress.*".to_string(), + "tools.image.progress.*".to_string(), + "tools.pdf.progress.*".to_string(), + "tools.video.progress.*".to_string(), + "tools.audio.progress.*".to_string(), + ], + max_age: std::time::Duration::from_secs(3600), + storage: async_nats::jetstream::stream::StorageType::Memory, + ..Default::default() + } +} + +/// Build a job subject for a given tool and job ID. +pub fn job_subject(tool_group: &str, job_id: &str) -> String { + format!("tools.{}.jobs.{}", tool_group, job_id) +} + +/// Build a progress subject for a given tool and job ID. +pub fn progress_subject(tool_group: &str, job_id: &str) -> String { + format!("tools.{}.progress.{}", tool_group, job_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_subject_format() { + assert_eq!(job_subject("scan", "abc-123"), "tools.scan.jobs.abc-123"); + assert_eq!( + progress_subject("scan", "abc-123"), + "tools.scan.progress.abc-123" + ); + assert_eq!( + job_subject("image", "def-456"), + "tools.image.jobs.def-456" + ); + assert_eq!(SCHEDULER_CLEANUP, "tools.scheduler.cleanup"); + } + + #[test] + fn test_stream_names() { + assert_eq!(STREAM_JOBS, "tools-jobs"); + assert_eq!(STREAM_PROGRESS, "tools-progress"); + } +} \ No newline at end of file diff --git a/backend/common/src/types.rs b/backend/common/src/types.rs new file mode 100644 index 0000000..6e01d17 --- /dev/null +++ b/backend/common/src/types.rs @@ -0,0 +1,228 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Status of a processing job. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum JobStatus { + Queued, + Processing { + stage: String, + progress: u8, + }, + Completed, + NeedsManualCrop, + Failed(String), +} + +/// Available tool types. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Tool { + Scan, + ImageCompress, + ImageResize, + ImageConvert, + RemoveBg, + PdfMerge, + PdfSplit, + ImagesToPdf, + PdfCompress, + PdfToImages, + VideoCompress, + AudioExtract, + VideoTrim, + GifMaker, + AudioConvert, +} + +impl Tool { + /// Returns the NATS subject prefix for this tool. + pub fn subject_prefix(&self) -> &'static str { + match self { + Tool::Scan => "tools.scan", + Tool::ImageCompress + | Tool::ImageResize + | Tool::ImageConvert + | Tool::RemoveBg => "tools.image", + Tool::PdfMerge + | Tool::PdfSplit + | Tool::ImagesToPdf + | Tool::PdfCompress + | Tool::PdfToImages => "tools.pdf", + Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => "tools.video", + Tool::AudioExtract | Tool::AudioConvert => "tools.audio", + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Tool::Scan => "scan", + Tool::ImageCompress => "image-compress", + Tool::ImageResize => "image-resize", + Tool::ImageConvert => "image-convert", + Tool::RemoveBg => "remove-bg", + Tool::PdfMerge => "pdf-merge", + Tool::PdfSplit => "pdf-split", + Tool::ImagesToPdf => "images-to-pdf", + Tool::PdfCompress => "pdf-compress", + Tool::PdfToImages => "pdf-to-images", + Tool::VideoCompress => "video-compress", + Tool::AudioExtract => "audio-extract", + Tool::VideoTrim => "video-trim", + Tool::GifMaker => "gif-maker", + Tool::AudioConvert => "audio-convert", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "scan" => Some(Tool::Scan), + "image-compress" => Some(Tool::ImageCompress), + "image-resize" => Some(Tool::ImageResize), + "image-convert" => Some(Tool::ImageConvert), + "remove-bg" => Some(Tool::RemoveBg), + "pdf-merge" => Some(Tool::PdfMerge), + "pdf-split" => Some(Tool::PdfSplit), + "images-to-pdf" => Some(Tool::ImagesToPdf), + "pdf-compress" => Some(Tool::PdfCompress), + "pdf-to-images" => Some(Tool::PdfToImages), + "video-compress" => Some(Tool::VideoCompress), + "audio-extract" => Some(Tool::AudioExtract), + "video-trim" => Some(Tool::VideoTrim), + "gif-maker" => Some(Tool::GifMaker), + "audio-convert" => Some(Tool::AudioConvert), + _ => None, + } + } +} + +/// Options for document scanning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanOptions { + pub ocr: bool, + pub enhance: bool, + pub output_format: OutputFormat, + pub dpi: u32, + pub quality: u8, + pub language: String, + pub color_mode: ColorMode, + pub page_size: PageSize, +} + +impl Default for ScanOptions { + fn default() -> Self { + Self { + ocr: true, + enhance: true, + output_format: OutputFormat::Pdf, + dpi: 300, + quality: 90, + language: "eng+ind".to_string(), + color_mode: ColorMode::BlackAndWhite, + page_size: PageSize::A4, + } + } +} + +/// Options for image tools. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageOptions { + pub quality: Option, + pub width: Option, + pub height: Option, + pub format: Option, + pub fit: Option, + pub bg_color: Option<[u8; 3]>, +} + +/// Options for PDF tools. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdfOptions { + pub quality: Option, + pub pages: Option, + pub dpi: Option, + pub page_size: Option, + pub margin_mm: Option, +} + +/// A complete job record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + pub id: Uuid, + pub tool: Tool, + pub status: JobStatus, + pub file_path: String, + pub result_path: Option, + pub file_size: u64, + pub options: serde_json::Value, + pub created_at: DateTime, + pub ttl_seconds: u64, +} + +/// Progress update sent via NATS and forwarded via WebSocket. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobProgress { + pub job_id: Uuid, + pub status: JobStatus, + pub stage: String, + pub progress: u8, + pub message: String, +} + +/// Response returned after successful upload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadResponse { + pub job_id: Uuid, + pub status: String, + pub tool: String, + pub ws_url: String, + pub created_at: DateTime, + pub estimated_seconds: u8, +} + +/// Job status response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobStatusResponse { + pub job_id: Uuid, + pub status: String, + pub tool: String, + pub progress: u8, + pub stage: String, + pub message: String, + pub result: Option, + pub created_at: DateTime, + pub error: Option, +} + +/// Result metadata included in status response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultInfo { + pub download_url: String, + pub file_size: u64, + pub file_name: String, + pub preview_url: Option, +} + +/// Output format for scan results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum OutputFormat { + Pdf, + Jpeg, + Png, +} + +/// Color mode for processed output. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ColorMode { + BlackAndWhite, + Grayscale, + Color, +} + +/// Page size for PDF output. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PageSize { + A4, + Letter, + Auto, +} \ No newline at end of file diff --git a/backend/gateway/Cargo.toml b/backend/gateway/Cargo.toml new file mode 100644 index 0000000..bdc83c3 --- /dev/null +++ b/backend/gateway/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tools-gateway" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tools-common = { path = "../common" } + +axum = { version = "0.8", features = ["multipart", "ws"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "limit"] } +tokio.workspace = true +tokio-util = { version = "0.7", features = ["io"] } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +async-nats.workspace = true +redis.workspace = true +thiserror.workspace = true +anyhow.workspace = true +futures = "0.3" \ No newline at end of file diff --git a/backend/gateway/src/config.rs b/backend/gateway/src/config.rs new file mode 100644 index 0000000..d0642cb --- /dev/null +++ b/backend/gateway/src/config.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +/// Application configuration loaded from environment variables. +#[derive(Debug, Clone)] +pub struct AppConfig { + pub port: u16, + pub nats_url: String, + pub redis_url: String, + pub storage_path: PathBuf, + pub max_file_size_mb: u64, + pub job_ttl_seconds: u64, + pub rate_limit_per_minute: u32, + pub rust_log: String, +} + +impl AppConfig { + /// Load configuration from environment variables with sensible defaults. + pub fn from_env() -> Self { + Self { + port: env_or_default("GATEWAY_PORT", "3001") + .parse() + .unwrap_or(3001), + nats_url: env_or_default("NATS_URL", "nats://localhost:4222"), + redis_url: env_or_default("REDIS_URL", "redis://localhost:6379"), + storage_path: PathBuf::from(env_or_default("STORAGE_PATH", "/data/tools")), + max_file_size_mb: env_or_default("MAX_FILE_SIZE_MB", "50") + .parse() + .unwrap_or(50), + job_ttl_seconds: env_or_default("JOB_TTL_SECONDS", "3600") + .parse() + .unwrap_or(3600), + rate_limit_per_minute: env_or_default("RATE_LIMIT_PER_MINUTE", "30") + .parse() + .unwrap_or(30), + rust_log: env_or_default("RUST_LOG", "info"), + } + } + + pub fn max_file_size_bytes(&self) -> u64 { + self.max_file_size_mb * 1024 * 1024 + } +} + +fn env_or_default(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = AppConfig::from_env(); + assert_eq!(config.port, 3001); + assert_eq!(config.nats_url, "nats://localhost:4222"); + assert_eq!(config.redis_url, "redis://localhost:6379"); + assert_eq!(config.max_file_size_mb, 50); + assert_eq!(config.job_ttl_seconds, 3600); + assert_eq!(config.rate_limit_per_minute, 30); + } + + #[test] + fn test_file_size_bytes() { + let config = AppConfig::from_env(); + assert_eq!(config.max_file_size_bytes(), 50 * 1024 * 1024); + } + + #[test] + fn test_env_override() { + std::env::set_var("GATEWAY_PORT", "9999"); + let config = AppConfig::from_env(); + assert_eq!(config.port, 9999); + std::env::remove_var("GATEWAY_PORT"); + } +} \ No newline at end of file diff --git a/backend/gateway/src/main.rs b/backend/gateway/src/main.rs new file mode 100644 index 0000000..5cca287 --- /dev/null +++ b/backend/gateway/src/main.rs @@ -0,0 +1,143 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::{ + routing::{get, post}, + Router, +}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::limit::RequestBodyLimitLayer; +use tower_http::trace::TraceLayer; +use tracing_subscriber::EnvFilter; + +mod config; +mod metrics; +mod middleware; +mod nats; +mod redis; +mod routes; + +use config::AppConfig; +use metrics::Metrics; +use routes::health::AppState; + +#[tokio::main] +async fn main() { + // Load config + let config = AppConfig::from_env(); + + // Init logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(&config.rust_log)) + .init(); + + tracing::info!("Starting tools-gateway..."); + + // Init Redis client + let redis_client = redis::create_client(&config.redis_url) + .expect("Failed to create Redis client"); + tracing::info!("Redis client created for {}", config.redis_url); + + // Init NATS connection + let nats = nats::publisher::NatsPublisher::connect(&config.nats_url) + .await + .expect("Failed to connect to NATS"); + tracing::info!("Connected to NATS at {}", config.nats_url); + + // Ensure NATS streams exist + if let Err(e) = ensure_nats_streams(&nats).await { + tracing::warn!("Failed to create NATS streams: {}", e); + } + + // Init metrics + let metrics = Metrics::new(); + + // Shared state + let state = Arc::new(AppState { + redis: redis_client, + nats, + config: config.clone(), + metrics, + }); + + // Build router + let app = Router::new() + .route("/api/upload", post(routes::upload::upload_handler)) + .route("/api/job/{id}", get(routes::job::job_status_handler)) + .route( + "/api/job/{id}/preview", + get(routes::job::job_preview_handler), + ) + .route("/api/job/{id}/ws", get(routes::ws::ws_handler)) + .route("/api/download/{id}", get(routes::download::download_handler)) + .route("/health", get(routes::health::health_handler)) + .route("/metrics", get(routes::health::metrics_handler)) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::new().allow_origin(Any)) + .layer(RequestBodyLimitLayer::new( + ((config.max_file_size_mb + 1) * 1024 * 1024) as usize, + )) + .with_state(state); + + // Start server + let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); + tracing::info!("Gateway listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); +} + +/// Ensure required NATS JetStream streams exist. +async fn ensure_nats_streams( + nats: &async_nats::Client, +) -> Result<(), Box> { + let js = async_nats::jetstream::new(nats.clone()); + + match js + .get_or_create_stream(tools_common::nats::jobs_stream_config()) + .await + { + Ok(_) => tracing::info!("NATS stream 'tools-jobs' ready"), + Err(e) => tracing::warn!("Failed to create tools-jobs stream: {}", e), + } + + match js + .get_or_create_stream(tools_common::nats::progress_stream_config()) + .await + { + Ok(_) => tracing::info!("NATS stream 'tools-progress' ready"), + Err(e) => tracing::warn!("Failed to create tools-progress stream: {}", e), + } + + Ok(()) +} + +/// Handle graceful shutdown on SIGINT/SIGTERM. +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::info!("Shutting down gateway..."); +} \ No newline at end of file diff --git a/backend/gateway/src/metrics.rs b/backend/gateway/src/metrics.rs new file mode 100644 index 0000000..d49e866 --- /dev/null +++ b/backend/gateway/src/metrics.rs @@ -0,0 +1,181 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +/// Simple Prometheus metrics collector. +pub struct Metrics { + /// Counter: tools_jobs_total{tool, status} + jobs_total: Mutex>, + /// Counter: tools_uploaded_files_total{tool, status} + uploaded_files_total: Mutex>, + /// Histogram buckets for processing duration (ms) + duration_buckets: Vec, + /// Histogram: tools_processing_duration_ms{tool} + duration_histogram: Mutex>>, + /// Gauge: tools_queue_depth{tool} + queue_depth: Mutex>, + /// Counter: tools_rate_limit_hits{tool} + rate_limit_hits: Mutex>, + /// Counter: cleanup deleted files + cleanup_deleted_files: AtomicU64, +} + +impl Metrics { + pub fn new() -> Self { + Self { + jobs_total: Mutex::new(HashMap::new()), + uploaded_files_total: Mutex::new(HashMap::new()), + duration_buckets: vec![ + 100.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0, 32000.0, + ], + duration_histogram: Mutex::new(HashMap::new()), + queue_depth: Mutex::new(HashMap::new()), + rate_limit_hits: Mutex::new(HashMap::new()), + cleanup_deleted_files: AtomicU64::new(0), + } + } + + pub fn increment_jobs_total(&self, tool: &str, status: &str) { + if let Ok(mut map) = self.jobs_total.lock() { + map.entry((tool.to_string(), status.to_string())) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(1, Ordering::Relaxed); + } + } + + pub fn increment_uploaded_files(&self, tool: &str, status: &str) { + if let Ok(mut map) = self.uploaded_files_total.lock() { + map.entry((tool.to_string(), status.to_string())) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(1, Ordering::Relaxed); + } + } + + #[allow(unused)] + pub fn record_duration(&self, tool: &str, duration_ms: f64) { + if let Ok(mut map) = self.duration_histogram.lock() { + let entry = map + .entry(tool.to_string()) + .or_insert_with(|| { + (0..self.duration_buckets.len()) + .map(|_| AtomicU64::new(0)) + .collect() + }); + for (i, bucket) in self.duration_buckets.iter().enumerate() { + if duration_ms <= *bucket { + if let Some(b) = entry.get(i) { + b.fetch_add(1, Ordering::Relaxed); + } + } + } + } + } + + pub fn set_queue_depth(&self, tool: &str, depth: u64) { + if let Ok(mut map) = self.queue_depth.lock() { + map.entry(tool.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .store(depth, Ordering::Relaxed); + } + } + + #[allow(unused)] + pub fn increment_rate_limit_hits(&self, tool: &str) { + if let Ok(mut map) = self.rate_limit_hits.lock() { + map.entry(tool.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(1, Ordering::Relaxed); + } + } + + #[allow(unused)] + pub fn increment_cleanup_deleted(&self) { + self.cleanup_deleted_files.fetch_add(1, Ordering::Relaxed); + } + + /// Format all metrics as Prometheus text format. + pub fn format(&self) -> String { + let mut output = String::new(); + + output.push_str("# HELP tools_jobs_total Total jobs processed\n"); + output.push_str("# TYPE tools_jobs_total counter\n"); + if let Ok(map) = self.jobs_total.lock() { + for ((tool, status), count) in map.iter() { + let val = count.load(Ordering::Relaxed); + output.push_str(&format!( + "tools_jobs_total{{tool=\"{}\",status=\"{}\"}} {}\n", + tool, status, val + )); + } + } + + output.push_str("# HELP tools_uploaded_files_total Total uploaded files\n"); + output.push_str("# TYPE tools_uploaded_files_total counter\n"); + if let Ok(map) = self.uploaded_files_total.lock() { + for ((tool, status), count) in map.iter() { + let val = count.load(Ordering::Relaxed); + output.push_str(&format!( + "tools_uploaded_files_total{{tool=\"{}\",status=\"{}\"}} {}\n", + tool, status, val + )); + } + } + + output.push_str("# HELP tools_processing_duration_ms Processing duration histogram\n"); + output.push_str("# TYPE tools_processing_duration_ms histogram\n"); + if let Ok(map) = self.duration_histogram.lock() { + for (tool, buckets) in map.iter() { + for (i, bucket) in self.duration_buckets.iter().enumerate() { + if let Some(b) = buckets.get(i) { + let val = b.load(Ordering::Relaxed); + if val > 0 { + output.push_str(&format!( + "tools_processing_duration_ms_bucket{{tool=\"{}\",le=\"{}\"}} {}\n", + tool, bucket, val + )); + } + } + } + } + } + + output.push_str("# HELP tools_queue_depth Current queue depth\n"); + output.push_str("# TYPE tools_queue_depth gauge\n"); + if let Ok(map) = self.queue_depth.lock() { + for (tool, depth) in map.iter() { + let val = depth.load(Ordering::Relaxed); + output.push_str(&format!( + "tools_queue_depth{{tool=\"{}\"}} {}\n", + tool, val + )); + } + } + + output.push_str("# HELP tools_rate_limit_hits Total rate limit violations\n"); + output.push_str("# TYPE tools_rate_limit_hits counter\n"); + if let Ok(map) = self.rate_limit_hits.lock() { + for (tool, count) in map.iter() { + let val = count.load(Ordering::Relaxed); + output.push_str(&format!( + "tools_rate_limit_hits{{tool=\"{}\"}} {}\n", + tool, val + )); + } + } + + output.push_str("# HELP tools_cleanup_deleted_files Total files deleted by cleanup\n"); + output.push_str("# TYPE tools_cleanup_deleted_files counter\n"); + output.push_str(&format!( + "tools_cleanup_deleted_files {}\n", + self.cleanup_deleted_files.load(Ordering::Relaxed) + )); + + output + } +} + +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/backend/gateway/src/middleware/error_handler.rs b/backend/gateway/src/middleware/error_handler.rs new file mode 100644 index 0000000..01cc712 --- /dev/null +++ b/backend/gateway/src/middleware/error_handler.rs @@ -0,0 +1,66 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +/// Unified JSON error response format. +#[derive(Debug)] +pub struct AppError { + pub status_code: StatusCode, + pub code: String, + pub message: String, +} + +impl AppError { + pub fn bad_request(message: impl Into) -> Self { + Self { + status_code: StatusCode::BAD_REQUEST, + code: "bad_request".to_string(), + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self { + status_code: StatusCode::NOT_FOUND, + code: "not_found".to_string(), + message: message.into(), + } + } + + pub fn too_large(message: impl Into) -> Self { + Self { + status_code: StatusCode::PAYLOAD_TOO_LARGE, + code: "file_too_large".to_string(), + message: message.into(), + } + } + + pub fn rate_limited(retry_after: u64) -> Self { + Self { + status_code: StatusCode::TOO_MANY_REQUESTS, + code: "rate_limit_exceeded".to_string(), + message: format!("Rate limit exceeded. Retry after {} seconds", retry_after), + } + } + + pub fn internal(message: impl Into) -> Self { + Self { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + code: "internal_error".to_string(), + message: message.into(), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = json!({ + "error": self.message, + "code": self.code, + }); + (self.status_code, Json(body)).into_response() + } +} \ No newline at end of file diff --git a/backend/gateway/src/middleware/mod.rs b/backend/gateway/src/middleware/mod.rs new file mode 100644 index 0000000..b96207f --- /dev/null +++ b/backend/gateway/src/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod error_handler; +pub mod request_id; \ No newline at end of file diff --git a/backend/gateway/src/middleware/request_id.rs b/backend/gateway/src/middleware/request_id.rs new file mode 100644 index 0000000..585ed5e --- /dev/null +++ b/backend/gateway/src/middleware/request_id.rs @@ -0,0 +1,73 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context, Poll}; + +use axum::{extract::Request, response::Response}; +use tower::{Layer, Service}; +use uuid::Uuid; + +/// Middleware that adds a unique X-Request-Id header to every request. +#[derive(Clone, Default)] +pub struct RequestIdLayer; + +impl Layer for RequestIdLayer { + type Service = RequestIdMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + RequestIdMiddleware { + inner, + counter: AtomicU64::new(0), + } + } +} + +pub struct RequestIdMiddleware { + inner: S, + counter: AtomicU64, +} + +impl Clone for RequestIdMiddleware { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + counter: AtomicU64::new(self.counter.load(Ordering::Relaxed)), + } + } +} + +impl Service> for RequestIdMiddleware +where + S: Service, Response = Response>, + S::Future: Send + 'static, + S::Error: 'static, + ReqBody: Send + 'static, + ResBody: Default + Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let request_id = Uuid::new_v4().to_string(); + let (mut parts, body) = req.into_parts(); + parts + .headers + .insert("x-request-id", request_id.parse().unwrap()); + + let req = Request::from_parts(parts, body); + let fut = self.inner.call(req); + + Box::pin(async move { + let mut response: Response = fut.await?; + response + .headers_mut() + .insert("x-request-id", request_id.parse().unwrap()); + Ok(response) + }) + } +} \ No newline at end of file diff --git a/backend/gateway/src/nats/mod.rs b/backend/gateway/src/nats/mod.rs new file mode 100644 index 0000000..21e3f07 --- /dev/null +++ b/backend/gateway/src/nats/mod.rs @@ -0,0 +1 @@ +pub mod publisher; \ No newline at end of file diff --git a/backend/gateway/src/nats/publisher.rs b/backend/gateway/src/nats/publisher.rs new file mode 100644 index 0000000..36ee1f8 --- /dev/null +++ b/backend/gateway/src/nats/publisher.rs @@ -0,0 +1,64 @@ +use async_nats::Client; +use tools_common::error::NatsError; +use tools_common::nats; +use tools_common::types::{Job, JobProgress, Tool}; + +/// NATS publisher for job and progress messages. +pub struct NatsPublisher; + +impl NatsPublisher { + /// Connect to NATS server. + pub async fn connect(url: &str) -> Result { + async_nats::connect(url) + .await + .map_err(|e| NatsError::Connection(e.to_string())) + } + + /// Publish a job to the appropriate NATS subject. + pub async fn publish_job(nats: &Client, tool: &Tool, job: &Job) -> Result<(), NatsError> { + let prefix = tool.subject_prefix(); + let subject = nats::job_subject(prefix, &job.id.to_string()); + let payload = serde_json::to_vec(job) + .map_err(|e| NatsError::Publish(e.to_string()))?; + + nats.publish(subject, payload.into()) + .await + .map_err(|e| NatsError::Publish(e.to_string()))?; + + tracing::debug!( + job_id = %job.id, + tool = %tool.as_str(), + "Published job to NATS" + ); + + Ok(()) + } + + /// Publish a progress update to the NATS progress subject. + pub async fn publish_progress( + nats: &Client, + progress: &JobProgress, + ) -> Result<(), NatsError> { + let tool_prefix = ""; // We need the tool from somewhere — stored in progress + let subject = format!("tools.*.progress.{}", progress.job_id); + let payload = serde_json::to_vec(progress) + .map_err(|e| NatsError::Publish(e.to_string()))?; + + nats.publish(subject, payload.into()) + .await + .map_err(|e| NatsError::Publish(e.to_string()))?; + + Ok(()) + } + + /// Subscribe to NATS progress updates for a specific job. + pub async fn subscribe_progress( + nats: &Client, + job_id: &str, + ) -> Result { + let subject = format!("tools.*.progress.{}", job_id); + nats.subscribe(subject) + .await + .map_err(|e| NatsError::Subscribe(e.to_string())) + } +} \ No newline at end of file diff --git a/backend/gateway/src/redis/job.rs b/backend/gateway/src/redis/job.rs new file mode 100644 index 0000000..d1c3879 --- /dev/null +++ b/backend/gateway/src/redis/job.rs @@ -0,0 +1,78 @@ +use redis::{AsyncCommands, RedisError}; +use uuid::Uuid; + +use tools_common::types::Job; + +/// Repository for job CRUD operations on Redis. +pub struct JobRepository; + +impl JobRepository { + /// Create a new job record in Redis with TTL. + pub async fn create( + conn: &mut impl AsyncCommands, + job: &Job, + ) -> Result<(), Box> { + let key = format!("job:{}", job.id); + let json = serde_json::to_string(job)?; + let _: () = conn + .set_ex(key, json, job.ttl_seconds) + .await + .map_err(|e: RedisError| -> Box { Box::new(e) })?; + Ok(()) + } + + /// Get a job by ID from Redis. + pub async fn get( + conn: &mut impl AsyncCommands, + job_id: Uuid, + ) -> Result> { + let key = format!("job:{}", job_id); + let json: String = conn.get(&key).await.map_err(|_| { + Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Job {} not found", job_id), + )) as Box + })?; + let job: Job = serde_json::from_str(&json)?; + Ok(job) + } + + /// Update the status of a job in Redis and refresh TTL. + pub async fn update_status( + conn: &mut impl AsyncCommands, + job_id: Uuid, + status: &tools_common::types::JobStatus, + result_path: Option, + ttl_seconds: u64, + ) -> Result<(), Box> { + let key = format!("job:{}", job_id); + let json: String = conn + .get(&key) + .await + .map_err(|e: RedisError| -> Box { Box::new(e) })?; + let mut job: Job = serde_json::from_str(&json)?; + job.status = status.clone(); + if let Some(path) = result_path { + job.result_path = Some(path); + } + let json = serde_json::to_string(&job)?; + let _: () = conn + .set_ex(key, json, ttl_seconds) + .await + .map_err(|e: RedisError| -> Box { Box::new(e) })?; + Ok(()) + } + + /// Delete a job from Redis. + pub async fn delete( + conn: &mut impl AsyncCommands, + job_id: Uuid, + ) -> Result<(), Box> { + let key = format!("job:{}", job_id); + let _: usize = conn + .del(key) + .await + .map_err(|e: RedisError| -> Box { Box::new(e) })?; + Ok(()) + } +} \ No newline at end of file diff --git a/backend/gateway/src/redis/mod.rs b/backend/gateway/src/redis/mod.rs new file mode 100644 index 0000000..0005372 --- /dev/null +++ b/backend/gateway/src/redis/mod.rs @@ -0,0 +1,9 @@ +pub mod job; +pub mod ratelimit; + +use redis::Client; + +/// Create a Redis client. +pub fn create_client(url: &str) -> Result { + Client::open(url) +} \ No newline at end of file diff --git a/backend/gateway/src/redis/ratelimit.rs b/backend/gateway/src/redis/ratelimit.rs new file mode 100644 index 0000000..c770a79 --- /dev/null +++ b/backend/gateway/src/redis/ratelimit.rs @@ -0,0 +1,50 @@ +use redis::AsyncCommands; + +/// Sliding window rate limiter using Redis sorted sets. +pub struct RateLimiter; + +impl RateLimiter { + /// Check if a request is within the rate limit. + pub async fn check( + conn: &mut impl AsyncCommands, + ip: &str, + tool: &str, + max_per_minute: u32, + ) -> Result> { + let key = format!("ratelimit:{}:{}", ip, tool); + let now = chrono::Utc::now().timestamp_millis(); + let window_start = now - 60_000; + + // Remove entries outside the window + let _: usize = conn.zrembyscore(&key, 0, window_start).await?; + + // Add current entry + let _: usize = conn + .zadd(&key, format!("{}:{}", ip, now), now as f64) + .await?; + + // Set TTL on the key (cleanup) + let _: usize = conn.expire(&key, 120).await?; + + // Count entries in window + let count: u32 = conn.zcount(&key, window_start, now).await?; + + Ok(count <= max_per_minute) + } + + /// Get remaining requests within the current window. + pub async fn remaining( + conn: &mut impl AsyncCommands, + ip: &str, + tool: &str, + max_per_minute: u32, + ) -> Result> { + let key = format!("ratelimit:{}:{}", ip, tool); + let now = chrono::Utc::now().timestamp_millis(); + let window_start = now - 60_000; + + let count: u32 = conn.zcount(&key, window_start, now).await?; + + Ok(max_per_minute.saturating_sub(count)) + } +} \ No newline at end of file diff --git a/backend/gateway/src/routes/download.rs b/backend/gateway/src/routes/download.rs new file mode 100644 index 0000000..5a7d14a --- /dev/null +++ b/backend/gateway/src/routes/download.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use tokio_util::io::ReaderStream; +use uuid::Uuid; + +use crate::routes::health::AppState; +use tools_common::types::JobStatus; + +/// Handle GET /api/download/{id} +pub async fn download_handler( + State(state): State>, + Path(id): Path, +) -> Result { + let mut conn = state.redis.get_multiplexed_async_connection().await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + JsonResponse(serde_json::json!({ "error": "Redis connection failed" })), + ) + })?; + + let job = crate::redis::job::JobRepository::get(&mut conn, id) + .await + .map_err(|_| { + ( + StatusCode::NOT_FOUND, + JsonResponse(serde_json::json!({ "error": "Job not found or expired" })), + ) + })?; + + // Verify job is completed + if job.status != JobStatus::Completed { + return Err(( + StatusCode::BAD_REQUEST, + JsonResponse(serde_json::json!({ + "error": "Job is not completed yet", + "status": match job.status { + JobStatus::Queued => "queued", + JobStatus::Processing { .. } => "processing", + JobStatus::NeedsManualCrop => "needs_manual_crop", + JobStatus::Failed(_) => "failed", + _ => "unknown", + } + })), + )); + } + + let result_path = job.result_path.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + JsonResponse(serde_json::json!({ "error": "Result file path not found" })), + ) + })?; + + // Open file + let file = tokio::fs::File::open(&result_path).await.map_err(|e| { + ( + StatusCode::NOT_FOUND, + JsonResponse(serde_json::json!({ "error": format!("File not found: {}", e) })), + ) + })?; + + let metadata = file.metadata().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + JsonResponse(serde_json::json!({ + "error": format!("Failed to read metadata: {}", e) + })), + ) + })?; + + // Determine content type + let ext = result_path.rsplit('.').next().unwrap_or("bin").to_string(); + let content_type = match ext.as_str() { + "pdf" => "application/pdf", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "mp4" => "video/mp4", + "mp3" => "audio/mpeg", + "zip" => "application/zip", + _ => "application/octet-stream", + }; + + // Generate filename for download + let file_name = format!( + "{}_{}.{}", + job.tool.as_str(), + job.id.to_string().split('-').next().unwrap_or("result"), + ext + ); + + // Stream the file + let stream = ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + + let response = Response::builder() + .header(header::CONTENT_TYPE, content_type) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", file_name), + ) + .header(header::CONTENT_LENGTH, metadata.len().to_string()) + .body(body) + .unwrap(); + + Ok(response) +} + +/// Wrapper for JSON error responses. +pub struct JsonResponse(pub serde_json::Value); + +impl IntoResponse for JsonResponse { + fn into_response(self) -> Response { + (StatusCode::OK, axum::Json(self.0)).into_response() + } +} \ No newline at end of file diff --git a/backend/gateway/src/routes/health.rs b/backend/gateway/src/routes/health.rs new file mode 100644 index 0000000..39d6983 --- /dev/null +++ b/backend/gateway/src/routes/health.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use axum::{extract::State, Json}; +use redis::AsyncCommands; +use serde::Serialize; + +use crate::metrics::Metrics; + +/// Shared application state accessible from all handlers. +pub struct AppState { + pub redis: redis::Client, + pub nats: async_nats::Client, + pub config: crate::config::AppConfig, + pub metrics: Metrics, +} + +/// Health check response. +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, + pub version: String, + pub redis: String, + pub nats: String, + pub uptime_seconds: u64, +} + +/// Handle GET /health +pub async fn health_handler( + State(state): State>, +) -> Json { + let redis_status = { + match state.redis.get_multiplexed_async_connection().await { + Ok(mut conn) => match redis::cmd("PING").query_async::(&mut conn).await { + Ok(_) => "connected".to_string(), + Err(_) => "error".to_string(), + }, + Err(_) => "disconnected".to_string(), + } + }; + + let nats_status = if state + .nats + .publish("tools.health.check", b"ping".to_vec().into()) + .await + .is_ok() + { + "connected".to_string() + } else { + "disconnected".to_string() + }; + + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + redis: redis_status, + nats: nats_status, + uptime_seconds: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + }) +} + +/// Handle GET /metrics +pub async fn metrics_handler( + State(state): State>, +) -> Result)> { + Ok(state.metrics.format()) +} \ No newline at end of file diff --git a/backend/gateway/src/routes/job.rs b/backend/gateway/src/routes/job.rs new file mode 100644 index 0000000..302455b --- /dev/null +++ b/backend/gateway/src/routes/job.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use uuid::Uuid; + +use crate::routes::health::AppState; +use tools_common::types::*; + +/// Handle GET /api/job/{id} +pub async fn job_status_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let mut conn = state.redis.get_multiplexed_async_connection().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Redis error: {}", e) })), + ) + })?; + + let job = crate::redis::job::JobRepository::get(&mut conn, id) + .await + .map_err(|_| { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Job not found or expired" })), + ) + })?; + + let status_str = match &job.status { + JobStatus::Queued => "queued", + JobStatus::Processing { .. } => "processing", + JobStatus::Completed => "completed", + JobStatus::NeedsManualCrop => "needs_manual_crop", + JobStatus::Failed(_) => "failed", + }; + + let (progress, stage, message) = match &job.status { + JobStatus::Processing { stage, progress } => (*progress, stage.clone(), String::new()), + JobStatus::Failed(msg) => (0, String::new(), msg.clone()), + JobStatus::Completed => (100, "complete".to_string(), "Processing complete".to_string()), + JobStatus::Queued => (0, "queued".to_string(), "Waiting in queue".to_string()), + JobStatus::NeedsManualCrop => { + (0, "manual_crop".to_string(), "Manual crop needed".to_string()) + } + }; + + let result = if job.status == JobStatus::Completed { + let file_name = job + .result_path + .as_ref() + .and_then(|p| std::path::Path::new(p).file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("result") + .to_string(); + Some(ResultInfo { + download_url: format!("/api/download/{}", job.id), + file_size: job.file_size, + file_name, + preview_url: Some(format!("/api/job/{}/preview", job.id)), + }) + } else { + None + }; + + let error = match &job.status { + JobStatus::Failed(msg) => Some(msg.clone()), + _ => None, + }; + + Ok(Json(JobStatusResponse { + job_id: job.id, + status: status_str.to_string(), + tool: job.tool.as_str().to_string(), + progress, + stage, + message, + result, + created_at: job.created_at, + error, + })) +} + +/// Handle GET /api/job/{id}/preview +pub async fn job_preview_handler( + State(state): State>, + Path(id): Path, +) -> Result<(StatusCode, [(String, String); 2], Vec), (StatusCode, Json)> { + let mut conn = state.redis.get_multiplexed_async_connection().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Redis error: {}", e) })), + ) + })?; + + let job = crate::redis::job::JobRepository::get(&mut conn, id) + .await + .map_err(|_| { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Job not found or expired" })), + ) + })?; + + let result_path = job.result_path.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No result available yet" })), + ) + })?; + + let data = tokio::fs::read(&result_path).await.map_err(|e| { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("File not found: {}", e) })), + ) + })?; + + let ext = result_path.rsplit('.').next().unwrap_or("bin").to_string(); + let content_type = match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }; + + Ok(( + StatusCode::OK, + [ + ("Content-Type".to_string(), content_type.to_string()), + ( + "Cache-Control".to_string(), + "private, max-age=300".to_string(), + ), + ], + data, + )) +} \ No newline at end of file diff --git a/backend/gateway/src/routes/mod.rs b/backend/gateway/src/routes/mod.rs new file mode 100644 index 0000000..e6c8dc9 --- /dev/null +++ b/backend/gateway/src/routes/mod.rs @@ -0,0 +1,5 @@ +pub mod download; +pub mod health; +pub mod job; +pub mod upload; +pub mod ws; \ No newline at end of file diff --git a/backend/gateway/src/routes/upload.rs b/backend/gateway/src/routes/upload.rs new file mode 100644 index 0000000..40c9674 --- /dev/null +++ b/backend/gateway/src/routes/upload.rs @@ -0,0 +1,261 @@ +use std::sync::Arc; + +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + Json, +}; +use chrono::Utc; +use tokio::fs; +use uuid::Uuid; + +use crate::routes::health::AppState; +use tools_common::error::UploadError; +use tools_common::types::*; + +/// Handle POST /api/upload +pub async fn upload_handler( + State(state): State>, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + let config = &state.config; + let max_size = config.max_file_size_bytes(); + + // Extract fields from multipart + let mut file_data: Option<(String, Vec)> = None; + let mut tool_str: Option = None; + let mut options: serde_json::Value = serde_json::Value::Null; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + let filename = field.file_name().unwrap_or("unknown").to_string(); + let data = field.bytes().await.map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Failed to read file", + "detail": e.to_string() + })), + ) + })?; + file_data = Some((filename, data.to_vec())); + } + "tool" => { + tool_str = Some(field.text().await.unwrap_or_default()); + } + "options" => { + let text = field.text().await.unwrap_or_default(); + if !text.is_empty() { + options = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); + } + } + _ => {} + } + } + + // Validate fields + let (filename, data) = file_data.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No file provided" })), + ) + })?; + + let tool_str = tool_str.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No tool specified" })), + ) + })?; + + let tool = Tool::from_str(&tool_str).ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Unknown tool: {}", tool_str) })), + ) + })?; + + // Validate file size + let file_size = data.len() as u64; + if file_size > max_size { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({ + "error": format!("File too large: {} bytes (max {} bytes)", file_size, max_size) + })), + )); + } + + // Validate MIME type based on tool + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + validate_mime(&tool, &ext).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ) + })?; + + // Verify magic bytes + if !verify_magic_bytes(&data, &ext) { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "File content does not match extension" })), + )); + } + + // Create directories + let upload_dir = config.storage_path.join("upload"); + fs::create_dir_all(&upload_dir).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Storage error: {}", e) })), + ) + })?; + + // Generate job ID and save file + let job_id = Uuid::new_v4(); + let storage_filename = format!("{}.{}", job_id, ext); + let file_path = upload_dir.join(&storage_filename); + fs::write(&file_path, &data).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to save file: {}", e) })), + ) + })?; + + // Create job record + let job = Job { + id: job_id, + tool: tool.clone(), + status: JobStatus::Queued, + file_path: file_path.to_string_lossy().to_string(), + result_path: None, + file_size, + options: options.clone(), + created_at: Utc::now(), + ttl_seconds: config.job_ttl_seconds, + }; + + // Save to Redis + { + let mut conn = state.redis.get_multiplexed_async_connection().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Redis error: {}", e) })), + ) + })?; + crate::redis::job::JobRepository::create(&mut conn, &job) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to create job: {}", e) })), + ) + })?; + } + + // Publish to NATS + crate::nats::publisher::NatsPublisher::publish_job(&state.nats, &tool, &job) + .await + .map_err(|e| { + tracing::error!("Failed to publish job to NATS: {}", e); + }); + + // Update metrics + state.metrics.increment_jobs_total(tool.as_str(), "queued"); + + // Return response + Ok(Json(UploadResponse { + job_id, + status: "queued".to_string(), + tool: tool_str, + ws_url: format!("/api/job/{}/ws", job_id), + created_at: job.created_at, + estimated_seconds: match tool { + Tool::Scan => 5, + _ => 3, + }, + })) +} + +fn validate_mime(tool: &Tool, ext: &str) -> Result<(), UploadError> { + let image_exts = ["jpg", "jpeg", "png", "webp", "heic", "bmp", "tiff", "tif"]; + let pdf_exts = ["pdf"]; + let video_exts = ["mp4", "webm", "avi", "mov", "mkv"]; + let audio_exts = ["mp3", "wav", "flac", "aac", "ogg", "m4a"]; + + match tool { + Tool::Scan + | Tool::ImageCompress + | Tool::ImageResize + | Tool::ImageConvert + | Tool::RemoveBg => { + if !image_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected image file, got .{}", + ext + ))); + } + } + Tool::PdfMerge | Tool::PdfSplit | Tool::PdfCompress | Tool::PdfToImages => { + if !pdf_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected PDF file, got .{}", + ext + ))); + } + } + Tool::ImagesToPdf => { + if !image_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected image file, got .{}", + ext + ))); + } + } + Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => { + if !video_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected video file, got .{}", + ext + ))); + } + } + Tool::AudioExtract => { + if !video_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected video file, got .{}", + ext + ))); + } + } + Tool::AudioConvert => { + if !audio_exts.contains(&ext) { + return Err(UploadError::InvalidMime(format!( + "Expected audio file, got .{}", + ext + ))); + } + } + } + Ok(()) +} + +fn verify_magic_bytes(data: &[u8], ext: &str) -> bool { + if data.is_empty() { + return false; + } + match ext { + "jpg" | "jpeg" => data.starts_with(&[0xFF, 0xD8, 0xFF]), + "png" => data.starts_with(&[0x89, 0x50, 0x4E, 0x47]), + "webp" => data.len() > 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP", + "gif" => data.starts_with(b"GIF8"), + "bmp" => data.starts_with(b"BM"), + "pdf" => data.starts_with(b"%PDF"), + "mp4" => data.len() > 8 && (&data[4..8] == b"ftyp" || &data[4..8] == b"ftyp"), + "heic" => data.len() > 12 && &data[4..12] == b"ftypheic", + _ => true, + } +} \ No newline at end of file diff --git a/backend/gateway/src/routes/ws.rs b/backend/gateway/src/routes/ws.rs new file mode 100644 index 0000000..ad1d8e9 --- /dev/null +++ b/backend/gateway/src/routes/ws.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use axum::{ + extract::{ + ws::{Message, WebSocket}, + Path, State, WebSocketUpgrade, + }, + response::IntoResponse, +}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::routes::health::AppState; +use tools_common::types::{JobProgress, JobStatus}; + +/// Handle WebSocket upgrade at /api/job/{id}/ws. +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, + Path(job_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, state, job_id)) +} + +async fn handle_ws(ws: WebSocket, state: Arc, job_id: Uuid) { + let (mut sender, mut receiver) = ws.split(); + + // Subscribe to NATS progress updates + let nats = state.nats.clone(); + let subject = format!("tools.*.progress.{}", job_id); + let mut subscriber = match nats.subscribe(subject).await { + Ok(sub) => sub, + Err(e) => { + tracing::error!("Failed to subscribe to NATS: {}", e); + let _ = sender + .send(Message::Text( + serde_json::json!({ + "type": "error", + "job_id": job_id, + "status": "failed", + "error": format!("Connection error: {}", e) + }) + .to_string() + .into(), + )) + .await; + return; + } + }; + + // Send initial status from Redis + if let Ok(mut conn) = state.redis.get_multiplexed_async_connection().await { + if let Ok(job) = crate::redis::job::JobRepository::get(&mut conn, job_id).await { + let init_msg = serde_json::json!({ + "type": "status", + "job_id": job_id, + "status": match &job.status { + JobStatus::Queued => "queued", + JobStatus::Processing { .. } => "processing", + JobStatus::Completed => "completed", + JobStatus::NeedsManualCrop => "needs_manual_crop", + JobStatus::Failed(_) => "failed", + }, + "progress": match &job.status { + JobStatus::Processing { progress, .. } => *progress, + JobStatus::Completed => 100, + _ => 0, + }, + }); + let _ = sender + .send(Message::Text(init_msg.to_string().into())) + .await; + } + } + + // Channel for NATS messages + let (tx, mut rx) = mpsc::channel::(32); + + // Spawn NATS listener + let tx_clone = tx.clone(); + let nats_listener = tokio::spawn(async move { + loop { + tokio::select! { + msg = subscriber.next() => { + match msg { + Some(nats_msg) => { + if let Ok(progress) = serde_json::from_slice::(&nats_msg.payload) { + let json = serde_json::json!({ + "type": "progress", + "job_id": progress.job_id, + "status": match &progress.status { + JobStatus::Queued => "queued", + JobStatus::Processing { .. } => "processing", + JobStatus::Completed => "completed", + JobStatus::NeedsManualCrop => "needs_manual_crop", + JobStatus::Failed(_) => "failed", + }, + "progress": progress.progress, + "stage": progress.stage, + "message": progress.message, + }); + let _ = tx_clone.send(json.to_string()).await; + } + } + None => break, + } + } + _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => { + // Keepalive ping + let _ = tx_clone.send(serde_json::json!({"type": "ping"}).to_string()).await; + } + } + } + }); + + // Forward messages from channel to WebSocket + let ws_sender = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if sender.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + // Listen for client close + let ws_receiver = tokio::spawn(async move { + while let Some(Ok(_)) = receiver.next().await { + // Client messages ignored (we only forward server→client) + } + }); + + // Wait for either task to complete (connection closed) + tokio::select! { + _ = ws_sender => {}, + _ = ws_receiver => {}, + } + + // Cancel NATS listener + nats_listener.abort(); +} \ No newline at end of file diff --git a/backend/rust-toolchain.toml b/backend/rust-toolchain.toml new file mode 100644 index 0000000..7bcfaa2 --- /dev/null +++ b/backend/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.85" \ No newline at end of file diff --git a/backend/wasm/Cargo.toml b/backend/wasm/Cargo.toml new file mode 100644 index 0000000..7c88a55 --- /dev/null +++ b/backend/wasm/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tools-wasm" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2" +image.workspace = true +console_error_panic_hook = "0.1" +serde.workspace = true +serde_json.workspace = true + +# Skip wasm crate from default cargo check +# Full build requires: wasm-pack build --target web \ No newline at end of file diff --git a/backend/wasm/src/lib.rs b/backend/wasm/src/lib.rs new file mode 100644 index 0000000..dd22d00 --- /dev/null +++ b/backend/wasm/src/lib.rs @@ -0,0 +1,18 @@ +use wasm_bindgen::prelude::*; + +/// Placeholder for WASM image processing. +/// Full implementation in Phase 2.2. +#[wasm_bindgen] +pub fn greet() -> String { + "tools-wasm: ready".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_greet() { + assert_eq!(greet(), "tools-wasm: ready"); + } +} \ No newline at end of file diff --git a/backend/workers/Cargo.toml b/backend/workers/Cargo.toml new file mode 100644 index 0000000..1981c62 --- /dev/null +++ b/backend/workers/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tools-workers" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tools-common = { path = "../common" } + +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +async-nats.workspace = true +redis.workspace = true +thiserror.workspace = true +anyhow.workspace = true +image.workspace = true +imageproc.workspace = true +nalgebra = "0.32" +lopdf.workspace = true +rayon = "1" +futures = "0.3" +async-trait = "0.1" +leptess = { version = "0.14", optional = true } + +[features] +default = [] +tesseract = ["leptess"] \ No newline at end of file diff --git a/backend/workers/src/audio/mod.rs b/backend/workers/src/audio/mod.rs new file mode 100644 index 0000000..7527523 --- /dev/null +++ b/backend/workers/src/audio/mod.rs @@ -0,0 +1,2 @@ +// Audio processing module. +// TODO: Phase 4 - implement convert, trim \ No newline at end of file diff --git a/backend/workers/src/config.rs b/backend/workers/src/config.rs new file mode 100644 index 0000000..dc341d8 --- /dev/null +++ b/backend/workers/src/config.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +/// Worker configuration loaded from environment variables. +#[derive(Debug, Clone)] +pub struct WorkerConfig { + pub nats_url: String, + pub redis_url: String, + pub storage_path: PathBuf, + pub concurrency: u32, + pub job_ttl_seconds: u64, + pub rust_log: String, +} + +impl WorkerConfig { + pub fn from_env() -> Self { + Self { + nats_url: env_or_default("NATS_URL", "nats://localhost:4222"), + redis_url: env_or_default("REDIS_URL", "redis://localhost:6379"), + storage_path: PathBuf::from(env_or_default("STORAGE_PATH", "/data/tools")), + concurrency: env_or_default("TOOLS_WORKER_CONCURRENCY", "4") + .parse() + .unwrap_or(4), + job_ttl_seconds: env_or_default("JOB_TTL_SECONDS", "3600") + .parse() + .unwrap_or(3600), + rust_log: env_or_default("RUST_LOG", "info"), + } + } +} + +fn env_or_default(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} \ No newline at end of file diff --git a/backend/workers/src/image/mod.rs b/backend/workers/src/image/mod.rs new file mode 100644 index 0000000..72a441e --- /dev/null +++ b/backend/workers/src/image/mod.rs @@ -0,0 +1,15 @@ +use crate::config::WorkerConfig; +use tools_common::types::Job; + +/// Process an image tool job. +pub async fn process_job( + job: Job, + _redis: &redis::Client, + _config: &WorkerConfig, +) -> Result<(), Box> { + tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing image job (stub)"); + // TODO: Phase 2.2 - implement actual image processing + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tracing::info!(job_id = %job.id, "Image job completed"); + Ok(()) +} \ No newline at end of file diff --git a/backend/workers/src/main.rs b/backend/workers/src/main.rs new file mode 100644 index 0000000..885b276 --- /dev/null +++ b/backend/workers/src/main.rs @@ -0,0 +1,39 @@ +mod config; +mod image; +mod nats; +mod pdf; +mod scanner; +mod scheduler; +mod video; +mod audio; + +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + let config = config::WorkerConfig::from_env(); + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(&config.rust_log)) + .init(); + + tracing::info!("Starting tools-workers..."); + + // Connect to NATS + let nats = nats::consumer::JobConsumer::connect(&config.nats_url) + .await + .expect("Failed to connect to NATS"); + tracing::info!("Connected to NATS at {}", config.nats_url); + + // Connect to Redis + let redis = nats::consumer::JobConsumer::connect_redis(&config.redis_url) + .await + .expect("Failed to connect to Redis"); + tracing::info!("Connected to Redis at {}", config.redis_url); + + // Start NATS consumers (blocks forever) + tracing::info!("Starting job consumers..."); + if let Err(e) = nats::consumer::JobConsumer::start(&nats, &redis, &config).await { + tracing::error!("Consumer error: {}", e); + } +} \ No newline at end of file diff --git a/backend/workers/src/nats/consumer.rs b/backend/workers/src/nats/consumer.rs new file mode 100644 index 0000000..447e4ac --- /dev/null +++ b/backend/workers/src/nats/consumer.rs @@ -0,0 +1,186 @@ +use async_nats::Client; +use futures::StreamExt; +use redis::AsyncCommands; +use uuid::Uuid; + +use tools_common::types::{Job, JobStatus}; + +use crate::config::WorkerConfig; + +/// NATS consumer setup and management. +pub struct JobConsumer; + +impl JobConsumer { + /// Connect to NATS. + pub async fn connect(url: &str) -> Result> { + Ok(async_nats::connect(url).await?) + } + + /// Connect to Redis. + pub async fn connect_redis( + url: &str, + ) -> Result> { + Ok(redis::Client::open(url)?) + } + + /// Start consuming job messages from NATS for all tool groups. + pub async fn start( + nats: &Client, + redis: &redis::Client, + config: &WorkerConfig, + ) -> Result<(), Box> { + // Subscribe to scan jobs + let scan_sub = nats + .queue_subscribe("tools.scan.jobs.>", "scan-workers".to_string()) + .await?; + tracing::info!("Subscribed to tools.scan.jobs.>"); + + // Subscribe to image jobs + let image_sub = nats + .queue_subscribe("tools.image.jobs.>", "image-workers".to_string()) + .await?; + tracing::info!("Subscribed to tools.image.jobs.>"); + + // Subscribe to pdf jobs + let pdf_sub = nats + .queue_subscribe("tools.pdf.jobs.>", "pdf-workers".to_string()) + .await?; + tracing::info!("Subscribed to tools.pdf.jobs.>"); + + // Subscribe to cleanup scheduler + let cleanup_sub = nats + .subscribe("tools.scheduler.cleanup".to_string()) + .await?; + tracing::info!("Subscribed to tools.scheduler.cleanup"); + + let redis_clone = redis.clone(); + let config_clone = config.clone(); + + // Process messages concurrently + tokio::select! { + _ = Self::process_subscription(scan_sub, redis.clone(), config.clone()) => {}, + _ = Self::process_subscription(image_sub, redis.clone(), config.clone()) => {}, + _ = Self::process_subscription(pdf_sub, redis.clone(), config.clone()) => {}, + _ = Self::process_cleanup(cleanup_sub, config_clone) => {}, + } + + Ok(()) + } + + /// Process messages from a NATS subscription. + async fn process_subscription( + mut sub: async_nats::Subscriber, + redis: redis::Client, + config: WorkerConfig, + ) { + while let Some(msg) = sub.next().await { + if let Ok(job) = serde_json::from_slice::(&msg.payload) { + let redis = redis.clone(); + let config = config.clone(); + + tokio::spawn(async move { + let tool = job.tool.clone(); + tracing::info!( + job_id = %job.id, + tool = %tool.as_str(), + "Received job" + ); + + match Self::dispatch_job(tool, job, &redis, &config).await { + Ok(()) => tracing::info!("Job completed successfully"), + Err(e) => tracing::error!("Job failed: {}", e), + } + }); + } + } + } + + /// Process cleanup scheduler messages. + async fn process_cleanup(mut sub: async_nats::Subscriber, config: WorkerConfig) { + while let Some(msg) = sub.next().await { + tracing::info!("Running cleanup cycle"); + let redis_url = config.redis_url.clone(); + match redis::Client::open(redis_url.as_str()) { + Ok(client) => { + match crate::scheduler::cleanup::CleanupScheduler::run( + &config.storage_path, + &client, + config.job_ttl_seconds, + ) + .await + { + Ok(result) => { + tracing::info!( + "Cleanup: {} files deleted, {} bytes freed", + result.files_deleted, + result.bytes_freed + ); + } + Err(e) => { + tracing::error!("Cleanup failed: {}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to create Redis client for cleanup: {}", e); + } + } + // Consume the message (no ack for core NATS) + let _ = msg; + } + } + + /// Dispatch a job to the appropriate handler based on tool type. + async fn dispatch_job( + tool: tools_common::types::Tool, + job: Job, + redis: &redis::Client, + config: &WorkerConfig, + ) -> Result<(), Box> { + match tool { + tools_common::types::Tool::Scan => { + crate::scanner::process_job(job, redis, config).await + } + tools_common::types::Tool::ImageCompress + | tools_common::types::Tool::ImageResize + | tools_common::types::Tool::ImageConvert + | tools_common::types::Tool::RemoveBg => { + crate::image::process_job(job, redis, config).await + } + tools_common::types::Tool::PdfMerge + | tools_common::types::Tool::PdfSplit + | tools_common::types::Tool::ImagesToPdf + | tools_common::types::Tool::PdfCompress + | tools_common::types::Tool::PdfToImages => { + crate::pdf::process_job(job, redis, config).await + } + _ => { + tracing::warn!(tool = %tool.as_str(), "Tool handler not yet implemented"); + Ok(()) + } + } + } + + /// Update job result in Redis after processing. + pub async fn update_job_result( + conn: &mut impl AsyncCommands, + job_id: Uuid, + result_path: &str, + ttl_seconds: u64, + ) -> Result<(), Box> { + let key = format!("job:{}", job_id); + let json: String = conn + .get(&key) + .await + .map_err(|e| -> Box { Box::new(e) })?; + let mut job: Job = serde_json::from_str(&json)?; + job.status = JobStatus::Completed; + job.result_path = Some(result_path.to_string()); + let updated = serde_json::to_string(&job)?; + let _: () = conn + .set_ex(key, updated, ttl_seconds) + .await + .map_err(|e| -> Box { Box::new(e) })?; + Ok(()) + } +} \ No newline at end of file diff --git a/backend/workers/src/nats/mod.rs b/backend/workers/src/nats/mod.rs new file mode 100644 index 0000000..4e6ecfa --- /dev/null +++ b/backend/workers/src/nats/mod.rs @@ -0,0 +1,2 @@ +pub mod consumer; +pub mod progress; \ No newline at end of file diff --git a/backend/workers/src/nats/progress.rs b/backend/workers/src/nats/progress.rs new file mode 100644 index 0000000..0833434 --- /dev/null +++ b/backend/workers/src/nats/progress.rs @@ -0,0 +1,82 @@ +use redis::AsyncCommands; +use uuid::Uuid; + +use tools_common::types::{JobStatus, Tool}; + +/// Reports progress from worker to NATS and Redis. +pub struct ProgressReporter { + redis: redis::Client, + nats: async_nats::Client, + job_id: Uuid, + tool: Tool, +} + +impl ProgressReporter { + pub fn new(redis: redis::Client, nats: async_nats::Client, job_id: Uuid, tool: Tool) -> Self { + Self { + redis, + nats, + job_id, + tool, + } + } + + /// Report progress: updates Redis and publishes to NATS. + pub async fn report( + &self, + status: JobStatus, + stage: &str, + progress: u8, + message: &str, + ) -> Result<(), Box> { + // Update Redis + if let Ok(mut conn) = self.redis.get_multiplexed_async_connection().await { + let key = format!("job:{}", self.job_id); + if let Ok(json) = conn.get::<_, String>(&key).await { + if let Ok(mut job) = serde_json::from_str::(&json) { + job.status = status.clone(); + let updated = serde_json::to_string(&job).unwrap_or(json); + let _: Result<(), _> = conn.set_ex(key, updated, job.ttl_seconds).await; + } + } + } + + // Publish to NATS + let progress_msg = tools_common::types::JobProgress { + job_id: self.job_id, + status, + stage: stage.to_string(), + progress, + message: message.to_string(), + }; + + let subject = format!("tools.{}.progress.{}", self.tool.subject_prefix(), self.job_id); + if let Ok(payload) = serde_json::to_vec(&progress_msg) { + let _ = self.nats.publish(subject, payload.into()).await; + } + + tracing::debug!( + job_id = %self.job_id, + stage = %stage, + progress = %progress, + "Progress update" + ); + + Ok(()) + } + + pub fn job_id(&self) -> Uuid { + self.job_id + } +} + +impl Clone for ProgressReporter { + fn clone(&self) -> Self { + Self { + redis: self.redis.clone(), + nats: self.nats.clone(), + job_id: self.job_id, + tool: self.tool.clone(), + } + } +} \ No newline at end of file diff --git a/backend/workers/src/pdf/mod.rs b/backend/workers/src/pdf/mod.rs new file mode 100644 index 0000000..88aa88c --- /dev/null +++ b/backend/workers/src/pdf/mod.rs @@ -0,0 +1,15 @@ +use crate::config::WorkerConfig; +use tools_common::types::Job; + +/// Process a PDF tool job. +pub async fn process_job( + job: Job, + _redis: &redis::Client, + _config: &WorkerConfig, +) -> Result<(), Box> { + tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing PDF job (stub)"); + // TODO: Phase 3 - implement actual PDF processing + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tracing::info!(job_id = %job.id, "PDF job completed"); + Ok(()) +} \ No newline at end of file diff --git a/backend/workers/src/scanner/binarize.rs b/backend/workers/src/scanner/binarize.rs new file mode 100644 index 0000000..2310f1b --- /dev/null +++ b/backend/workers/src/scanner/binarize.rs @@ -0,0 +1,246 @@ +use image::{GrayImage, Luma}; + +/// Apply Sauvola local threshold for clean black-and-white output. +/// +/// Sauvola: T(x,y) = m(x,y) * [1 + k * (s(x,y)/R - 1)] +/// where m = local mean, s = local std dev, R = 128, k = 0.2 +pub fn sauvola_threshold(img: &GrayImage, window_size: u32, k: f64) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let half_win = (window_size / 2) as i32; + let mut output = GrayImage::new(w, h); + + // Integral images for O(1) mean and variance computation + let integral = compute_integral_image(img); + let integral_sq = compute_integral_image_sq(img); + + for y in 0..h { + for x in 0..w { + let (mean, variance) = local_stats( + &integral, + &integral_sq, + x as i32, + y as i32, + half_win, + w as i32, + h as i32, + ); + let std_dev = variance.sqrt(); + let threshold = mean * (1.0 + k * (std_dev / 128.0 - 1.0)); + + let pixel = img.get_pixel(x, y)[0] as f64; + output.put_pixel(x, y, Luma([if pixel > threshold { 255 } else { 0 }])); + } + } + + output +} + +/// Compute integral image for O(1) sum queries. +fn compute_integral_image(img: &GrayImage) -> Vec { + let (w, h) = (img.width() as usize, img.height() as usize); + let mut integral = vec![0u64; (w + 1) * (h + 1)]; + + for y in 0..h { + for x in 0..w { + let idx = (y + 1) * (w + 1) + (x + 1); + let pixel = img.get_pixel(x as u32, y as u32)[0] as u64; + integral[idx] = pixel + + integral[(y + 1) * (w + 1) + x] + + integral[y * (w + 1) + (x + 1)] + - integral[y * (w + 1) + x]; + } + } + + integral +} + +/// Compute squared integral image for O(1) variance queries. +fn compute_integral_image_sq(img: &GrayImage) -> Vec { + let (w, h) = (img.width() as usize, img.height() as usize); + let mut integral = vec![0u64; (w + 1) * (h + 1)]; + + for y in 0..h { + for x in 0..w { + let idx = (y + 1) * (w + 1) + (x + 1); + let pixel = img.get_pixel(x as u32, y as u32)[0] as u64; + let pixel_sq = pixel * pixel; + integral[idx] = pixel_sq + + integral[(y + 1) * (w + 1) + x] + + integral[y * (w + 1) + (x + 1)] + - integral[y * (w + 1) + x]; + } + } + + integral +} + +/// Compute local mean and variance for a window around (x, y) using integral images. +fn local_stats( + integral: &[u64], + integral_sq: &[u64], + x: i32, + y: i32, + half_win: i32, + w: i32, + h: i32, +) -> (f64, f64) { + let x1 = (x - half_win).max(0); + let y1 = (y - half_win).max(0); + let x2 = (x + half_win).min(w - 1); + let y2 = (y + half_win).min(h - 1); + + let width = (w + 1) as usize; + let area = ((x2 - x1 + 1) * (y2 - y1 + 1)) as f64; + + if area <= 0.0 { + return (0.0, 0.0); + } + + // Sum from integral image + let idx_tl = (y1) as usize * width + (x1) as usize; + let idx_tr = (y1) as usize * width + (x2 + 1) as usize; + let idx_bl = (y2 + 1) as usize * width + (x1) as usize; + let idx_br = (y2 + 1) as usize * width + (x2 + 1) as usize; + + let sum = integral[idx_br] + .wrapping_sub(integral[idx_tr]) + .wrapping_sub(integral[idx_bl]) + .wrapping_add(integral[idx_tl]); + + // Sum of squares + let sum_sq = integral_sq[idx_br] + .wrapping_sub(integral_sq[idx_tr]) + .wrapping_sub(integral_sq[idx_bl]) + .wrapping_add(integral_sq[idx_tl]); + + let mean = sum as f64 / area; + let variance = (sum_sq as f64 / area) - mean * mean; + + (mean, variance.max(0.0)) +} + +/// Otsu global threshold (fallback for when Sauvola is too slow). +#[allow(dead_code)] +pub fn otsu_threshold(img: &GrayImage) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let total_pixels = w * h; + + // Compute histogram + let mut hist = [0u32; 256]; + for pixel in img.iter() { + hist[*pixel as usize] += 1; + } + + // Normalize to probabilities + let mut prob = [0.0f64; 256]; + for i in 0..256 { + prob[i] = hist[i] as f64 / total_pixels as f64; + } + + // Find threshold that maximizes between-class variance + let mut best_threshold = 128u8; + let mut best_variance = 0.0f64; + + for t in 1..255 { + let w0: f64 = prob[..t].iter().sum(); + let w1: f64 = prob[t..].iter().sum(); + + if w0 < 1e-6 || w1 < 1e-6 { + continue; + } + + let mut mean0 = 0.0f64; + let mut mean1 = 0.0f64; + + for i in 0..t { + mean0 += i as f64 * prob[i] / w0; + } + for i in t..256 { + mean1 += i as f64 * prob[i] / w1; + } + + let variance = w0 * w1 * (mean0 - mean1).powi(2); + if variance > best_variance { + best_variance = variance; + best_threshold = t as u8; + } + } + + // Apply threshold + let mut output = GrayImage::new(w, h); + for y in 0..h { + for x in 0..w { + let pixel = img.get_pixel(x, y)[0]; + output.put_pixel(x, y, Luma([if pixel > best_threshold { 255 } else { 0 }])); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sauvola_on_simple_image() { + // Create document-like image: white background with dark text lines + let mut img = GrayImage::new(100, 100); + // White background + for y in 0..100 { + for x in 0..100 { + img.put_pixel(x, y, Luma([220])); + } + } + // Dark text lines (simulated with thin dark rectangles) + for y in 0..100 { + for x in 0..100 { + // Alternate thin dark "text" lines + if y % 10 < 3 && x > 10 && x < 90 { + img.put_pixel(x, y, Luma([30])); + } + } + } + + let result = sauvola_threshold(&img, 25, 0.2); + // Text line at y=1 should be black (0) + let text_pixel1 = result.get_pixel(50, 1)[0]; + let text_pixel2 = result.get_pixel(50, 2)[0]; + assert_eq!(text_pixel1, 0, "Text line at y=1 should be black (0), got {}", text_pixel1); + assert_eq!(text_pixel2, 0, "Text line at y=2 should be black (0), got {}", text_pixel2); + // Background at y=5 should be white (255) + let bg_pixel = result.get_pixel(50, 5)[0]; + assert_eq!(bg_pixel, 255, "Background at y=5 should be white (255), got {}", bg_pixel); + } + + #[test] + fn test_integral_image() { + let mut img = GrayImage::new(4, 4); + img.put_pixel(0, 0, Luma([1])); + img.put_pixel(1, 0, Luma([2])); + img.put_pixel(0, 1, Luma([3])); + img.put_pixel(1, 1, Luma([4])); + + let integral = compute_integral_image(&img); + let width = 5; // (w+1) + // Sum of all 4 pixels at (2,2) + let sum = integral[2 * width + 2]; + assert_eq!(sum, 1 + 2 + 3 + 4); // 10 + } + + #[test] + fn test_otsu_on_bimodal() { + // Create a bimodal image: half black, half white + let mut img = GrayImage::new(50, 50); + for y in 0..50 { + for x in 0..50 { + let val = if x < 25 { 30 } else { 200 }; + img.put_pixel(x, y, Luma([val])); + } + } + let result = otsu_threshold(&img); + // Should threshold correctly at ~115 + assert_eq!(result.get_pixel(10, 25)[0], 0); // dark side + assert_eq!(result.get_pixel(35, 25)[0], 255); // light side + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/corners.rs b/backend/workers/src/scanner/corners.rs new file mode 100644 index 0000000..57fe961 --- /dev/null +++ b/backend/workers/src/scanner/corners.rs @@ -0,0 +1,177 @@ +use image::GrayImage; +use imageproc::contours::find_contours; + +use tools_common::error::PipelineError; + +/// Represents a detected corner point. +pub type CornerPoint = (f64, f64); + +/// The fallback reason if corner detection fails. +pub enum FallbackReason { + NoContours, + NoRectangularContour, + TooSmall, +} + +/// Find the 4 corners of the document from an edge image. +pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackReason> { + let contours = find_contours::(edges); + if contours.is_empty() { + return Err(FallbackReason::NoContours); + } + + // Convert contours to use i32 coordinates + let contour_points: Vec> = contours + .iter() + .map(|c| c.points.iter().map(|p| (p.x as i32, p.y as i32)).collect()) + .collect(); + + // Sort by area descending + let mut sorted: Vec<_> = contour_points.iter().collect(); + sorted.sort_by(|a, b| { + contour_area_slice(b) + .partial_cmp(&contour_area_slice(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + for points in sorted.iter().take(5) { + if let Some(corners) = approx_quadrilateral(points) { + let ordered = order_corners(&corners); + return Ok(ordered); + } + } + + // Fallback: use bounding rect of largest contour + if let Some(largest) = sorted.first() { + let rect = bounding_rect_slice(largest); + let corners = vec![ + (rect.0 as f64, rect.1 as f64), + (rect.2 as f64, rect.1 as f64), + (rect.2 as f64, rect.3 as f64), + (rect.0 as f64, rect.3 as f64), + ]; + return Ok(order_corners(&corners)); + } + + Err(FallbackReason::NoContours) +} + +/// Compute the area of a contour using the Shoelace formula. +fn contour_area_slice(points: &[(i32, i32)]) -> f64 { + let n = points.len(); + if n < 3 { + return 0.0; + } + let mut area = 0.0; + for i in 0..n { + let j = (i + 1) % n; + area += points[i].0 as f64 * points[j].1 as f64; + area -= points[j].0 as f64 * points[i].1 as f64; + } + area.abs() / 2.0 +} + +/// Approximate a contour to a quadrilateral. +fn approx_quadrilateral(points: &[(i32, i32)]) -> Option> { + let n = points.len(); + if n < 4 { + return None; + } + + let top = points.iter().min_by(|a, b| a.1.cmp(&b.1))?; + let bottom = points.iter().max_by(|a, b| a.1.cmp(&b.1))?; + let left = points.iter().min_by(|a, b| a.0.cmp(&b.0))?; + let right = points.iter().max_by(|a, b| a.0.cmp(&b.0))?; + + Some(vec![ + (left.0 as f64, left.1 as f64), + (right.0 as f64, top.1 as f64), + (right.0 as f64, bottom.1 as f64), + (left.0 as f64, bottom.1 as f64), + ]) +} + +/// Order 4 corners: top-left, top-right, bottom-right, bottom-left. +fn order_corners(points: &[CornerPoint]) -> [CornerPoint; 4] { + let mut pts: Vec = points.to_vec(); + let mut ordered = [(0.0, 0.0); 4]; + + if pts.len() >= 4 { + // Sort by position + // TL = min(x+y), BR = max(x+y) + pts.sort_by(|a, b| { + (a.0 + a.1) + .partial_cmp(&(b.0 + b.1)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + ordered[0] = pts[0]; // TL + ordered[2] = pts[3]; // BR + + // TR = max(x - y), BL = min(x - y) + pts.sort_by(|a, b| { + (a.0 - a.1) + .partial_cmp(&(b.0 - b.1)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + ordered[1] = pts[3]; // TR + ordered[3] = pts[0]; // BL + } + + ordered +} + +/// Compute bounding rectangle: (left, top, right, bottom). +fn bounding_rect_slice(points: &[(i32, i32)]) -> (i32, i32, i32, i32) { + let left = points.iter().map(|p| p.0).min().unwrap_or(0); + let top = points.iter().map(|p| p.1).min().unwrap_or(0); + let right = points.iter().map(|p| p.0).max().unwrap_or(0); + let bottom = points.iter().map(|p| p.1).max().unwrap_or(0); + (left, top, right, bottom) +} + +/// Detect corners with fallback: full resolution, then half, then error. +pub fn detect_corners_with_fallback( + edges: &GrayImage, +) -> Result<[CornerPoint; 4], PipelineError> { + // Attempt 1: Full resolution + if let Ok(corners) = detect_corners(edges) { + return Ok(corners); + } + + // Attempt 2: Half resolution + let (w, h) = (edges.width() / 2, edges.height() / 2); + if w > 10 && h > 10 { + let half = image::imageops::resize( + edges, + w, + h, + image::imageops::FilterType::Lanczos3, + ); + if let Ok(corners) = detect_corners(&half) { + return Ok(corners.map(|(x, y)| (x * 2.0, y * 2.0))); + } + } + + Err(PipelineError::CornerDetection( + "Could not detect document corners automatically".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contour_area_slice() { + let points = vec![(0, 0), (100, 0), (100, 100), (0, 100)]; + let area = contour_area_slice(&points); + assert!((area - 10000.0).abs() < 1.0); + } + + #[test] + fn test_bounding_rect_slice() { + let points = vec![(10, 20), (100, 30), (90, 150), (5, 140)]; + let rect = bounding_rect_slice(&points); + assert_eq!(rect, (5, 20, 100, 150)); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/deskew.rs b/backend/workers/src/scanner/deskew.rs new file mode 100644 index 0000000..861c9ac --- /dev/null +++ b/backend/workers/src/scanner/deskew.rs @@ -0,0 +1,197 @@ +use image::{GrayImage, Luma}; +use image::imageops; + +/// Detect and correct small rotation (<5°) of text lines using Hough transform. +pub fn deskew(img: &GrayImage) -> GrayImage { + let lines = hough_lines(img, 10, 50); + + if lines.is_empty() { + return img.clone(); + } + + // Compute median angle of all detected lines + let angles: Vec = lines + .iter() + .map(|line| line.angle_deg()) + .filter(|a| a.abs() < 45.0) // Skip vertical lines + .collect(); + + if angles.is_empty() { + return img.clone(); + } + + let median_angle = median(&angles); + + // Skip if angle is very small (<0.5°) + if median_angle.abs() < 0.5 { + return img.clone(); + } + + // Rotate image + rotate_image(img, median_angle) +} + +/// Represents a line detected by Hough transform. +#[derive(Debug, Clone)] +struct HoughLine { + rho: f64, + theta: f64, +} + +impl HoughLine { + fn angle_deg(&self) -> f64 { + self.theta.to_degrees() - 90.0 + } +} + +/// Simple Hough line detection. +fn hough_lines(img: &GrayImage, threshold: u32, _max_lines: usize) -> Vec { + let (w, h) = (img.width() as i32, img.height() as i32); + let max_rho = ((w * w + h * h) as f64).sqrt().ceil() as i32; + + let theta_step = 1.0_f64.to_radians(); + let num_thetas = 180; + + // Accumulator + let mut accumulator = + vec![vec![0u32; (2 * max_rho + 1) as usize]; num_thetas]; + + // Vote + for y in 0..h { + for x in 0..w { + if img.get_pixel(x as u32, y as u32)[0] > 128 { + for t_idx in 0..num_thetas { + let theta = t_idx as f64 * theta_step; + let rho = (x as f64 * theta.cos() + y as f64 * theta.sin()).round() as i32; + let rho_idx = rho + max_rho; + if rho_idx >= 0 && (rho_idx as usize) < accumulator[t_idx].len() { + accumulator[t_idx][rho_idx as usize] += 1; + } + } + } + } + } + + // Find local maxima above threshold + let mut lines = Vec::new(); + for t_idx in 0..num_thetas { + let theta = t_idx as f64 * theta_step; + for (r_idx, &count) in accumulator[t_idx].iter().enumerate() { + if count > threshold { + let rho = r_idx as i32 - max_rho; + lines.push(HoughLine { + rho: rho as f64, + theta, + }); + } + } + } + + // Sort by votes (descending) and take top N + lines.sort_by(|a, b| { + let a_idx = (a.theta / theta_step).round() as usize; + let b_idx = (b.theta / theta_step).round() as usize; + let a_rho_idx = (a.rho + max_rho as f64).round() as usize; + let b_rho_idx = (b.rho + max_rho as f64).round() as usize; + let a_count = accumulator[a_idx.min(num_thetas - 1)][a_rho_idx.min(accumulator[0].len() - 1)]; + let b_count = accumulator[b_idx.min(num_thetas - 1)][b_rho_idx.min(accumulator[0].len() - 1)]; + b_count.cmp(&a_count) + }); + + lines.truncate(100); + lines +} + +/// Compute median of a sorted slice of f64 values. +fn median(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } +} + +/// Rotate an image by the given angle in degrees. +fn rotate_image(img: &GrayImage, angle_deg: f64) -> GrayImage { + let angle_rad = angle_deg.to_radians(); + let (w, h) = (img.width(), img.height()); + + // Compute new image dimensions to fit the rotated content + let cos = angle_rad.cos().abs(); + let sin = angle_rad.sin().abs(); + let new_w = (w as f64 * cos + h as f64 * sin).ceil() as u32; + let new_h = (w as f64 * sin + h as f64 * cos).ceil() as u32; + let new_w = new_w.max(1); + let new_h = new_h.max(1); + + let mut output = GrayImage::new(new_w, new_h); + let cx = w as f64 / 2.0; + let cy = h as f64 / 2.0; + let new_cx = new_w as f64 / 2.0; + let new_cy = new_h as f64 / 2.0; + + // Backward mapping + for out_y in 0..new_h { + for out_x in 0..new_w { + // Translate to origin, rotate, translate back + let dx = out_x as f64 - new_cx; + let dy = out_y as f64 - new_cy; + let src_x = dx * cos + dy * sin + cx; + let src_y = -dx * sin + dy * cos + cy; + + if src_x >= 0.0 && src_x < w as f64 - 1.0 && src_y >= 0.0 && src_y < h as f64 - 1.0 { + // Bilinear interpolation + let x0 = src_x.floor() as u32; + let y0 = src_y.floor() as u32; + let x1 = (x0 + 1).min(w - 1); + let y1 = (y0 + 1).min(h - 1); + let fx = src_x - x0 as f64; + let fy = src_y - y0 as f64; + + let p00 = img.get_pixel(x0, y0)[0] as f64; + let p10 = img.get_pixel(x1, y0)[0] as f64; + let p01 = img.get_pixel(x0, y1)[0] as f64; + let p11 = img.get_pixel(x1, y1)[0] as f64; + + let val = p00 * (1.0 - fx) * (1.0 - fy) + + p10 * fx * (1.0 - fy) + + p01 * (1.0 - fx) * fy + + p11 * fx * fy; + + output.put_pixel(out_x, out_y, Luma([val.round().clamp(0.0, 255.0) as u8])); + } else { + output.put_pixel(out_x, out_y, Luma([255])); // White padding + } + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_median_odd() { + let v = vec![1.0, 3.0, 5.0]; + assert!((median(&v) - 3.0).abs() < 0.001); + } + + #[test] + fn test_median_even() { + let v = vec![1.0, 2.0, 3.0, 4.0]; + assert!((median(&v) - 2.5).abs() < 0.001); + } + + #[test] + fn test_empty() { + assert!((median(&[]) - 0.0).abs() < 0.001); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/edge.rs b/backend/workers/src/scanner/edge.rs new file mode 100644 index 0000000..d5bca26 --- /dev/null +++ b/backend/workers/src/scanner/edge.rs @@ -0,0 +1,77 @@ +use image::{GrayImage}; +use imageproc::edges::canny; +use imageproc::filter::gaussian_blur_f32; +use imageproc::distance_transform::Norm; +use imageproc::morphology::close; + +use tools_common::error::PipelineError; + +/// Detect edges using Canny algorithm with adaptive threshold. +pub fn detect_edges(img: &GrayImage) -> Result { + // 1. Gaussian blur for noise reduction + let blurred = gaussian_blur_f32(img, 3.0); + + // 2. First attempt: Canny with standard thresholds + let edges = canny(&blurred, 50.0, 150.0); + + // 3. Morphological close to connect broken edges + let closed = close(&edges, Norm::L1, 5); + + // 4. Check edge coverage + let edge_count = count_non_zero(&closed); + let total_pixels = (closed.width() * closed.height()) as u32; + + // If too few edges (<1%), retry with lower thresholds + if edge_count < total_pixels / 100 { + let edges2 = canny(&blurred, 20.0, 80.0); + let closed2 = close(&edges2, Norm::L1, 5); + let edge_count2 = count_non_zero(&closed2); + + if edge_count2 < total_pixels / 200 { + return Err(PipelineError::EdgeDetection( + "Too few edges detected even with low threshold".to_string(), + )); + } + return Ok(closed2); + } + + Ok(closed) +} + +/// Count non-zero (white) pixels in a binary image. +fn count_non_zero(img: &GrayImage) -> u32 { + let mut count = 0u32; + for pixel in img.iter() { + if *pixel > 0 { + count += 1; + } + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Luma; + + #[test] + fn test_edge_detection_on_simple_image() { + let mut img = GrayImage::new(200, 200); + for y in 30..170 { + for x in 30..170 { + img.put_pixel(x, y, Luma([255])); + } + } + let result = detect_edges(&img); + assert!(result.is_ok()); + let edges = result.unwrap(); + assert!(count_non_zero(&edges) > 0); + } + + #[test] + fn test_empty_image_returns_error() { + let img = GrayImage::new(100, 100); + let result = detect_edges(&img); + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/enhance.rs b/backend/workers/src/scanner/enhance.rs new file mode 100644 index 0000000..42ea097 --- /dev/null +++ b/backend/workers/src/scanner/enhance.rs @@ -0,0 +1,134 @@ +use image::{GrayImage, Luma}; +use imageproc::filter::gaussian_blur_f32; + +/// Apply final sharpening and contrast optimization. +pub fn enhance_final(img: &GrayImage) -> GrayImage { + let sharpened = unsharp_mask(img, 1.0, 1.0); + adjust_contrast(&sharpened, 1.2) +} + +/// Unsharp mask: add high-frequency detail back to the image. +/// result = img + amount * (img - blurred) +pub fn unsharp_mask(img: &GrayImage, sigma: f64, amount: f64) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let blurred = gaussian_blur_f32(img, sigma as f32); + + let mut output = GrayImage::new(w, h); + for y in 0..h { + for x in 0..w { + let orig = img.get_pixel(x, y)[0] as f64; + let blur = blurred.get_pixel(x, y)[0] as f64; + let mask = orig - blur; + let result = (orig + amount * mask).clamp(0.0, 255.0) as u8; + output.put_pixel(x, y, Luma([result])); + } + } + output +} + +/// Adjust contrast by scaling pixel values around the mean. +pub fn adjust_contrast(img: &GrayImage, factor: f64) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let mean = mean_value(img); + + let mut output = GrayImage::new(w, h); + for y in 0..h { + for x in 0..w { + let pixel = img.get_pixel(x, y)[0] as f64; + let adjusted = ((pixel - mean) * factor + mean).clamp(0.0, 255.0) as u8; + output.put_pixel(x, y, Luma([adjusted])); + } + } + output +} + +/// Remove salt-and-pepper noise using a median-like filter. +#[allow(dead_code)] +pub fn remove_noise(img: &GrayImage, threshold: u8) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let mut output = GrayImage::new(w, h); + + for y in 1..h - 1 { + for x in 1..w - 1 { + let center = img.get_pixel(x, y)[0]; + // Check if pixel is significantly different from neighbors + let mut neighbors = Vec::new(); + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + neighbors.push( + img.get_pixel((x as i32 + dx) as u32, (y as i32 + dy) as u32)[0], + ); + } + } + let min = *neighbors.iter().min().unwrap_or(&0); + let max = *neighbors.iter().max().unwrap_or(&255); + + if (center as i16 - min as i16).abs() > threshold as i16 + || (center as i16 - max as i16).abs() > threshold as i16 + { + // Replace with median + neighbors.sort(); + output.put_pixel(x, y, Luma([neighbors[neighbors.len() / 2]])); + } else { + output.put_pixel(x, y, Luma([center])); + } + } + } + + // Copy edges + for x in 0..w { + output.put_pixel(x, 0, *img.get_pixel(x, 0)); + output.put_pixel(x, h - 1, *img.get_pixel(x, h - 1)); + } + for y in 0..h { + output.put_pixel(0, y, *img.get_pixel(0, y)); + output.put_pixel(w - 1, y, *img.get_pixel(w - 1, y)); + } + + output +} + +/// Compute mean pixel value. +fn mean_value(img: &GrayImage) -> f64 { + let sum: u64 = img.iter().map(|&p| p as u64).sum(); + let count = img.width() as u64 * img.height() as u64; + if count > 0 { + sum as f64 / count as f64 + } else { + 128.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unsharp_mask_no_change() { + // Uniform image should remain unchanged + let img = GrayImage::from_pixel(50, 50, Luma([128])); + let result = unsharp_mask(&img, 1.0, 0.0); + assert_eq!(result.get_pixel(25, 25)[0], 128); + } + + #[test] + fn test_contrast_increase() { + let mut img = GrayImage::new(10, 10); + img.put_pixel(0, 0, Luma([100])); + img.put_pixel(1, 0, Luma([200])); + let result = adjust_contrast(&img, 2.0); + // With factor > 1, contrast increases + let diff_orig = (200 - 100) as f64; + let diff_result = (result.get_pixel(1, 0)[0] as f64) - (result.get_pixel(0, 0)[0] as f64); + // The difference after contrast adjustment should be greater than original + assert!( + diff_result.abs() > diff_orig.abs() * 0.5, + "diff_orig={}, diff_result={}", + diff_orig, + diff_result + ); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/mod.rs b/backend/workers/src/scanner/mod.rs new file mode 100644 index 0000000..3fd35a0 --- /dev/null +++ b/backend/workers/src/scanner/mod.rs @@ -0,0 +1,80 @@ +pub mod binarize; +pub mod corners; +pub mod deskew; +pub mod edge; +pub mod enhance; +pub mod ocr; +pub mod pdf; +pub mod pipeline; +pub mod preprocess; +pub mod shadow; +pub mod warp; + +use crate::config::WorkerConfig; +use crate::nats::progress::ProgressReporter; +use tools_common::types::{Job, JobStatus, Tool}; + +/// Process a scan job through the full pipeline. +pub async fn process_job( + job: Job, + redis: &redis::Client, + config: &WorkerConfig, +) -> Result<(), Box> { + tracing::info!(job_id = %job.id, "Processing scan job"); + + let nats = async_nats::connect(&config.nats_url).await?; + let progress = ProgressReporter::new(redis.clone(), nats, job.id, Tool::Scan); + + progress + .report( + JobStatus::Processing { + stage: "preprocess".to_string(), + progress: 5, + }, + "preprocess", + 5, + "Memproses gambar...", + ) + .await?; + + let result = pipeline::process(&job, config, &progress).await; + + match result { + Ok(scan_result) => { + progress + .report(JobStatus::Completed, "complete", 100, "Scan selesai") + .await?; + + let mut conn = redis.get_multiplexed_async_connection().await?; + crate::nats::consumer::JobConsumer::update_job_result( + &mut conn, + job.id, + &scan_result.output_path, + job.ttl_seconds, + ) + .await?; + + tracing::info!( + job_id = %job.id, + output = %scan_result.output_path, + duration_ms = %scan_result.processing_time_ms, + "Scan job completed" + ); + + Ok(()) + } + Err(e) => { + progress + .report( + JobStatus::Failed(e.to_string()), + "error", + 0, + &format!("Gagal: {}", e), + ) + .await?; + + tracing::error!(job_id = %job.id, error = %e, "Scan job failed"); + Err(e) + } + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/ocr.rs b/backend/workers/src/scanner/ocr.rs new file mode 100644 index 0000000..20c57f1 --- /dev/null +++ b/backend/workers/src/scanner/ocr.rs @@ -0,0 +1,112 @@ +use image::GrayImage; + +use tools_common::error::PipelineError; + +/// OCR result with text and word-level bounding boxes. +pub struct OcrResult { + pub full_text: String, + pub words: Vec, + pub confidence: f32, +} + +/// A single word detected by OCR with its bounding box. +#[derive(Debug, Clone)] +pub struct OcrWord { + pub text: String, + pub bbox: Bbox, + pub confidence: i32, +} + +/// Bounding box coordinates. +#[derive(Debug, Clone)] +pub struct Bbox { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// Initialize Tesseract OCR engine. +/// Uses leptess crate which binds to libtesseract. +/// Falls back gracefully if Tesseract is not installed. +#[cfg(feature = "tesseract")] +fn init_tesseract(lang: &str) -> Result { + let tessdata_prefix = std::env::var("TESSDATA_PREFIX") + .unwrap_or_else(|_| "/usr/share/tesseract-ocr/5/tessdata".to_string()); + + let mut tess = leptess::LepTess::new(Some(&tessdata_prefix), lang) + .map_err(|e| PipelineError::Ocr(format!("Failed to init Tesseract: {}", e)))?; + + Ok(tess) +} + +/// Run OCR on a grayscale image and return extracted text. +/// Uses Tesseract via leptess crate when the "tesseract" feature is enabled. +/// Falls back to a placeholder when Tesseract is unavailable. +pub fn ocr_text(img: &GrayImage, lang: &str) -> Result { + #[cfg(feature = "tesseract")] + { + let mut tess = init_tesseract(lang)?; + + let width = img.width() as i32; + let height = img.height() as i32; + + // Set image from memory + tess.set_image_from_mem(&img.to_vec(), width, height, 1, width) + .map_err(|e| PipelineError::Ocr(format!("Failed to set image: {}", e)))?; + + tess.set_source_resolution(300); + + // Set PSM to automatic + tess.set_page_seg_mode(3); + + let text = tess.get_utf8_text() + .map_err(|e| PipelineError::Ocr(format!("OCR failed: {}", e)))?; + + let words = tess.get_words() + .iter() + .map(|w| OcrWord { + text: w.text.clone(), + bbox: Bbox { + x: w.x, + y: w.y, + width: w.w, + height: w.h, + }, + confidence: w.confidence, + }) + .collect(); + + let confidence = if words.is_empty() { + 0.0 + } else { + words.iter().map(|w| w.confidence as f32).sum::() / words.len() as f32 + }; + + Ok(OcrResult { + full_text: text, + words, + confidence, + }) + } + + #[cfg(not(feature = "tesseract"))] + { + tracing::warn!("Tesseract feature not enabled, OCR returning placeholder"); + Ok(OcrResult { + full_text: String::new(), + words: Vec::new(), + confidence: 0.0, + }) + } +} + +/// Run OCR on a grayscale image, returning only the text. +pub fn ocr_text_only(img: &GrayImage, lang: &str) -> Result { + ocr_text(img, lang).map(|r| r.full_text) +} + +/// Run OCR with word-level bounding boxes. +pub fn ocr_words(img: &GrayImage, lang: &str) -> Result, PipelineError> { + ocr_text(img, lang).map(|r| r.words) +} \ No newline at end of file diff --git a/backend/workers/src/scanner/pdf.rs b/backend/workers/src/scanner/pdf.rs new file mode 100644 index 0000000..969ffee --- /dev/null +++ b/backend/workers/src/scanner/pdf.rs @@ -0,0 +1,190 @@ +use image::GrayImage; +use lopdf::{Document, Object, Stream, Dictionary}; + +use tools_common::error::PipelineError; + +/// A4 page dimensions in points (1 pt = 1/72 inch). +pub const A4_WIDTH_PT: f64 = 595.28; +pub const A4_HEIGHT_PT: f64 = 841.89; + +/// Generate a searchable PDF with JPEG image + invisible OCR text layer. +pub fn generate_searchable_pdf( + image_data: &[u8], + _ocr_text: &str, + words: &[super::ocr::OcrWord], + page_width: f64, + page_height: f64, +) -> Result, PipelineError> { + let mut doc = Document::new(); + + // ── Pages object ── + let pages_id = doc.new_object_id(); + let mut pages = Dictionary::new(); + pages.set("Type", Object::Name("Pages".as_bytes().to_vec())); + pages.set("Kids", Object::Array(vec![])); + pages.set("Count", Object::Integer(0)); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + // ── Image XObject ── + let mut img_dict = Dictionary::new(); + img_dict.set("Type", Object::Name("XObject".as_bytes().to_vec())); + img_dict.set("Subtype", Object::Name("Image".as_bytes().to_vec())); + img_dict.set("Width", Object::Integer(page_width as i64)); + img_dict.set("Height", Object::Integer(page_height as i64)); + img_dict.set("ColorSpace", Object::Name("DeviceGray".as_bytes().to_vec())); + img_dict.set("BitsPerComponent", Object::Integer(8)); + img_dict.set("Filter", Object::Name("DCTDecode".as_bytes().to_vec())); + + let image_stream = Stream::new(img_dict, image_data.to_vec()); + let image_id = doc.add_object(Object::Stream(image_stream)); + + // ── Content stream: place image + invisible text ── + let mut content = Vec::new(); + + // Place image at full page + content.extend_from_slice(b"q\n"); + content.extend_from_slice( + format!("{} 0 0 {} 0 0 cm\n", page_width, page_height).as_bytes(), + ); + content.extend_from_slice(b"/Im0 Do\n"); + content.extend_from_slice(b"Q\n"); + + // Add invisible text layer (searchable) + for word in words { + let x = word.bbox.x as f64 / 300.0 * 72.0; + let y = page_height - (word.bbox.y as f64 / 300.0 * 72.0); + let font_size = (word.bbox.height as f64 / 300.0 * 72.0 * 0.8).max(4.0); + + content.extend_from_slice(b"BT\n"); + content.extend_from_slice(b"3 Tr\n"); // Rendering mode: invisible (neither fill nor stroke) + content.extend_from_slice( + format!("/F1 {} Tf\n{} {} Td\n", font_size, x, y - font_size).as_bytes(), + ); + content.extend_from_slice( + format!("({}) Tj\n", escape_pdf_string(&word.text)).as_bytes(), + ); + content.extend_from_slice(b"ET\n"); + } + + let content_stream = Stream::new(Dictionary::new(), content); + let content_id = doc.add_object(Object::Stream(content_stream)); + + // ── Font dictionary ── + let mut font_dict = Dictionary::new(); + let mut f1 = Dictionary::new(); + f1.set("Type", Object::Name("Font".as_bytes().to_vec())); + f1.set("Subtype", Object::Name("Type1".as_bytes().to_vec())); + f1.set("BaseFont", Object::Name("Helvetica".as_bytes().to_vec())); + font_dict.set("F1", Object::Dictionary(f1)); + + // ── Resources dictionary ── + let mut xobject_dict = Dictionary::new(); + xobject_dict.set("Im0", Object::Reference(image_id)); + + let mut resources = Dictionary::new(); + resources.set("XObject", Object::Dictionary(xobject_dict)); + resources.set("Font", Object::Dictionary(font_dict)); + + // ── Page object ── + let page_id = doc.new_object_id(); + let mut page = Dictionary::new(); + page.set("Type", Object::Name("Page".as_bytes().to_vec())); + page.set("Parent", Object::Reference(pages_id)); + page.set( + "MediaBox", + Object::Array(vec![ + Object::Real(0.0), + Object::Real(0.0), + Object::Real(page_width as f32), + Object::Real(page_height as f32), + ]), + ); + page.set("Contents", Object::Reference(content_id)); + page.set("Resources", Object::Dictionary(resources)); + + doc.objects.insert(page_id, Object::Dictionary(page)); + + // ── Update pages object ── + if let Some(Object::Dictionary(ref mut pages_dict)) = doc.objects.get_mut(&pages_id) { + pages_dict.set("Count", Object::Integer(1)); + pages_dict.set("Kids", Object::Array(vec![Object::Reference(page_id)])); + } + + // ── Save ── + let mut output = Vec::new(); + doc.save_to(&mut output) + .map_err(|e| PipelineError::PdfGeneration(e.to_string()))?; + + Ok(output) +} + +/// Escape special characters for PDF string literals. +fn escape_pdf_string(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '(' => result.push_str("\\("), + ')' => result.push_str("\\)"), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + other => result.push(other), + } + } + result +} + +/// Compress grayscale image as JPEG bytes. +pub fn compress_image_jpeg(img: &GrayImage, quality: u8) -> Result, PipelineError> { + let mut bytes = Vec::new(); + let rgb = image::DynamicImage::ImageLuma8(img.clone()).into_rgb8(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality); + encoder + .encode( + rgb.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::Rgb8, + ) + .map_err(|e| PipelineError::PdfGeneration(format!("JPEG compression failed: {}", e)))?; + Ok(bytes) +} + +/// Compress RGB image data as JPEG bytes. +pub fn compress_rgb_image_jpeg( + data: &[u8], + width: u32, + height: u32, + quality: u8, +) -> Result, PipelineError> { + let mut bytes = Vec::new(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality); + encoder + .encode(data, width, height, image::ExtendedColorType::Rgb8) + .map_err(|e| PipelineError::PdfGeneration(format!("JPEG compression failed: {}", e)))?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Luma; + + #[test] + fn test_escape_pdf_string() { + assert_eq!(escape_pdf_string("hello"), "hello"); + assert_eq!(escape_pdf_string("(parens)"), "\\(parens\\)"); + assert_eq!(escape_pdf_string("back\\slash"), "back\\\\slash"); + } + + #[test] + fn test_jpeg_compression() { + let img = GrayImage::from_pixel(100, 100, Luma([128])); + let result = compress_image_jpeg(&img, 90); + assert!(result.is_ok(), "JPEG compression failed: {:?}", result.err()); + let bytes = result.unwrap(); + assert!(!bytes.is_empty()); + assert_eq!(&bytes[0..2], &[0xFF, 0xD8]); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/pipeline.rs b/backend/workers/src/scanner/pipeline.rs new file mode 100644 index 0000000..6ac6eed --- /dev/null +++ b/backend/workers/src/scanner/pipeline.rs @@ -0,0 +1,116 @@ +use std::path::Path; +use std::time::Instant; + +use image::DynamicImage; +use tools_common::error::PipelineError; +use tools_common::types::Job; + +use crate::config::WorkerConfig; +use crate::nats::progress::ProgressReporter; + +use super::binarize::sauvola_threshold; +use super::corners::detect_corners_with_fallback; +use super::deskew::deskew; +use super::edge::detect_edges; +use super::enhance::enhance_final; +use super::preprocess::preprocess; +use super::shadow::remove_shadow; +use super::warp::warp_perspective; + +/// Result of the scanning pipeline. +pub struct ScanResult { + pub output_path: String, + pub page_count: u32, + pub file_size: u64, + pub ocr_text: Option, + pub processing_time_ms: u64, +} + +/// Run the full scanner pipeline with all stages. +pub async fn process( + job: &Job, + config: &WorkerConfig, + progress: &ProgressReporter, +) -> Result> { + let start = Instant::now(); + let input_path = Path::new(&job.file_path); + + // Create output directory + let output_dir = config.storage_path.join("output"); + tokio::fs::create_dir_all(&output_dir).await?; + + // Stage 1: Load & Preprocess (0-15%) + report(progress, "preprocess", 5, "Memuat dan meresize gambar...").await; + let gray = preprocess(input_path) + .map_err(|e| format!("Preprocess failed: {}", e))?; + + // Stage 2: Edge Detection (15-30%) + report(progress, "edge_detection", 20, "Mendeteksi tepi dokumen...").await; + let edges = detect_edges(&gray).map_err(|e| format!("Edge detection failed: {}", e))?; + + // Stage 3: Corner Detection (30-40%) + report(progress, "corner_detection", 35, "Mencari sudut dokumen...").await; + let corners = detect_corners_with_fallback(&edges)?; + + // Stage 4: Perspective Warp (40-55%) + report(progress, "warp", 45, "Meluruskan perspektif dokumen...").await; + let image = image::open(input_path) + .map_err(|e| PipelineError::ImageLoad(e.to_string()))?; + let warped = warp_perspective(&image, corners)?; + + // Stage 5: Shadow Removal (55-70%) + report(progress, "shadow_removal", 60, "Menghilangkan bayangan...").await; + let warped_gray = warped.to_luma8(); + let clean = remove_shadow(&warped_gray); + + // Stage 6: Binarization (70-80%) + report(progress, "binarization", 75, "Mengubah ke hitam-putih...").await; + let binary = sauvola_threshold(&clean, 30, 0.2); + + // Stage 7: Deskew (80-87%) + report(progress, "deskew", 82, "Meluruskan teks...").await; + let final_img = deskew(&binary); + + // Stage 8: Enhance (87-93%) + report(progress, "enhance", 90, "Mengoptimalkan kualitas...").await; + let final_img = enhance_final(&final_img); + + // Stage 9: Save output (93-100%) + report(progress, "save", 95, "Menyimpan hasil...").await; + + let output_filename = format!("{}.png", progress.job_id()); + let output_path = output_dir.join(&output_filename); + + final_img.save(&output_path)?; + + let elapsed = start.elapsed().as_millis() as u64; + + tracing::info!( + job_id = %progress.job_id(), + duration_ms = elapsed, + "Pipeline complete" + ); + + Ok(ScanResult { + output_path: output_path.to_string_lossy().to_string(), + page_count: 1, + file_size: tokio::fs::metadata(&output_path).await.map(|m| m.len()).unwrap_or(0), + ocr_text: None, + processing_time_ms: elapsed, + }) +} + +/// Helper to report progress. +async fn report(progress: &ProgressReporter, stage: &str, pct: u8, msg: &str) { + let _ = progress + .report( + tools_common::types::JobStatus::Processing { + stage: stage.to_string(), + progress: pct, + }, + stage, + pct, + msg, + ) + .await; +} \ No newline at end of file diff --git a/backend/workers/src/scanner/preprocess.rs b/backend/workers/src/scanner/preprocess.rs new file mode 100644 index 0000000..4519cb8 --- /dev/null +++ b/backend/workers/src/scanner/preprocess.rs @@ -0,0 +1,74 @@ +use image::{DynamicImage, GrayImage, Luma}; +use image::imageops::FilterType; + +use tools_common::error::PipelineError; + +/// Maximum dimension for processing (edge detection works fine at this resolution). +const MAX_DIMENSION: u32 = 2000; + +/// Load image from file path. +pub fn load_image(path: &std::path::Path) -> Result { + image::open(path).map_err(|e| PipelineError::ImageLoad(e.to_string())) +} + +/// Resize image if it exceeds the maximum dimension, preserving aspect ratio. +/// Uses Lanczos3 filter for sharpest downscale. +pub fn safe_resize(img: &DynamicImage) -> DynamicImage { + let (w, h) = (img.width(), img.height()); + let max_dim = w.max(h) as f64; + + if max_dim > MAX_DIMENSION as f64 { + let scale = MAX_DIMENSION as f64 / max_dim; + let new_w = (w as f64 * scale) as u32; + let new_h = (h as f64 * scale) as u32; + img.resize_exact(new_w.max(1), new_h.max(1), FilterType::Lanczos3) + } else { + img.clone() + } +} + +/// Convert to grayscale (Luma8). +pub fn to_grayscale(img: &DynamicImage) -> GrayImage { + img.to_luma8() +} + +/// Full preprocess pipeline: load → resize → grayscale. +pub fn preprocess(path: &std::path::Path) -> Result { + let img = load_image(path)?; + let resized = safe_resize(&img); + Ok(to_grayscale(&resized)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_resize_no_resize() { + // Image smaller than MAX_DIMENSION should not be resized + let img = DynamicImage::new_luma8(800, 600); + let result = safe_resize(&img); + assert_eq!(result.width(), 800); + assert_eq!(result.height(), 600); + } + + #[test] + fn test_safe_resize_downscale() { + // 12MP image (4000x3000) should be resized to ≤2000px + let img = DynamicImage::new_luma8(4000, 3000); + let result = safe_resize(&img); + assert!(result.width() <= 2000); + assert!(result.height() <= 2000); + // Aspect ratio preserved: 4000/3000 = 1.333 + let ratio = result.width() as f64 / result.height() as f64; + assert!((ratio - 4.0 / 3.0).abs() < 0.01); + } + + #[test] + fn test_to_grayscale() { + let img = DynamicImage::new_rgba8(100, 100); + let gray = to_grayscale(&img); + assert_eq!(gray.width(), 100); + assert_eq!(gray.height(), 100); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/shadow.rs b/backend/workers/src/scanner/shadow.rs new file mode 100644 index 0000000..aa0b0ad --- /dev/null +++ b/backend/workers/src/scanner/shadow.rs @@ -0,0 +1,161 @@ +use image::{GrayImage, Luma}; +use imageproc::filter::gaussian_blur_f32; + +/// Remove uneven lighting and shadows from a grayscale document image. +/// +/// Algorithm: +/// 1. Large Gaussian blur to estimate background illumination +/// 2. Subtract background from original +/// 3. Apply CLAHE for local contrast normalization +pub fn remove_shadow(img: &GrayImage) -> GrayImage { + let (w, h) = (img.width(), img.height()); + + // 1. Large Gaussian blur for illumination estimate + let blur_radius = (w.min(h) as f64 / 50.0).max(15.0); + let background = gaussian_blur_f32(img, blur_radius as f32); + + // 2. Subtract background + let bg_mean = mean_pixel(&background); + let mut corrected = GrayImage::new(w, h); + + for y in 0..h { + for x in 0..w { + let orig = img.get_pixel(x, y)[0] as f32; + let bg = background.get_pixel(x, y)[0] as f32; + let corrected_val = (orig - bg + bg_mean).clamp(0.0, 255.0) as u8; + corrected.put_pixel(x, y, Luma([corrected_val])); + } + } + + // 3. Apply CLAHE + apply_clahe(&corrected, 8, 4) +} + +/// Compute mean pixel value of a grayscale image. +fn mean_pixel(img: &GrayImage) -> f32 { + let sum: u32 = img.iter().map(|&p| p as u32).sum(); + let count = img.width() * img.height(); + if count > 0 { + sum as f32 / count as f32 + } else { + 0.0 + } +} + +/// Contrast Limited Adaptive Histogram Equalization. +/// Divides the image into tiles and applies histogram equalization to each. +fn apply_clahe(img: &GrayImage, tile_size: u32, clip_limit: u8) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let tiles_x = (w + tile_size - 1) / tile_size; + let tiles_y = (h + tile_size - 1) / tile_size; + + let mut output = GrayImage::new(w, h); + + for ty in 0..tiles_y { + for tx in 0..tiles_x { + let start_x = tx * tile_size; + let start_y = ty * tile_size; + let end_x = (start_x + tile_size).min(w); + let end_y = (start_y + tile_size).min(h); + + // Compute histogram for this tile + let mut hist = [0u32; 256]; + for y in start_y..end_y { + for x in start_x..end_x { + hist[img.get_pixel(x, y)[0] as usize] += 1; + } + } + + // Clip histogram + let tile_pixels = (end_x - start_x) * (end_y - start_y); + let clip_limit_count = tile_pixels as u32 * clip_limit as u32 / 255 / 10; + let mut excess = 0u32; + for count in hist.iter_mut() { + if *count > clip_limit_count { + excess += *count - clip_limit_count; + *count = clip_limit_count; + } + } + // Redistribute excess + let add_per_bin = excess / 256; + for count in hist.iter_mut() { + *count += add_per_bin; + } + + // Build CDF + let mut cdf = [0u32; 256]; + cdf[0] = hist[0]; + for i in 1..256 { + cdf[i] = cdf[i - 1] + hist[i]; + } + let cdf_min = cdf.iter().find(|&&v| v > 0).copied().unwrap_or(0); + + // Apply equalization to this tile + for y in start_y..end_y { + for x in start_x..end_x { + let pixel = img.get_pixel(x, y)[0] as usize; + let equalized = if cdf_max(cdf) > cdf_min { + ((cdf[pixel].saturating_sub(cdf_min)) as f64 + / (cdf_max(cdf).saturating_sub(cdf_min)) as f64 + * 255.0) as u8 + } else { + pixel as u8 + }; + output.put_pixel(x, y, Luma([equalized])); + } + } + } + } + + output +} + +/// Get the maximum value in the CDF array. +fn cdf_max(cdf: [u32; 256]) -> u32 { + *cdf.iter().max().unwrap_or(&0) +} + +/// Retinex-based shadow removal (alternative algorithm). +#[allow(dead_code)] +fn retinex_shadow_removal(img: &GrayImage) -> GrayImage { + let (w, h) = (img.width(), img.height()); + let blurred = gaussian_blur_f32(img, 30.0); + + let mut output = GrayImage::new(w, h); + for y in 0..h { + for x in 0..w { + let orig = img.get_pixel(x, y)[0] as f32; + let bg = blurred.get_pixel(x, y)[0] as f32; + if bg > 0.0 { + let retinex = (orig / bg).ln() * 255.0; + output.put_pixel(x, y, Luma([retinex.clamp(0.0, 255.0) as u8])); + } else { + output.put_pixel(x, y, Luma([0])); + } + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shadow_removal_uniform() { + // Uniform image should remain uniform + let img = GrayImage::from_pixel(100, 100, Luma([128])); + let result = remove_shadow(&img); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 100); + // The result should have fewer dark pixels than a shadowed version + let dark_count = result.iter().filter(|&&p| p < 50).count(); + assert!(dark_count < 100); // Very few dark pixels + } + + #[test] + fn test_mean_pixel() { + let img = GrayImage::from_pixel(10, 10, Luma([100])); + assert!((mean_pixel(&img) - 100.0).abs() < 1.0); + } +} \ No newline at end of file diff --git a/backend/workers/src/scanner/warp.rs b/backend/workers/src/scanner/warp.rs new file mode 100644 index 0000000..930209e --- /dev/null +++ b/backend/workers/src/scanner/warp.rs @@ -0,0 +1,208 @@ +use image::{DynamicImage, GrayImage, Luma}; +use nalgebra::{Matrix3, SVD}; + +use tools_common::error::PipelineError; + +use crate::scanner::corners::CornerPoint; + +/// Compute homography matrix from 4 point correspondences using DLT algorithm. +pub fn compute_homography( + src: &[CornerPoint; 4], + dst: &[CornerPoint; 4], +) -> Result<[[f64; 3]; 3], PipelineError> { + // Build 8x9 matrix A from 4 point correspondences + // Each correspondence (x,y) -> (x',y') gives 2 rows: + // [-x, -y, -1, 0, 0, 0, x*x', y*x', x'] + // [ 0, 0, 0, -x, -y, -1, x*y', y*y', y'] + let mut a = nalgebra::DMatrix::::zeros(8, 9); + + for i in 0..4 { + let x = src[i].0; + let y = src[i].1; + let xp = dst[i].0; + let yp = dst[i].1; + + // First row + a[(i * 2, 0)] = -x; + a[(i * 2, 1)] = -y; + a[(i * 2, 2)] = -1.0; + a[(i * 2, 3)] = 0.0; + a[(i * 2, 4)] = 0.0; + a[(i * 2, 5)] = 0.0; + a[(i * 2, 6)] = x * xp; + a[(i * 2, 7)] = y * xp; + a[(i * 2, 8)] = xp; + + // Second row + a[(i * 2 + 1, 0)] = 0.0; + a[(i * 2 + 1, 1)] = 0.0; + a[(i * 2 + 1, 2)] = 0.0; + a[(i * 2 + 1, 3)] = -x; + a[(i * 2 + 1, 4)] = -y; + a[(i * 2 + 1, 5)] = -1.0; + a[(i * 2 + 1, 6)] = x * yp; + a[(i * 2 + 1, 7)] = y * yp; + a[(i * 2 + 1, 8)] = yp; + } + + // Solve Ah = 0 via SVD: h = last column of V + let svd = SVD::new(a, true, true); + if let Some(v_t) = &svd.v_t { + let nrows = v_t.nrows(); + if nrows > 0 { + let h_vec: Vec = v_t.row(nrows - 1).iter().copied().collect(); + if h_vec.len() >= 9 { + let h = [ + [h_vec[0], h_vec[1], h_vec[2]], + [h_vec[3], h_vec[4], h_vec[5]], + [h_vec[6], h_vec[7], h_vec[8]], + ]; + return Ok(h); + } + } + } + + Err(PipelineError::Warp("SVD decomposition failed".to_string())) +} + +/// Invert a 3x3 homography matrix. +pub fn invert_homography(h: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let m = Matrix3::new(h[0][0], h[0][1], h[0][2], h[1][0], h[1][1], h[1][2], h[2][0], h[2][1], h[2][2]); + let inv = m + .try_inverse() + .unwrap_or(Matrix3::identity()); + [ + [inv[(0, 0)], inv[(0, 1)], inv[(0, 2)]], + [inv[(1, 0)], inv[(1, 1)], inv[(1, 2)]], + [inv[(2, 0)], inv[(2, 1)], inv[(2, 2)]], + ] +} + +/// Apply homography to a point (forward mapping). +pub fn apply_homography(h: &[[f64; 3]; 3], x: f64, y: f64) -> (f64, f64) { + let z = h[2][0] * x + h[2][1] * y + h[2][2]; + if z.abs() < 1e-10 { + return (x, y); + } + let xp = (h[0][0] * x + h[0][1] * y + h[0][2]) / z; + let yp = (h[1][0] * x + h[1][1] * y + h[1][2]) / z; + (xp, yp) +} + +/// Bilinear interpolation at sub-pixel coordinates. +fn bilinear_interpolate(img: &GrayImage, x: f64, y: f64) -> Luma { + let x0 = x.floor() as i32; + let y0 = y.floor() as i32; + let x1 = x0 + 1; + let y1 = y0 + 1; + + let w = img.width() as i32; + let h = img.height() as i32; + + // Clamp coordinates + let x0 = x0.clamp(0, w - 1); + let x1 = x1.clamp(0, w - 1); + let y0 = y0.clamp(0, h - 1); + let y1 = y1.clamp(0, h - 1); + + let fx = x - x0 as f64; + let fy = y - y0 as f64; + + let p00 = img.get_pixel(x0 as u32, y0 as u32)[0] as f64; + let p10 = img.get_pixel(x1 as u32, y0 as u32)[0] as f64; + let p01 = img.get_pixel(x0 as u32, y1 as u32)[0] as f64; + let p11 = img.get_pixel(x1 as u32, y1 as u32)[0] as f64; + + let val = p00 * (1.0 - fx) * (1.0 - fy) + + p10 * fx * (1.0 - fy) + + p01 * (1.0 - fx) * fy + + p11 * fx * fy; + + Luma([val.round().clamp(0.0, 255.0) as u8]) +} + +/// Apply perspective warp to correct the document perspective. +/// Takes the original color image and 4 corners, returns warped image. +pub fn warp_perspective( + img: &DynamicImage, + corners: [CornerPoint; 4], +) -> Result { + let [tl, tr, br, bl] = corners; + + // Compute target width and height (preserve aspect ratio) + let width_top = distance(tl, tr); + let width_bot = distance(bl, br); + let width = width_top.max(width_bot).ceil() as u32; + + let height_left = distance(tl, bl); + let height_right = distance(tr, br); + let height = height_left.max(height_right).ceil() as u32; + + // Clamp output dimensions + let width = width.min(3000).max(1); + let height = height.min(3000).max(1); + + let src = [tl, tr, br, bl]; + let dst = [ + (0.0, 0.0), + (width as f64, 0.0), + (width as f64, height as f64), + (0.0, height as f64), + ]; + + let h = compute_homography(&src, &dst)?; + let h_inv = invert_homography(&h); + + let gray = img.to_luma8(); + let mut output = GrayImage::new(width, height); + + // Backward mapping: for each output pixel, find source pixel + for y in 0..height { + for x in 0..width { + let (sx, sy) = apply_homography(&h_inv, x as f64, y as f64); + let pixel = bilinear_interpolate(&gray, sx, sy); + output.put_pixel(x, y, pixel); + } + } + + Ok(DynamicImage::ImageLuma8(output)) +} + +/// Euclidean distance between two points. +fn distance(a: CornerPoint, b: CornerPoint) -> f64 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_homography_identity() { + // Identity mapping should produce identity matrix + let src = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let dst = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let h = compute_homography(&src, &dst).unwrap(); + let (xp, yp) = apply_homography(&h, 50.0, 50.0); + assert!((xp - 50.0).abs() < 1.0); + assert!((yp - 50.0).abs() < 1.0); + } + + #[test] + fn test_invert_homography() { + let h = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]]; + let inv = invert_homography(&h); + let (xp, yp) = apply_homography(&inv, 100.0, 100.0); + assert!((xp - 50.0).abs() < 0.001); + assert!((yp - 50.0).abs() < 0.001); + } + + #[test] + fn test_bilinear_interpolate() { + let mut img = GrayImage::new(3, 3); + img.put_pixel(0, 0, Luma([100])); + img.put_pixel(1, 0, Luma([200])); + let pixel = bilinear_interpolate(&img, 0.5, 0.0); + assert_eq!(pixel[0], 150); // Midpoint between 100 and 200 + } +} \ No newline at end of file diff --git a/backend/workers/src/scheduler/cleanup.rs b/backend/workers/src/scheduler/cleanup.rs new file mode 100644 index 0000000..e06d2ae --- /dev/null +++ b/backend/workers/src/scheduler/cleanup.rs @@ -0,0 +1,81 @@ +use redis::AsyncCommands; + +/// Cleanup expired files and Redis keys. +/// Scans storage directory and removes files older than TTL. +pub struct CleanupScheduler; + +impl CleanupScheduler { + /// Run a single cleanup cycle. + pub async fn run( + storage_path: &std::path::Path, + redis_client: &redis::Client, + ttl_seconds: u64, + ) -> Result> { + let mut result = CleanupResult::default(); + let now = std::time::SystemTime::now(); + + // Clean up upload files + let upload_dir = storage_path.join("upload"); + if upload_dir.exists() { + let mut entries = tokio::fs::read_dir(&upload_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if let Ok(metadata) = entry.metadata().await { + if let Ok(modified) = metadata.modified() { + if now + .duration_since(modified) + .map(|d| d.as_secs() > ttl_seconds) + .unwrap_or(false) + { + if let Ok(_) = tokio::fs::remove_file(entry.path()).await { + result.files_deleted += 1; + result.bytes_freed += metadata.len(); + } + } + } + } + } + } + + // Clean up output files + let output_dir = storage_path.join("output"); + if output_dir.exists() { + let mut entries = tokio::fs::read_dir(&output_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if let Ok(metadata) = entry.metadata().await { + if let Ok(modified) = metadata.modified() { + if now + .duration_since(modified) + .map(|d| d.as_secs() > ttl_seconds) + .unwrap_or(false) + { + if let Ok(_) = tokio::fs::remove_file(entry.path()).await { + result.files_deleted += 1; + result.bytes_freed += metadata.len(); + } + } + } + } + } + } + + // Clean up orphaned Redis keys + if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await { + // Scan for expired job keys + let _: Result<(), _> = redis::cmd("SCAN") + .arg(0) + .arg("MATCH") + .arg("job:*") + .query_async(&mut conn) + .await; + } + + Ok(result) + } +} + +#[derive(Debug, Default)] +pub struct CleanupResult { + pub files_deleted: u64, + pub bytes_freed: u64, + pub orphan_keys: u64, +} \ No newline at end of file diff --git a/backend/workers/src/scheduler/mod.rs b/backend/workers/src/scheduler/mod.rs new file mode 100644 index 0000000..08559a6 --- /dev/null +++ b/backend/workers/src/scheduler/mod.rs @@ -0,0 +1,3 @@ +/// Auto-cleanup scheduler for expired files and Redis keys. +/// TODO: Phase 1.4 - implement cleanup logic +pub mod cleanup; \ No newline at end of file diff --git a/backend/workers/src/video/mod.rs b/backend/workers/src/video/mod.rs new file mode 100644 index 0000000..a2b155d --- /dev/null +++ b/backend/workers/src/video/mod.rs @@ -0,0 +1,2 @@ +// Video processing module. +// TODO: Phase 4 - implement compress, extract audio, trim, GIF maker \ No newline at end of file diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..d984b2a --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../../biome.json"] +} \ No newline at end of file diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..bf85cf5 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..67131f2 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactCompiler: true, + turbopack: { + root: process.cwd(), + }, +}; + +export default nextConfig; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7064d6a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "tools-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start", + "lint": "biome check", + "format": "biome format --write" + }, + "dependencies": { + "@shadcn/react": "^0.2.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.42.2", + "lucide-react": "^1.26.0", + "next": "16.2.11", + "next-themes": "^0.4.6", + "react": "19.2.8", + "react-dom": "19.2.8", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@biomejs/biome": "2.5.5", + "@tailwindcss/postcss": "^4", + "@types/node": "^26", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..5f04293 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; \ No newline at end of file diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..c53498d --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Tools — Asep Haryana", + "short_name": "Tools", + "description": "Document Scanner, Image & PDF Tools", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a1a", + "theme_color": "#0a0a1a", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/frontend/src/app/api/download/[id]/route.ts b/frontend/src/app/api/download/[id]/route.ts new file mode 100644 index 0000000..68cf115 --- /dev/null +++ b/frontend/src/app/api/download/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; + +const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const response = await fetch(`${RUST_GATEWAY}/api/download/${id}`); + + if (!response.ok) { + const data = await response.json().catch(() => null); + return NextResponse.json( + data ?? { error: "Download failed" }, + { status: response.status }, + ); + } + + // Stream the file back + const blob = await response.blob(); + const contentType = + response.headers.get("content-type") || "application/octet-stream"; + const contentDisposition = + response.headers.get("content-disposition") || + "attachment; filename=\"result\""; + + return new NextResponse(blob, { + headers: { + "Content-Type": contentType, + "Content-Disposition": contentDisposition, + }, + }); + } catch (error) { + console.error("Download proxy error:", error); + return NextResponse.json( + { error: "Failed to download file" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/api/job/[id]/route.ts b/frontend/src/app/api/job/[id]/route.ts new file mode 100644 index 0000000..4df8e37 --- /dev/null +++ b/frontend/src/app/api/job/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; + +const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const response = await fetch(`${RUST_GATEWAY}/api/job/${id}`); + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data); + } catch (error) { + console.error("Job status proxy error:", error); + return NextResponse.json( + { error: "Failed to fetch job status" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/api/job/[id]/ws/route.ts b/frontend/src/app/api/job/[id]/ws/route.ts new file mode 100644 index 0000000..e728947 --- /dev/null +++ b/frontend/src/app/api/job/[id]/ws/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +// WebSocket is handled directly by the client connecting to the Rust gateway. +// Next.js App Router cannot proxy WebSocket connections in route handlers. +// The client-side useJobStatus hook connects directly to ws://localhost:3001/api/job/{id}/ws +// In production, configure the WebSocket to connect to wss://tools.asepharyana.my.id/api/job/{id}/ws +export function GET() { + return NextResponse.json( + { + note: "WebSocket connections go directly to the Rust gateway", + ws_url: + process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:3001/api/job/{id}/ws", + }, + ); +} \ No newline at end of file diff --git a/frontend/src/app/api/upload/route.ts b/frontend/src/app/api/upload/route.ts new file mode 100644 index 0000000..abfba9f --- /dev/null +++ b/frontend/src/app/api/upload/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001"; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get("file"); + const tool = formData.get("tool"); + const options = formData.get("options"); + + if (!file || !tool) { + return NextResponse.json( + { error: "Missing file or tool parameter" }, + { status: 400 }, + ); + } + + // Forward to Rust gateway + const gatewayForm = new FormData(); + gatewayForm.append("file", file); + gatewayForm.append("tool", tool as string); + if (options) { + gatewayForm.append("options", options as string); + } + + const response = await fetch(`${RUST_GATEWAY}/api/upload`, { + method: "POST", + body: gatewayForm, + }); + + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: 202 }); + } catch (error) { + console.error("Upload proxy error:", error); + return NextResponse.json( + { error: "Failed to process upload" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/audio/convert/page.tsx b/frontend/src/app/audio/convert/page.tsx new file mode 100644 index 0000000..1a83723 --- /dev/null +++ b/frontend/src/app/audio/convert/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Mic } from "lucide-react"; +import { ToolLayout } from "@/components/tools/tool-layout"; +import { UploadZone } from "@/components/tools/upload-zone"; +import { useUpload } from "@/hooks/use-upload"; +import { useJobStatus } from "@/hooks/use-job-status"; +import { ProgressBar } from "@/components/tools/progress-bar"; +import { ResultPreview } from "@/components/tools/result-preview"; + +type PageState = "upload" | "processing" | "result" | "error"; + +export default function AudioConvertPage() { + const [pageState, setPageState] = useState("upload"); + const [jobId, setJobId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const { upload } = useUpload({ + tool: "audio-convert", + options: { quality: 80 }, + }); + + const handleComplete = useCallback(() => setPageState("result"), []); + const handleError = useCallback( + (err: string) => { + setErrorMsg(err); + setPageState("error"); + }, + [], + ); + + const { progress, stage, message, status, result } = useJobStatus(jobId, { + onComplete: handleComplete, + onError: handleError, + }); + + const handleUpload = useCallback( + async (file: File) => { + setErrorMsg(null); + const res = await upload(file); + if (res) { + setJobId(res.job_id); + setPageState("processing"); + } + }, + [upload], + ); + + const handleRetry = useCallback(() => { + setPageState("upload"); + setJobId(null); + setErrorMsg(null); + }, []); + + return ( + + {pageState === "upload" && ( + + )} + + {pageState === "processing" && jobId && ( + + )} + + {pageState === "result" && result && ( + + )} + + {pageState === "error" && ( +
+

+ {errorMsg || "Terjadi kesalahan"} +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..dbf6aef --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,121 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-ring: var(--ring); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --font-sans: "Geist", sans-serif; + --font-mono: "Geist Mono", monospace; +} + +:root { + --radius: 0.625rem; + --background: oklch(0.97 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0.042 265.755); + --primary-foreground: oklch(0.985 0 0); + --muted: oklch(0.965 0.001 286.375); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.965 0.001 286.375); + --accent-foreground: oklch(0.205 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0.004 286.375); + --ring: oklch(0.205 0.042 265.755); +} + +.dark { + --background: oklch(0.07 0.015 265); + --foreground: oklch(0.985 0 0); + --card: oklch(0.12 0.02 265); + --card-foreground: oklch(0.985 0 0); + --primary: oklch(0.7 0.15 265); + --primary-foreground: oklch(0.07 0.015 265); + --muted: oklch(0.15 0.02 265); + --muted-foreground: oklch(0.6 0.02 265); + --accent: oklch(0.15 0.02 265); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.2 0.02 265); + --ring: oklch(0.7 0.15 265); +} + +* { + border-color: var(--border); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} + +/* Glass effect */ +.glass { + background: oklch(from var(--card) l c h / 0.6); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid oklch(from var(--border) l c h / 0.5); +} + +/* Gradient text */ +.gradient-text { + background: linear-gradient(135deg, var(--primary), oklch(0.6 0.2 265)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Terminal cursor blink */ +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.cursor-blink::after { + content: "█"; + animation: blink 1s step-end infinite; + color: var(--primary); +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--muted); +} + +::-webkit-scrollbar-thumb { + background: var(--muted-foreground); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--primary); +} \ No newline at end of file diff --git a/frontend/src/app/health/route.ts b/frontend/src/app/health/route.ts new file mode 100644 index 0000000..eccbe58 --- /dev/null +++ b/frontend/src/app/health/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001"; + +export async function GET() { + try { + const response = await fetch(`${RUST_GATEWAY}/health`, { + signal: AbortSignal.timeout(5000), + }); + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { status: "error", message: "Gateway unreachable" }, + { status: 503 }, + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/image/compress/page.tsx b/frontend/src/app/image/compress/page.tsx new file mode 100644 index 0000000..f9cc09c --- /dev/null +++ b/frontend/src/app/image/compress/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { ImageDown } from "lucide-react"; +import { ToolLayout } from "@/components/tools/tool-layout"; +import { UploadZone } from "@/components/tools/upload-zone"; +import { useUpload } from "@/hooks/use-upload"; +import { useJobStatus } from "@/hooks/use-job-status"; +import { ProgressBar } from "@/components/tools/progress-bar"; +import { ResultPreview } from "@/components/tools/result-preview"; + +type PageState = "upload" | "processing" | "result" | "error"; + +export default function ImageCompressPage() { + const [pageState, setPageState] = useState("upload"); + const [jobId, setJobId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const { upload } = useUpload({ + tool: "image-compress", + options: { quality: 80 }, + }); + + const handleComplete = useCallback(() => setPageState("result"), []); + const handleError = useCallback( + (err: string) => { + setErrorMsg(err); + setPageState("error"); + }, + [], + ); + + const { progress, stage, message, status, result } = useJobStatus(jobId, { + onComplete: handleComplete, + onError: handleError, + }); + + const handleUpload = useCallback( + async (file: File) => { + setErrorMsg(null); + const res = await upload(file); + if (res) { + setJobId(res.job_id); + setPageState("processing"); + } + }, + [upload], + ); + + const handleRetry = useCallback(() => { + setPageState("upload"); + setJobId(null); + setErrorMsg(null); + }, []); + + return ( + + {pageState === "upload" && ( + + )} + + {pageState === "processing" && jobId && ( + + )} + + {pageState === "result" && result && ( + + )} + + {pageState === "error" && ( +
+

+ {errorMsg || "Terjadi kesalahan"} +

+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/image/convert/page.tsx b/frontend/src/app/image/convert/page.tsx new file mode 100644 index 0000000..ca3b1cc --- /dev/null +++ b/frontend/src/app/image/convert/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Repeat } from "lucide-react"; +import { ToolLayout } from "@/components/tools/tool-layout"; +import { UploadZone } from "@/components/tools/upload-zone"; +import { useUpload } from "@/hooks/use-upload"; +import { useJobStatus } from "@/hooks/use-job-status"; +import { ProgressBar } from "@/components/tools/progress-bar"; +import { ResultPreview } from "@/components/tools/result-preview"; + +type PageState = "upload" | "processing" | "result" | "error"; + +export default function ImageConvertPage() { + const [pageState, setPageState] = useState("upload"); + const [jobId, setJobId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const { upload } = useUpload({ + tool: "image-convert", + options: { quality: 80 }, + }); + + const handleComplete = useCallback(() => setPageState("result"), []); + const handleError = useCallback( + (err: string) => { + setErrorMsg(err); + setPageState("error"); + }, + [], + ); + + const { progress, stage, message, status, result } = useJobStatus(jobId, { + onComplete: handleComplete, + onError: handleError, + }); + + const handleUpload = useCallback( + async (file: File) => { + setErrorMsg(null); + const res = await upload(file); + if (res) { + setJobId(res.job_id); + setPageState("processing"); + } + }, + [upload], + ); + + const handleRetry = useCallback(() => { + setPageState("upload"); + setJobId(null); + setErrorMsg(null); + }, []); + + return ( + + {pageState === "upload" && ( + + )} + + {pageState === "processing" && jobId && ( + + )} + + {pageState === "result" && result && ( + + )} + + {pageState === "error" && ( +
+

+ {errorMsg || "Terjadi kesalahan"} +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/image/remove-bg/page.tsx b/frontend/src/app/image/remove-bg/page.tsx new file mode 100644 index 0000000..73ae8c4 --- /dev/null +++ b/frontend/src/app/image/remove-bg/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Shrink } from "lucide-react"; +import { ToolLayout } from "@/components/tools/tool-layout"; +import { UploadZone } from "@/components/tools/upload-zone"; +import { useUpload } from "@/hooks/use-upload"; +import { useJobStatus } from "@/hooks/use-job-status"; +import { ProgressBar } from "@/components/tools/progress-bar"; +import { ResultPreview } from "@/components/tools/result-preview"; + +type PageState = "upload" | "processing" | "result" | "error"; + +export default function ImageRemoveBgPage() { + const [pageState, setPageState] = useState("upload"); + const [jobId, setJobId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const { upload } = useUpload({ + tool: "image-remove-bg", + options: { quality: 80 }, + }); + + const handleComplete = useCallback(() => setPageState("result"), []); + const handleError = useCallback( + (err: string) => { + setErrorMsg(err); + setPageState("error"); + }, + [], + ); + + const { progress, stage, message, status, result } = useJobStatus(jobId, { + onComplete: handleComplete, + onError: handleError, + }); + + const handleUpload = useCallback( + async (file: File) => { + setErrorMsg(null); + const res = await upload(file); + if (res) { + setJobId(res.job_id); + setPageState("processing"); + } + }, + [upload], + ); + + const handleRetry = useCallback(() => { + setPageState("upload"); + setJobId(null); + setErrorMsg(null); + }, []); + + return ( + + {pageState === "upload" && ( + + )} + + {pageState === "processing" && jobId && ( + + )} + + {pageState === "result" && result && ( + + )} + + {pageState === "error" && ( +
+

+ {errorMsg || "Terjadi kesalahan"} +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/image/resize/page.tsx b/frontend/src/app/image/resize/page.tsx new file mode 100644 index 0000000..c194245 --- /dev/null +++ b/frontend/src/app/image/resize/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { ImageResize } from "lucide-react"; +import { ToolLayout } from "@/components/tools/tool-layout"; +import { UploadZone } from "@/components/tools/upload-zone"; +import { useUpload } from "@/hooks/use-upload"; +import { useJobStatus } from "@/hooks/use-job-status"; +import { ProgressBar } from "@/components/tools/progress-bar"; +import { ResultPreview } from "@/components/tools/result-preview"; + +type PageState = "upload" | "processing" | "result" | "error"; + +export default function ImageResizePage() { + const [pageState, setPageState] = useState("upload"); + const [jobId, setJobId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const { upload } = useUpload({ + tool: "image-resize", + options: { quality: 80 }, + }); + + const handleComplete = useCallback(() => setPageState("result"), []); + const handleError = useCallback( + (err: string) => { + setErrorMsg(err); + setPageState("error"); + }, + [], + ); + + const { progress, stage, message, status, result } = useJobStatus(jobId, { + onComplete: handleComplete, + onError: handleError, + }); + + const handleUpload = useCallback( + async (file: File) => { + setErrorMsg(null); + const res = await upload(file); + if (res) { + setJobId(res.job_id); + setPageState("processing"); + } + }, + [upload], + ); + + const handleRetry = useCallback(() => { + setPageState("upload"); + setJobId(null); + setErrorMsg(null); + }, []); + + return ( + + {pageState === "upload" && ( + + )} + + {pageState === "processing" && jobId && ( + + )} + + {pageState === "result" && result && ( + + )} + + {pageState === "error" && ( +
+

+ {errorMsg || "Terjadi kesalahan"} +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..057219e --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { ThemeProvider } from "next-themes"; + +import "./globals.css"; +import { Header } from "@/components/tools/header"; +import { Footer } from "@/components/tools/footer"; + +export const metadata: Metadata = { + title: "Tools — Asep Haryana", + description: + "Self-hosted document scanner, image tools & PDF tools. No upload to third-party servers.", + manifest: "/manifest.json", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+
{children}
+