Mayo commited on
feat: websocket-based rpc
Browse files- Cargo.lock +74 -53
- Cargo.toml +7 -7
- koharu/Cargo.toml +5 -1
- koharu/src/app.rs +43 -65
- koharu/src/command.rs +0 -39
- koharu/src/lib.rs +2 -2
- koharu/src/{endpoints.rs → operations.rs} +175 -226
- koharu/src/result.rs +0 -3
- koharu/src/rpc.rs +569 -0
- koharu/src/server.rs +28 -65
- ui/app/(app)/about/page.tsx +1 -1
- ui/app/(app)/settings/page.tsx +2 -14
- ui/app/providers.tsx +1 -1
- ui/app/splashscreen/page.tsx +47 -53
- ui/components/Image.tsx +1 -1
- ui/hooks/useMaskDrawing.ts +2 -2
- ui/hooks/useRenderBrushDrawing.ts +1 -1
- ui/lib/backend.ts +121 -206
- ui/lib/rpc-types.ts +111 -0
- ui/lib/store.ts +9 -9
- ui/lib/util.ts +13 -5
- ui/lib/ws.ts +189 -0
- ui/package.json +1 -0
- ui/types.d.ts +6 -6
Cargo.lock
CHANGED
|
@@ -546,6 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
| 546 |
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
| 547 |
dependencies = [
|
| 548 |
"axum-core",
|
|
|
|
| 549 |
"bytes",
|
| 550 |
"form_urlencoded",
|
| 551 |
"futures-util",
|
|
@@ -558,15 +559,16 @@ dependencies = [
|
|
| 558 |
"matchit",
|
| 559 |
"memchr",
|
| 560 |
"mime",
|
| 561 |
-
"multer",
|
| 562 |
"percent-encoding",
|
| 563 |
"pin-project-lite",
|
| 564 |
"serde_core",
|
| 565 |
"serde_json",
|
| 566 |
"serde_path_to_error",
|
| 567 |
"serde_urlencoded",
|
|
|
|
| 568 |
"sync_wrapper",
|
| 569 |
"tokio",
|
|
|
|
| 570 |
"tower",
|
| 571 |
"tower-layer",
|
| 572 |
"tower-service",
|
|
@@ -1564,6 +1566,12 @@ dependencies = [
|
|
| 1564 |
"serde",
|
| 1565 |
]
|
| 1566 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
[[package]]
|
| 1568 |
name = "deranged"
|
| 1569 |
version = "0.5.5"
|
|
@@ -1870,6 +1878,18 @@ dependencies = [
|
|
| 1870 |
"syn 2.0.114",
|
| 1871 |
]
|
| 1872 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1873 |
[[package]]
|
| 1874 |
name = "enumflags2"
|
| 1875 |
version = "0.7.12"
|
|
@@ -4059,10 +4079,12 @@ name = "koharu"
|
|
| 4059 |
version = "0.30.1"
|
| 4060 |
dependencies = [
|
| 4061 |
"anyhow",
|
|
|
|
| 4062 |
"axum",
|
| 4063 |
"blake3",
|
| 4064 |
"clap",
|
| 4065 |
"dirs",
|
|
|
|
| 4066 |
"futures",
|
| 4067 |
"git-version",
|
| 4068 |
"icu",
|
|
@@ -4076,7 +4098,8 @@ dependencies = [
|
|
| 4076 |
"postcard",
|
| 4077 |
"rayon",
|
| 4078 |
"rfd",
|
| 4079 |
-
"
|
|
|
|
| 4080 |
"serde",
|
| 4081 |
"serde_bytes",
|
| 4082 |
"strum",
|
|
@@ -4085,6 +4108,7 @@ dependencies = [
|
|
| 4085 |
"tauri-build",
|
| 4086 |
"thiserror 2.0.18",
|
| 4087 |
"tokio",
|
|
|
|
| 4088 |
"tower-http",
|
| 4089 |
"tracing",
|
| 4090 |
"tracing-subscriber",
|
|
@@ -4630,23 +4654,6 @@ dependencies = [
|
|
| 4630 |
"windows-sys 0.60.2",
|
| 4631 |
]
|
| 4632 |
|
| 4633 |
-
[[package]]
|
| 4634 |
-
name = "multer"
|
| 4635 |
-
version = "3.1.0"
|
| 4636 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4637 |
-
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
| 4638 |
-
dependencies = [
|
| 4639 |
-
"bytes",
|
| 4640 |
-
"encoding_rs",
|
| 4641 |
-
"futures-util",
|
| 4642 |
-
"http",
|
| 4643 |
-
"httparse",
|
| 4644 |
-
"memchr",
|
| 4645 |
-
"mime",
|
| 4646 |
-
"spin",
|
| 4647 |
-
"version_check",
|
| 4648 |
-
]
|
| 4649 |
-
|
| 4650 |
[[package]]
|
| 4651 |
name = "naga"
|
| 4652 |
version = "28.0.0"
|
|
@@ -6373,48 +6380,42 @@ dependencies = [
|
|
| 6373 |
]
|
| 6374 |
|
| 6375 |
[[package]]
|
| 6376 |
-
name = "
|
| 6377 |
-
version = "0.
|
| 6378 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6379 |
-
checksum = "
|
| 6380 |
dependencies = [
|
| 6381 |
-
"
|
| 6382 |
]
|
| 6383 |
|
| 6384 |
[[package]]
|
| 6385 |
-
name = "
|
| 6386 |
-
version = "
|
| 6387 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6388 |
-
checksum = "
|
| 6389 |
dependencies = [
|
| 6390 |
-
"
|
| 6391 |
-
"
|
| 6392 |
-
"walkdir",
|
| 6393 |
]
|
| 6394 |
|
| 6395 |
[[package]]
|
| 6396 |
-
name = "
|
| 6397 |
-
version = "
|
| 6398 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6399 |
-
checksum = "
|
| 6400 |
dependencies = [
|
| 6401 |
-
"
|
| 6402 |
-
"
|
| 6403 |
-
"
|
| 6404 |
-
"shellexpand",
|
| 6405 |
-
"syn 2.0.114",
|
| 6406 |
-
"walkdir",
|
| 6407 |
]
|
| 6408 |
|
| 6409 |
[[package]]
|
| 6410 |
-
name = "
|
| 6411 |
-
version = "
|
| 6412 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6413 |
-
checksum = "
|
| 6414 |
dependencies = [
|
| 6415 |
-
"
|
| 6416 |
-
"sha2",
|
| 6417 |
-
"walkdir",
|
| 6418 |
]
|
| 6419 |
|
| 6420 |
[[package]]
|
|
@@ -6905,15 +6906,6 @@ dependencies = [
|
|
| 6905 |
"lazy_static",
|
| 6906 |
]
|
| 6907 |
|
| 6908 |
-
[[package]]
|
| 6909 |
-
name = "shellexpand"
|
| 6910 |
-
version = "3.1.1"
|
| 6911 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6912 |
-
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
| 6913 |
-
dependencies = [
|
| 6914 |
-
"dirs",
|
| 6915 |
-
]
|
| 6916 |
-
|
| 6917 |
[[package]]
|
| 6918 |
name = "shlex"
|
| 6919 |
version = "1.3.0"
|
|
@@ -7793,6 +7785,18 @@ dependencies = [
|
|
| 7793 |
"tokio",
|
| 7794 |
]
|
| 7795 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7796 |
[[package]]
|
| 7797 |
name = "tokio-util"
|
| 7798 |
version = "0.7.18"
|
|
@@ -8070,6 +8074,23 @@ version = "0.25.1"
|
|
| 8070 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 8071 |
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
| 8072 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8073 |
[[package]]
|
| 8074 |
name = "typed-path"
|
| 8075 |
version = "0.12.2"
|
|
|
|
| 546 |
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
| 547 |
dependencies = [
|
| 548 |
"axum-core",
|
| 549 |
+
"base64 0.22.1",
|
| 550 |
"bytes",
|
| 551 |
"form_urlencoded",
|
| 552 |
"futures-util",
|
|
|
|
| 559 |
"matchit",
|
| 560 |
"memchr",
|
| 561 |
"mime",
|
|
|
|
| 562 |
"percent-encoding",
|
| 563 |
"pin-project-lite",
|
| 564 |
"serde_core",
|
| 565 |
"serde_json",
|
| 566 |
"serde_path_to_error",
|
| 567 |
"serde_urlencoded",
|
| 568 |
+
"sha1",
|
| 569 |
"sync_wrapper",
|
| 570 |
"tokio",
|
| 571 |
+
"tokio-tungstenite",
|
| 572 |
"tower",
|
| 573 |
"tower-layer",
|
| 574 |
"tower-service",
|
|
|
|
| 1566 |
"serde",
|
| 1567 |
]
|
| 1568 |
|
| 1569 |
+
[[package]]
|
| 1570 |
+
name = "data-encoding"
|
| 1571 |
+
version = "2.10.0"
|
| 1572 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1573 |
+
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
| 1574 |
+
|
| 1575 |
[[package]]
|
| 1576 |
name = "deranged"
|
| 1577 |
version = "0.5.5"
|
|
|
|
| 1878 |
"syn 2.0.114",
|
| 1879 |
]
|
| 1880 |
|
| 1881 |
+
[[package]]
|
| 1882 |
+
name = "enum_dispatch"
|
| 1883 |
+
version = "0.3.13"
|
| 1884 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1885 |
+
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
|
| 1886 |
+
dependencies = [
|
| 1887 |
+
"once_cell",
|
| 1888 |
+
"proc-macro2",
|
| 1889 |
+
"quote",
|
| 1890 |
+
"syn 2.0.114",
|
| 1891 |
+
]
|
| 1892 |
+
|
| 1893 |
[[package]]
|
| 1894 |
name = "enumflags2"
|
| 1895 |
version = "0.7.12"
|
|
|
|
| 4079 |
version = "0.30.1"
|
| 4080 |
dependencies = [
|
| 4081 |
"anyhow",
|
| 4082 |
+
"async-trait",
|
| 4083 |
"axum",
|
| 4084 |
"blake3",
|
| 4085 |
"clap",
|
| 4086 |
"dirs",
|
| 4087 |
+
"enum_dispatch",
|
| 4088 |
"futures",
|
| 4089 |
"git-version",
|
| 4090 |
"icu",
|
|
|
|
| 4098 |
"postcard",
|
| 4099 |
"rayon",
|
| 4100 |
"rfd",
|
| 4101 |
+
"rmp-serde",
|
| 4102 |
+
"rmpv",
|
| 4103 |
"serde",
|
| 4104 |
"serde_bytes",
|
| 4105 |
"strum",
|
|
|
|
| 4108 |
"tauri-build",
|
| 4109 |
"thiserror 2.0.18",
|
| 4110 |
"tokio",
|
| 4111 |
+
"tower",
|
| 4112 |
"tower-http",
|
| 4113 |
"tracing",
|
| 4114 |
"tracing-subscriber",
|
|
|
|
| 4654 |
"windows-sys 0.60.2",
|
| 4655 |
]
|
| 4656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4657 |
[[package]]
|
| 4658 |
name = "naga"
|
| 4659 |
version = "28.0.0"
|
|
|
|
| 6380 |
]
|
| 6381 |
|
| 6382 |
[[package]]
|
| 6383 |
+
name = "rmp"
|
| 6384 |
+
version = "0.8.15"
|
| 6385 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6386 |
+
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
| 6387 |
dependencies = [
|
| 6388 |
+
"num-traits",
|
| 6389 |
]
|
| 6390 |
|
| 6391 |
[[package]]
|
| 6392 |
+
name = "rmp-serde"
|
| 6393 |
+
version = "1.3.1"
|
| 6394 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6395 |
+
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
| 6396 |
dependencies = [
|
| 6397 |
+
"rmp",
|
| 6398 |
+
"serde",
|
|
|
|
| 6399 |
]
|
| 6400 |
|
| 6401 |
[[package]]
|
| 6402 |
+
name = "rmpv"
|
| 6403 |
+
version = "1.3.1"
|
| 6404 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6405 |
+
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
|
| 6406 |
dependencies = [
|
| 6407 |
+
"rmp",
|
| 6408 |
+
"serde",
|
| 6409 |
+
"serde_bytes",
|
|
|
|
|
|
|
|
|
|
| 6410 |
]
|
| 6411 |
|
| 6412 |
[[package]]
|
| 6413 |
+
name = "roxmltree"
|
| 6414 |
+
version = "0.21.1"
|
| 6415 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6416 |
+
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
| 6417 |
dependencies = [
|
| 6418 |
+
"memchr",
|
|
|
|
|
|
|
| 6419 |
]
|
| 6420 |
|
| 6421 |
[[package]]
|
|
|
|
| 6906 |
"lazy_static",
|
| 6907 |
]
|
| 6908 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6909 |
[[package]]
|
| 6910 |
name = "shlex"
|
| 6911 |
version = "1.3.0"
|
|
|
|
| 7785 |
"tokio",
|
| 7786 |
]
|
| 7787 |
|
| 7788 |
+
[[package]]
|
| 7789 |
+
name = "tokio-tungstenite"
|
| 7790 |
+
version = "0.28.0"
|
| 7791 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 7792 |
+
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
| 7793 |
+
dependencies = [
|
| 7794 |
+
"futures-util",
|
| 7795 |
+
"log",
|
| 7796 |
+
"tokio",
|
| 7797 |
+
"tungstenite",
|
| 7798 |
+
]
|
| 7799 |
+
|
| 7800 |
[[package]]
|
| 7801 |
name = "tokio-util"
|
| 7802 |
version = "0.7.18"
|
|
|
|
| 8074 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 8075 |
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
| 8076 |
|
| 8077 |
+
[[package]]
|
| 8078 |
+
name = "tungstenite"
|
| 8079 |
+
version = "0.28.0"
|
| 8080 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 8081 |
+
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
| 8082 |
+
dependencies = [
|
| 8083 |
+
"bytes",
|
| 8084 |
+
"data-encoding",
|
| 8085 |
+
"http",
|
| 8086 |
+
"httparse",
|
| 8087 |
+
"log",
|
| 8088 |
+
"rand 0.9.2",
|
| 8089 |
+
"sha1",
|
| 8090 |
+
"thiserror 2.0.18",
|
| 8091 |
+
"utf-8",
|
| 8092 |
+
]
|
| 8093 |
+
|
| 8094 |
[[package]]
|
| 8095 |
name = "typed-path"
|
| 8096 |
version = "0.12.2"
|
Cargo.toml
CHANGED
|
@@ -106,13 +106,13 @@ fontdue = "0.9"
|
|
| 106 |
wgpu = "28.0"
|
| 107 |
etagere = "0.2"
|
| 108 |
bytemuck = { version = "1.21", features = ["derive"] }
|
| 109 |
-
axum = { version = "0.8", features = ["
|
| 110 |
-
tower-http = { version = "0.6", features = ["fs"
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
criterion = { version = "0.8", features = ["html_reports"] }
|
| 117 |
git-version = "0.3"
|
| 118 |
|
|
|
|
| 106 |
wgpu = "28.0"
|
| 107 |
etagere = "0.2"
|
| 108 |
bytemuck = { version = "1.21", features = ["derive"] }
|
| 109 |
+
axum = { version = "0.8", features = ["ws"] }
|
| 110 |
+
tower-http = { version = "0.6", features = ["fs"] }
|
| 111 |
+
tower = { version = "0.5", features = ["timeout", "util"] }
|
| 112 |
+
rmp-serde = "1"
|
| 113 |
+
rmpv = { version = "1", features = ["with-serde"] }
|
| 114 |
+
enum_dispatch = "0.3"
|
| 115 |
+
async-trait = "0.1"
|
| 116 |
criterion = { version = "0.8", features = ["html_reports"] }
|
| 117 |
git-version = "0.3"
|
| 118 |
|
koharu/Cargo.toml
CHANGED
|
@@ -50,7 +50,11 @@ sys-locale = { workspace = true }
|
|
| 50 |
icu = { workspace = true }
|
| 51 |
axum = { workspace = true }
|
| 52 |
tower-http = { workspace = true }
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
git-version = { workspace = true }
|
| 55 |
|
| 56 |
[target.'cfg(target_os = "windows")'.dependencies]
|
|
|
|
| 50 |
icu = { workspace = true }
|
| 51 |
axum = { workspace = true }
|
| 52 |
tower-http = { workspace = true }
|
| 53 |
+
tower = { workspace = true }
|
| 54 |
+
rmp-serde = { workspace = true }
|
| 55 |
+
rmpv = { workspace = true }
|
| 56 |
+
enum_dispatch = { workspace = true }
|
| 57 |
+
async-trait = { workspace = true }
|
| 58 |
git-version = { workspace = true }
|
| 59 |
|
| 60 |
[target.'cfg(target_os = "windows")'.dependencies]
|
koharu/src/app.rs
CHANGED
|
@@ -1,10 +1,4 @@
|
|
| 1 |
-
use std::{
|
| 2 |
-
path::PathBuf,
|
| 3 |
-
sync::{
|
| 4 |
-
Arc,
|
| 5 |
-
atomic::{AtomicU16, Ordering},
|
| 6 |
-
},
|
| 7 |
-
};
|
| 8 |
|
| 9 |
use anyhow::{Context, Result};
|
| 10 |
use clap::Parser;
|
|
@@ -17,8 +11,9 @@ use tokio::{net::TcpListener, sync::RwLock};
|
|
| 17 |
use tracing_subscriber::fmt::format::FmtSpan;
|
| 18 |
|
| 19 |
use crate::{
|
| 20 |
-
|
| 21 |
renderer::Renderer,
|
|
|
|
| 22 |
server,
|
| 23 |
state::{AppState, State},
|
| 24 |
};
|
|
@@ -165,13 +160,11 @@ async fn update_app() -> Result<()> {
|
|
| 165 |
async fn prefetch() -> Result<()> {
|
| 166 |
ensure_dylibs(LIB_ROOT.to_path_buf()).await?;
|
| 167 |
ml::prefetch().await?;
|
| 168 |
-
// Skip for now as it's too big
|
| 169 |
-
// llm::prefetch().await?;
|
| 170 |
|
| 171 |
Ok(())
|
| 172 |
}
|
| 173 |
|
| 174 |
-
async fn build_resources(cpu: bool
|
| 175 |
if cuda_is_available() {
|
| 176 |
ensure_dylibs(LIB_ROOT.to_path_buf())
|
| 177 |
.await
|
|
@@ -180,7 +173,7 @@ async fn build_resources(cpu: bool, _register_file_assoc: bool) -> Result<AppRes
|
|
| 180 |
|
| 181 |
#[cfg(target_os = "windows")]
|
| 182 |
{
|
| 183 |
-
if
|
| 184 |
tracing::warn!(?err, "Failed to register .khr file association");
|
| 185 |
}
|
| 186 |
|
|
@@ -237,64 +230,49 @@ pub async fn run() -> Result<()> {
|
|
| 237 |
}
|
| 238 |
});
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
}
|
| 249 |
-
}
|
|
|
|
| 250 |
|
|
|
|
|
|
|
|
|
|
| 251 |
tokio::signal::ctrl_c().await?;
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
-
tauri::Builder::default()
|
| 256 |
-
.invoke_handler(tauri::generate_handler![
|
| 257 |
-
command::initialize,
|
| 258 |
-
command::download_progress,
|
| 259 |
-
])
|
| 260 |
-
.manage(AtomicU16::new(0))
|
| 261 |
-
.setup(move |app| {
|
| 262 |
-
let handle = app.handle().clone();
|
| 263 |
-
tauri::async_runtime::spawn(async move {
|
| 264 |
-
let resources = build_resources(cpu, true)
|
| 265 |
-
.await
|
| 266 |
-
.expect("failed to build app resources");
|
| 267 |
-
let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0)))
|
| 268 |
-
.await
|
| 269 |
-
.expect("failed to bind HTTP server");
|
| 270 |
-
let port = listener
|
| 271 |
-
.local_addr()
|
| 272 |
-
.expect("failed to get listener address")
|
| 273 |
-
.port();
|
| 274 |
-
|
| 275 |
-
handle.state::<AtomicU16>().store(port, Ordering::SeqCst);
|
| 276 |
-
|
| 277 |
-
let server_resources = resources.clone();
|
| 278 |
-
tokio::spawn(async move {
|
| 279 |
-
server::serve_with_listener(listener, server_resources)
|
| 280 |
-
.await
|
| 281 |
-
.expect("failed to run HTTP server");
|
| 282 |
-
});
|
| 283 |
-
|
| 284 |
-
handle
|
| 285 |
-
.get_webview_window("splashscreen")
|
| 286 |
-
.expect("splashscreen window not found")
|
| 287 |
-
.close()
|
| 288 |
-
.ok();
|
| 289 |
-
handle
|
| 290 |
-
.get_webview_window("main")
|
| 291 |
-
.expect("main window not found")
|
| 292 |
-
.show()
|
| 293 |
-
.ok();
|
| 294 |
-
});
|
| 295 |
-
Ok(())
|
| 296 |
-
})
|
| 297 |
-
.run(tauri::generate_context!())?;
|
| 298 |
-
|
| 299 |
Ok(())
|
| 300 |
}
|
|
|
|
| 1 |
+
use std::{path::PathBuf, sync::Arc};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
use anyhow::{Context, Result};
|
| 4 |
use clap::Parser;
|
|
|
|
| 11 |
use tracing_subscriber::fmt::format::FmtSpan;
|
| 12 |
|
| 13 |
use crate::{
|
| 14 |
+
llm, ml,
|
| 15 |
renderer::Renderer,
|
| 16 |
+
rpc::SharedResources,
|
| 17 |
server,
|
| 18 |
state::{AppState, State},
|
| 19 |
};
|
|
|
|
| 160 |
async fn prefetch() -> Result<()> {
|
| 161 |
ensure_dylibs(LIB_ROOT.to_path_buf()).await?;
|
| 162 |
ml::prefetch().await?;
|
|
|
|
|
|
|
| 163 |
|
| 164 |
Ok(())
|
| 165 |
}
|
| 166 |
|
| 167 |
+
async fn build_resources(cpu: bool) -> Result<AppResources> {
|
| 168 |
if cuda_is_available() {
|
| 169 |
ensure_dylibs(LIB_ROOT.to_path_buf())
|
| 170 |
.await
|
|
|
|
| 173 |
|
| 174 |
#[cfg(target_os = "windows")]
|
| 175 |
{
|
| 176 |
+
if let Err(err) = crate::windows::register_khr() {
|
| 177 |
tracing::warn!(?err, "Failed to register .khr file association");
|
| 178 |
}
|
| 179 |
|
|
|
|
| 230 |
}
|
| 231 |
});
|
| 232 |
|
| 233 |
+
let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0))).await?;
|
| 234 |
+
let ws_port = listener.local_addr()?.port();
|
| 235 |
+
let shared: SharedResources = Arc::new(tokio::sync::OnceCell::new());
|
| 236 |
|
| 237 |
+
let app = tauri::Builder::default()
|
| 238 |
+
.append_invoke_initialization_script(format!("window.__KOHARU_WS_PORT__ = {};", ws_port))
|
| 239 |
+
.build(tauri::generate_context!())?;
|
| 240 |
+
|
| 241 |
+
let resolver = Arc::new(app.asset_resolver());
|
| 242 |
+
tokio::spawn({
|
| 243 |
+
let shared = shared.clone();
|
| 244 |
+
async move {
|
| 245 |
+
if let Err(err) = server::serve_with_listener(listener, shared, resolver).await {
|
| 246 |
+
tracing::error!("Server error: {err:#}");
|
| 247 |
}
|
| 248 |
+
}
|
| 249 |
+
});
|
| 250 |
|
| 251 |
+
if headless {
|
| 252 |
+
let resources = build_resources(cpu).await?;
|
| 253 |
+
shared.set(resources).ok();
|
| 254 |
tokio::signal::ctrl_c().await?;
|
| 255 |
+
} else {
|
| 256 |
+
let handle = app.handle().clone();
|
| 257 |
+
tokio::spawn(async move {
|
| 258 |
+
let resources = build_resources(cpu)
|
| 259 |
+
.await
|
| 260 |
+
.expect("failed to build app resources");
|
| 261 |
+
shared.set(resources).ok();
|
| 262 |
+
|
| 263 |
+
handle
|
| 264 |
+
.get_webview_window("splashscreen")
|
| 265 |
+
.expect("splashscreen window not found")
|
| 266 |
+
.close()
|
| 267 |
+
.ok();
|
| 268 |
+
handle
|
| 269 |
+
.get_webview_window("main")
|
| 270 |
+
.expect("main window not found")
|
| 271 |
+
.show()
|
| 272 |
+
.ok();
|
| 273 |
+
});
|
| 274 |
+
app.run(|_, _| {});
|
| 275 |
}
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
Ok(())
|
| 278 |
}
|
koharu/src/command.rs
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 1 |
-
use std::sync::atomic::{AtomicU16, Ordering};
|
| 2 |
-
|
| 3 |
-
use tauri::State;
|
| 4 |
-
use tauri::ipc::Channel;
|
| 5 |
-
|
| 6 |
-
use crate::result::Result;
|
| 7 |
-
|
| 8 |
-
#[tauri::command]
|
| 9 |
-
pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
|
| 10 |
-
loop {
|
| 11 |
-
let p = port.load(Ordering::SeqCst);
|
| 12 |
-
if p != 0 {
|
| 13 |
-
return Ok(p);
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
| 17 |
-
}
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
#[tauri::command]
|
| 21 |
-
pub async fn download_progress(
|
| 22 |
-
channel: Channel<koharu_core::download::DownloadProgress>,
|
| 23 |
-
) -> Result<()> {
|
| 24 |
-
let mut rx = koharu_core::download::subscribe();
|
| 25 |
-
tokio::spawn(async move {
|
| 26 |
-
loop {
|
| 27 |
-
match rx.recv().await {
|
| 28 |
-
Ok(progress) => {
|
| 29 |
-
if channel.send(progress).is_err() {
|
| 30 |
-
break;
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
| 34 |
-
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
});
|
| 38 |
-
Ok(())
|
| 39 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
koharu/src/lib.rs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
pub mod app;
|
| 2 |
-
pub mod command;
|
| 3 |
-
pub mod endpoints;
|
| 4 |
pub mod image;
|
| 5 |
pub mod khr;
|
| 6 |
pub mod llm;
|
| 7 |
pub mod ml;
|
|
|
|
| 8 |
pub mod pipeline;
|
| 9 |
pub mod renderer;
|
| 10 |
pub mod result;
|
|
|
|
| 11 |
pub mod server;
|
| 12 |
pub mod state;
|
| 13 |
pub mod version;
|
|
|
|
| 1 |
pub mod app;
|
|
|
|
|
|
|
| 2 |
pub mod image;
|
| 3 |
pub mod khr;
|
| 4 |
pub mod llm;
|
| 5 |
pub mod ml;
|
| 6 |
+
pub mod operations;
|
| 7 |
pub mod pipeline;
|
| 8 |
pub mod renderer;
|
| 9 |
pub mod result;
|
| 10 |
+
pub mod rpc;
|
| 11 |
pub mod server;
|
| 12 |
pub mod state;
|
| 13 |
pub mod version;
|
koharu/src/{endpoints.rs → operations.rs}
RENAMED
|
@@ -1,20 +1,8 @@
|
|
| 1 |
use std::{io::Cursor, path::PathBuf, str::FromStr};
|
| 2 |
|
| 3 |
-
use std::convert::Infallible;
|
| 4 |
-
|
| 5 |
-
use axum::{
|
| 6 |
-
Json,
|
| 7 |
-
body::Body,
|
| 8 |
-
extract::{Multipart, State},
|
| 9 |
-
http::{HeaderValue, StatusCode, header},
|
| 10 |
-
response::{
|
| 11 |
-
Response,
|
| 12 |
-
sse::{Event, KeepAlive, Sse},
|
| 13 |
-
},
|
| 14 |
-
};
|
| 15 |
use image::{GenericImageView, ImageFormat, RgbaImage};
|
| 16 |
use koharu_ml::llm::ModelId;
|
| 17 |
-
use koharu_renderer::renderer::
|
| 18 |
use rayon::iter::{IntoParallelIterator, ParallelIterator};
|
| 19 |
use serde::{Deserialize, Serialize};
|
| 20 |
use strum::IntoEnumIterator;
|
|
@@ -26,27 +14,26 @@ use crate::{
|
|
| 26 |
image::SerializableDynamicImage,
|
| 27 |
khr::{deserialize_khr, has_khr_magic, serialize_khr},
|
| 28 |
llm,
|
| 29 |
-
result::Result,
|
| 30 |
state::{Document, TextBlock},
|
| 31 |
version,
|
| 32 |
};
|
| 33 |
|
| 34 |
-
pub async fn app_version(
|
| 35 |
-
Ok(
|
| 36 |
}
|
| 37 |
|
| 38 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 39 |
#[serde(rename_all = "camelCase")]
|
| 40 |
pub struct DeviceInfo {
|
| 41 |
pub ml_device: String,
|
| 42 |
-
pub wgpu: WgpuDeviceInfo,
|
| 43 |
}
|
| 44 |
|
| 45 |
-
pub async fn device(
|
| 46 |
-
Ok(
|
| 47 |
ml_device: state.ml_device.to_string(),
|
| 48 |
wgpu: state.renderer.wgpu_device_info(),
|
| 49 |
-
})
|
| 50 |
}
|
| 51 |
|
| 52 |
#[derive(Debug, Deserialize)]
|
|
@@ -55,14 +42,14 @@ pub struct OpenExternalPayload {
|
|
| 55 |
pub url: String,
|
| 56 |
}
|
| 57 |
|
| 58 |
-
pub
|
| 59 |
open::that(&payload.url)?;
|
| 60 |
-
Ok(
|
| 61 |
}
|
| 62 |
|
| 63 |
-
pub async fn get_documents(
|
| 64 |
let guard = state.state.read().await;
|
| 65 |
-
Ok(
|
| 66 |
}
|
| 67 |
|
| 68 |
#[derive(Debug, Deserialize)]
|
|
@@ -71,23 +58,28 @@ pub struct IndexPayload {
|
|
| 71 |
pub index: usize,
|
| 72 |
}
|
| 73 |
|
| 74 |
-
pub async fn get_document(
|
| 75 |
-
State(state): State<AppResources>,
|
| 76 |
-
Json(payload): Json<IndexPayload>,
|
| 77 |
-
) -> Result<Json<Document>> {
|
| 78 |
let guard = state.state.read().await;
|
| 79 |
-
|
| 80 |
.documents
|
| 81 |
.get(payload.index)
|
| 82 |
.cloned()
|
| 83 |
-
.ok_or_else(|| anyhow::anyhow!("Document not found at index {}", payload.index))
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
pub async fn get_thumbnail(
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
) -> Result<
|
| 91 |
let guard = state.state.read().await;
|
| 92 |
let doc = guard
|
| 93 |
.documents
|
|
@@ -100,43 +92,60 @@ pub async fn get_thumbnail(
|
|
| 100 |
let mut buf = Cursor::new(Vec::new());
|
| 101 |
thumbnail.write_to(&mut buf, ImageFormat::WebP)?;
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
.
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
|
| 110 |
pub async fn open_documents(
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
) -> Result<
|
| 114 |
-
let
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
.map(|s| s.to_string())
|
| 120 |
-
.unwrap_or_else(|| "unknown".to_string());
|
| 121 |
-
let bytes = field.bytes().await?.to_vec();
|
| 122 |
-
inputs.push((PathBuf::from(name), bytes));
|
| 123 |
-
}
|
| 124 |
|
| 125 |
if inputs.is_empty() {
|
| 126 |
-
|
| 127 |
}
|
| 128 |
|
| 129 |
let docs = load_documents(inputs)?;
|
| 130 |
let count = docs.len();
|
| 131 |
let mut guard = state.state.write().await;
|
| 132 |
guard.documents = docs;
|
| 133 |
-
Ok(
|
| 134 |
}
|
| 135 |
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
let guard = state.state.read().await;
|
| 138 |
if guard.documents.is_empty() {
|
| 139 |
-
|
| 140 |
}
|
| 141 |
|
| 142 |
let filename = if guard.documents.len() == 1 {
|
|
@@ -153,17 +162,17 @@ pub async fn save_documents(State(state): State<AppResources>) -> Result<Respons
|
|
| 153 |
let bytes = serialize_khr(&guard.documents)?;
|
| 154 |
drop(guard);
|
| 155 |
|
| 156 |
-
Ok(
|
| 157 |
-
|
| 158 |
-
bytes,
|
| 159 |
-
"application/octet-stream",
|
| 160 |
-
)
|
| 161 |
}
|
| 162 |
|
| 163 |
pub async fn export_document(
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
) -> Result<
|
| 167 |
let guard = state.state.read().await;
|
| 168 |
let document = guard
|
| 169 |
.documents
|
|
@@ -184,16 +193,18 @@ pub async fn export_document(
|
|
| 184 |
|
| 185 |
let bytes = encode_image(rendered, &ext)?;
|
| 186 |
let filename = format!("{}_koharu.{}", document.name, ext);
|
|
|
|
| 187 |
drop(guard);
|
| 188 |
|
| 189 |
-
Ok(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
#[instrument(level = "info", skip_all)]
|
| 193 |
-
pub async fn detect(
|
| 194 |
-
State(state): State<AppResources>,
|
| 195 |
-
Json(payload): Json<IndexPayload>,
|
| 196 |
-
) -> Result<Json<()>> {
|
| 197 |
let mut snapshot = {
|
| 198 |
let guard = state.state.read().await;
|
| 199 |
guard
|
|
@@ -211,14 +222,11 @@ pub async fn detect(
|
|
| 211 |
.get_mut(payload.index)
|
| 212 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 213 |
*document = snapshot;
|
| 214 |
-
Ok(
|
| 215 |
}
|
| 216 |
|
| 217 |
#[instrument(level = "info", skip_all)]
|
| 218 |
-
pub async fn ocr(
|
| 219 |
-
State(state): State<AppResources>,
|
| 220 |
-
Json(payload): Json<IndexPayload>,
|
| 221 |
-
) -> Result<Json<()>> {
|
| 222 |
let mut snapshot = {
|
| 223 |
let guard = state.state.read().await;
|
| 224 |
guard
|
|
@@ -236,14 +244,11 @@ pub async fn ocr(
|
|
| 236 |
.get_mut(payload.index)
|
| 237 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 238 |
*document = snapshot;
|
| 239 |
-
Ok(
|
| 240 |
}
|
| 241 |
|
| 242 |
#[instrument(level = "info", skip_all)]
|
| 243 |
-
pub async fn inpaint(
|
| 244 |
-
State(state): State<AppResources>,
|
| 245 |
-
Json(payload): Json<IndexPayload>,
|
| 246 |
-
) -> Result<Json<()>> {
|
| 247 |
let mut snapshot = {
|
| 248 |
let guard = state.state.read().await;
|
| 249 |
guard
|
|
@@ -261,21 +266,22 @@ pub async fn inpaint(
|
|
| 261 |
.get_mut(payload.index)
|
| 262 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 263 |
*document = snapshot;
|
| 264 |
-
Ok(
|
| 265 |
}
|
| 266 |
|
| 267 |
#[derive(Debug, Deserialize)]
|
| 268 |
#[serde(rename_all = "camelCase")]
|
| 269 |
pub struct UpdateInpaintMaskPayload {
|
| 270 |
pub index: usize,
|
|
|
|
| 271 |
pub mask: Vec<u8>,
|
| 272 |
pub region: Option<InpaintRegion>,
|
| 273 |
}
|
| 274 |
|
| 275 |
pub async fn update_inpaint_mask(
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
) -> Result<
|
| 279 |
let snapshot = {
|
| 280 |
let guard = state.state.read().await;
|
| 281 |
guard
|
|
@@ -298,13 +304,13 @@ pub async fn update_inpaint_mask(
|
|
| 298 |
Some(region) => {
|
| 299 |
let (patch_width, patch_height) = update_image.dimensions();
|
| 300 |
if patch_width != region.width || patch_height != region.height {
|
| 301 |
-
|
| 302 |
"Mask patch size mismatch: expected {}x{}, got {}x{}",
|
| 303 |
region.width,
|
| 304 |
region.height,
|
| 305 |
patch_width,
|
| 306 |
patch_height
|
| 307 |
-
)
|
| 308 |
}
|
| 309 |
|
| 310 |
let x0 = region.x.min(doc_width.saturating_sub(1));
|
|
@@ -313,7 +319,7 @@ pub async fn update_inpaint_mask(
|
|
| 313 |
let y1 = region.y.saturating_add(region.height).min(doc_height);
|
| 314 |
|
| 315 |
if x1 <= x0 || y1 <= y0 {
|
| 316 |
-
return Ok(
|
| 317 |
}
|
| 318 |
|
| 319 |
let patch_rgba = update_image.to_rgba8();
|
|
@@ -326,13 +332,13 @@ pub async fn update_inpaint_mask(
|
|
| 326 |
None => {
|
| 327 |
let (mask_width, mask_height) = update_image.dimensions();
|
| 328 |
if mask_width != doc_width || mask_height != doc_height {
|
| 329 |
-
|
| 330 |
"Mask size mismatch: expected {}x{}, got {}x{}",
|
| 331 |
doc_width,
|
| 332 |
doc_height,
|
| 333 |
mask_width,
|
| 334 |
mask_height
|
| 335 |
-
)
|
| 336 |
}
|
| 337 |
base_mask = update_image.to_rgba8();
|
| 338 |
}
|
|
@@ -347,21 +353,22 @@ pub async fn update_inpaint_mask(
|
|
| 347 |
.get_mut(payload.index)
|
| 348 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 349 |
*document = updated;
|
| 350 |
-
Ok(
|
| 351 |
}
|
| 352 |
|
| 353 |
#[derive(Debug, Deserialize)]
|
| 354 |
#[serde(rename_all = "camelCase")]
|
| 355 |
pub struct UpdateBrushLayerPayload {
|
| 356 |
pub index: usize,
|
|
|
|
| 357 |
pub patch: Vec<u8>,
|
| 358 |
pub region: InpaintRegion,
|
| 359 |
}
|
| 360 |
|
| 361 |
pub async fn update_brush_layer(
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
) -> Result<
|
| 365 |
let snapshot = {
|
| 366 |
let guard = state.state.read().await;
|
| 367 |
guard
|
|
@@ -373,20 +380,20 @@ pub async fn update_brush_layer(
|
|
| 373 |
|
| 374 |
let (img_width, img_height) = (snapshot.width, snapshot.height);
|
| 375 |
let Some((x0, y0, width, height)) = clamp_region(&payload.region, img_width, img_height) else {
|
| 376 |
-
return Ok(
|
| 377 |
};
|
| 378 |
|
| 379 |
let patch_image = image::load_from_memory(&payload.patch)?;
|
| 380 |
let (patch_width, patch_height) = patch_image.dimensions();
|
| 381 |
|
| 382 |
if patch_width != payload.region.width || patch_height != payload.region.height {
|
| 383 |
-
|
| 384 |
"Brush patch size mismatch: expected {}x{}, got {}x{}",
|
| 385 |
payload.region.width,
|
| 386 |
payload.region.height,
|
| 387 |
patch_width,
|
| 388 |
patch_height
|
| 389 |
-
)
|
| 390 |
}
|
| 391 |
|
| 392 |
let brush_rgba = patch_image.to_rgba8();
|
|
@@ -411,7 +418,7 @@ pub async fn update_brush_layer(
|
|
| 411 |
.get_mut(payload.index)
|
| 412 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 413 |
*document = updated;
|
| 414 |
-
Ok(
|
| 415 |
}
|
| 416 |
|
| 417 |
#[derive(Debug, Deserialize)]
|
|
@@ -423,9 +430,9 @@ pub struct InpaintPartialPayload {
|
|
| 423 |
|
| 424 |
#[instrument(level = "info", skip_all)]
|
| 425 |
pub async fn inpaint_partial(
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
) -> Result<
|
| 429 |
let snapshot = {
|
| 430 |
let guard = state.state.read().await;
|
| 431 |
guard
|
|
@@ -441,7 +448,7 @@ pub async fn inpaint_partial(
|
|
| 441 |
.ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
|
| 442 |
|
| 443 |
if payload.region.width == 0 || payload.region.height == 0 {
|
| 444 |
-
return Ok(
|
| 445 |
}
|
| 446 |
|
| 447 |
let (img_width, img_height) = (snapshot.width, snapshot.height);
|
|
@@ -461,7 +468,7 @@ pub async fn inpaint_partial(
|
|
| 461 |
let crop_height = y1.saturating_sub(y0);
|
| 462 |
|
| 463 |
if crop_width == 0 || crop_height == 0 {
|
| 464 |
-
return Ok(
|
| 465 |
}
|
| 466 |
|
| 467 |
let patch_x1 = x0 + crop_width;
|
|
@@ -476,7 +483,7 @@ pub async fn inpaint_partial(
|
|
| 476 |
});
|
| 477 |
|
| 478 |
if !overlaps_text {
|
| 479 |
-
return Ok(
|
| 480 |
}
|
| 481 |
|
| 482 |
let image_crop =
|
|
@@ -517,7 +524,7 @@ pub async fn inpaint_partial(
|
|
| 517 |
.get_mut(payload.index)
|
| 518 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 519 |
*document = updated;
|
| 520 |
-
Ok(
|
| 521 |
}
|
| 522 |
|
| 523 |
#[derive(Debug, Deserialize)]
|
|
@@ -529,10 +536,7 @@ pub struct RenderPayload {
|
|
| 529 |
}
|
| 530 |
|
| 531 |
#[instrument(level = "info", skip_all)]
|
| 532 |
-
pub async fn render(
|
| 533 |
-
State(state): State<AppResources>,
|
| 534 |
-
Json(payload): Json<RenderPayload>,
|
| 535 |
-
) -> Result<Json<()>> {
|
| 536 |
let snapshot = {
|
| 537 |
let guard = state.state.read().await;
|
| 538 |
guard
|
|
@@ -555,7 +559,7 @@ pub async fn render(
|
|
| 555 |
.get_mut(payload.index)
|
| 556 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 557 |
*document = updated;
|
| 558 |
-
Ok(
|
| 559 |
}
|
| 560 |
|
| 561 |
#[derive(Debug, Deserialize)]
|
|
@@ -566,23 +570,23 @@ pub struct UpdateTextBlocksPayload {
|
|
| 566 |
}
|
| 567 |
|
| 568 |
pub async fn update_text_blocks(
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
) -> Result<
|
| 572 |
let mut guard = state.state.write().await;
|
| 573 |
let document = guard
|
| 574 |
.documents
|
| 575 |
.get_mut(payload.index)
|
| 576 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 577 |
document.text_blocks = payload.text_blocks;
|
| 578 |
-
Ok(
|
| 579 |
}
|
| 580 |
|
| 581 |
-
pub async fn list_font_families(
|
| 582 |
-
|
| 583 |
}
|
| 584 |
|
| 585 |
-
pub async fn llm_list(
|
| 586 |
let mut models: Vec<ModelId> = ModelId::iter().collect();
|
| 587 |
let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 };
|
| 588 |
let zh_locale_factor = match get_locale().unwrap_or_default() {
|
|
@@ -602,7 +606,7 @@ pub async fn llm_list(State(state): State<AppResources>) -> Result<Json<Vec<llm:
|
|
| 602 |
ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
|
| 603 |
});
|
| 604 |
|
| 605 |
-
Ok(
|
| 606 |
}
|
| 607 |
|
| 608 |
#[derive(Debug, Deserialize)]
|
|
@@ -612,22 +616,19 @@ pub struct LlmLoadPayload {
|
|
| 612 |
}
|
| 613 |
|
| 614 |
#[instrument(level = "info", skip_all)]
|
| 615 |
-
pub async fn llm_load(
|
| 616 |
-
State(state): State<AppResources>,
|
| 617 |
-
Json(payload): Json<LlmLoadPayload>,
|
| 618 |
-
) -> Result<Json<()>> {
|
| 619 |
let id = ModelId::from_str(&payload.id)?;
|
| 620 |
state.llm.load(id).await;
|
| 621 |
-
Ok(
|
| 622 |
}
|
| 623 |
|
| 624 |
-
pub async fn llm_offload(
|
| 625 |
state.llm.offload().await;
|
| 626 |
-
Ok(
|
| 627 |
}
|
| 628 |
|
| 629 |
-
pub async fn llm_ready(
|
| 630 |
-
Ok(
|
| 631 |
}
|
| 632 |
|
| 633 |
#[derive(Debug, Deserialize)]
|
|
@@ -639,10 +640,7 @@ pub struct LlmGeneratePayload {
|
|
| 639 |
}
|
| 640 |
|
| 641 |
#[instrument(level = "info", skip_all)]
|
| 642 |
-
pub async fn llm_generate(
|
| 643 |
-
State(state): State<AppResources>,
|
| 644 |
-
Json(payload): Json<LlmGeneratePayload>,
|
| 645 |
-
) -> Result<Json<()>> {
|
| 646 |
let snapshot = {
|
| 647 |
let guard = state.state.read().await;
|
| 648 |
guard
|
|
@@ -677,10 +675,49 @@ pub async fn llm_generate(
|
|
| 677 |
.get_mut(payload.index)
|
| 678 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 679 |
*document = updated;
|
| 680 |
-
Ok(
|
| 681 |
}
|
| 682 |
|
| 683 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
|
| 685 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 686 |
#[serde(rename_all = "camelCase")]
|
|
@@ -715,21 +752,13 @@ fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result<V
|
|
| 715 |
Ok(buf)
|
| 716 |
}
|
| 717 |
|
| 718 |
-
fn
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
response
|
| 726 |
-
.headers_mut()
|
| 727 |
-
.insert(header::CONTENT_TYPE, HeaderValue::from_str(content_type)?);
|
| 728 |
-
response.headers_mut().insert(
|
| 729 |
-
header::CONTENT_DISPOSITION,
|
| 730 |
-
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))?,
|
| 731 |
-
);
|
| 732 |
-
Ok(response)
|
| 733 |
}
|
| 734 |
|
| 735 |
pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Document>> {
|
|
@@ -760,87 +789,7 @@ pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Doc
|
|
| 760 |
Ok(documents)
|
| 761 |
}
|
| 762 |
|
| 763 |
-
fn mime_from_ext(ext: &str) -> &'static str {
|
| 764 |
-
match ext {
|
| 765 |
-
"png" => "image/png",
|
| 766 |
-
"jpg" | "jpeg" => "image/jpeg",
|
| 767 |
-
"webp" => "image/webp",
|
| 768 |
-
_ => "application/octet-stream",
|
| 769 |
-
}
|
| 770 |
-
}
|
| 771 |
-
|
| 772 |
fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDynamicImage {
|
| 773 |
let blank = RgbaImage::from_pixel(width, height, color);
|
| 774 |
image::DynamicImage::ImageRgba8(blank).into()
|
| 775 |
}
|
| 776 |
-
|
| 777 |
-
pub async fn download_progress()
|
| 778 |
-
-> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
|
| 779 |
-
let rx = koharu_core::download::subscribe();
|
| 780 |
-
let stream = futures::stream::unfold(rx, |mut rx| async {
|
| 781 |
-
loop {
|
| 782 |
-
match rx.recv().await {
|
| 783 |
-
Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
|
| 784 |
-
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
| 785 |
-
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
| 786 |
-
}
|
| 787 |
-
}
|
| 788 |
-
});
|
| 789 |
-
Sse::new(stream).keep_alive(KeepAlive::default())
|
| 790 |
-
}
|
| 791 |
-
|
| 792 |
-
// --- Auto-processing pipeline endpoints ---
|
| 793 |
-
|
| 794 |
-
/// Start the processing pipeline. Returns immediately; progress is available
|
| 795 |
-
/// via the `process_progress` SSE endpoint.
|
| 796 |
-
pub async fn process(
|
| 797 |
-
State(state): State<AppResources>,
|
| 798 |
-
Json(payload): Json<crate::pipeline::ProcessRequest>,
|
| 799 |
-
) -> Result<Json<()>> {
|
| 800 |
-
{
|
| 801 |
-
let guard = state.pipeline.read().await;
|
| 802 |
-
if guard.is_some() {
|
| 803 |
-
Err(anyhow::anyhow!("A processing pipeline is already running"))?;
|
| 804 |
-
}
|
| 805 |
-
}
|
| 806 |
-
|
| 807 |
-
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
| 808 |
-
{
|
| 809 |
-
let mut guard = state.pipeline.write().await;
|
| 810 |
-
*guard = Some(crate::pipeline::PipelineHandle {
|
| 811 |
-
cancel: cancel.clone(),
|
| 812 |
-
});
|
| 813 |
-
}
|
| 814 |
-
|
| 815 |
-
let resources = state.clone();
|
| 816 |
-
tokio::spawn(async move {
|
| 817 |
-
crate::pipeline::run_pipeline(resources, payload, cancel).await;
|
| 818 |
-
});
|
| 819 |
-
|
| 820 |
-
Ok(Json(()))
|
| 821 |
-
}
|
| 822 |
-
|
| 823 |
-
pub async fn process_progress()
|
| 824 |
-
-> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
|
| 825 |
-
let rx = crate::pipeline::subscribe();
|
| 826 |
-
let stream = futures::stream::unfold(rx, |mut rx| async {
|
| 827 |
-
loop {
|
| 828 |
-
match rx.recv().await {
|
| 829 |
-
Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
|
| 830 |
-
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
| 831 |
-
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
| 832 |
-
}
|
| 833 |
-
}
|
| 834 |
-
});
|
| 835 |
-
Sse::new(stream).keep_alive(KeepAlive::default())
|
| 836 |
-
}
|
| 837 |
-
|
| 838 |
-
pub async fn process_cancel(State(state): State<AppResources>) -> Result<Json<()>> {
|
| 839 |
-
let guard = state.pipeline.read().await;
|
| 840 |
-
if let Some(handle) = guard.as_ref() {
|
| 841 |
-
handle
|
| 842 |
-
.cancel
|
| 843 |
-
.store(true, std::sync::atomic::Ordering::Relaxed);
|
| 844 |
-
}
|
| 845 |
-
Ok(Json(()))
|
| 846 |
-
}
|
|
|
|
| 1 |
use std::{io::Cursor, path::PathBuf, str::FromStr};
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
use image::{GenericImageView, ImageFormat, RgbaImage};
|
| 4 |
use koharu_ml::llm::ModelId;
|
| 5 |
+
use koharu_renderer::renderer::TextShaderEffect;
|
| 6 |
use rayon::iter::{IntoParallelIterator, ParallelIterator};
|
| 7 |
use serde::{Deserialize, Serialize};
|
| 8 |
use strum::IntoEnumIterator;
|
|
|
|
| 14 |
image::SerializableDynamicImage,
|
| 15 |
khr::{deserialize_khr, has_khr_magic, serialize_khr},
|
| 16 |
llm,
|
|
|
|
| 17 |
state::{Document, TextBlock},
|
| 18 |
version,
|
| 19 |
};
|
| 20 |
|
| 21 |
+
pub async fn app_version(_state: AppResources) -> anyhow::Result<String> {
|
| 22 |
+
Ok(version::current().to_string())
|
| 23 |
}
|
| 24 |
|
| 25 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 26 |
#[serde(rename_all = "camelCase")]
|
| 27 |
pub struct DeviceInfo {
|
| 28 |
pub ml_device: String,
|
| 29 |
+
pub wgpu: koharu_renderer::renderer::WgpuDeviceInfo,
|
| 30 |
}
|
| 31 |
|
| 32 |
+
pub async fn device(state: AppResources) -> anyhow::Result<DeviceInfo> {
|
| 33 |
+
Ok(DeviceInfo {
|
| 34 |
ml_device: state.ml_device.to_string(),
|
| 35 |
wgpu: state.renderer.wgpu_device_info(),
|
| 36 |
+
})
|
| 37 |
}
|
| 38 |
|
| 39 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 42 |
pub url: String,
|
| 43 |
}
|
| 44 |
|
| 45 |
+
pub fn open_external(payload: OpenExternalPayload) -> anyhow::Result<()> {
|
| 46 |
open::that(&payload.url)?;
|
| 47 |
+
Ok(())
|
| 48 |
}
|
| 49 |
|
| 50 |
+
pub async fn get_documents(state: AppResources) -> anyhow::Result<usize> {
|
| 51 |
let guard = state.state.read().await;
|
| 52 |
+
Ok(guard.documents.len())
|
| 53 |
}
|
| 54 |
|
| 55 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 58 |
pub index: usize,
|
| 59 |
}
|
| 60 |
|
| 61 |
+
pub async fn get_document(state: AppResources, payload: IndexPayload) -> anyhow::Result<Document> {
|
|
|
|
|
|
|
|
|
|
| 62 |
let guard = state.state.read().await;
|
| 63 |
+
guard
|
| 64 |
.documents
|
| 65 |
.get(payload.index)
|
| 66 |
.cloned()
|
| 67 |
+
.ok_or_else(|| anyhow::anyhow!("Document not found at index {}", payload.index))
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/// Returns WebP-encoded thumbnail bytes.
|
| 71 |
+
#[derive(Debug, Serialize)]
|
| 72 |
+
#[serde(rename_all = "camelCase")]
|
| 73 |
+
pub struct ThumbnailResult {
|
| 74 |
+
#[serde(with = "serde_bytes")]
|
| 75 |
+
pub data: Vec<u8>,
|
| 76 |
+
pub content_type: String,
|
| 77 |
}
|
| 78 |
|
| 79 |
pub async fn get_thumbnail(
|
| 80 |
+
state: AppResources,
|
| 81 |
+
payload: IndexPayload,
|
| 82 |
+
) -> anyhow::Result<ThumbnailResult> {
|
| 83 |
let guard = state.state.read().await;
|
| 84 |
let doc = guard
|
| 85 |
.documents
|
|
|
|
| 92 |
let mut buf = Cursor::new(Vec::new());
|
| 93 |
thumbnail.write_to(&mut buf, ImageFormat::WebP)?;
|
| 94 |
|
| 95 |
+
Ok(ThumbnailResult {
|
| 96 |
+
data: buf.into_inner(),
|
| 97 |
+
content_type: "image/webp".to_string(),
|
| 98 |
+
})
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#[derive(Debug, Deserialize)]
|
| 102 |
+
#[serde(rename_all = "camelCase")]
|
| 103 |
+
pub struct FileEntry {
|
| 104 |
+
pub name: String,
|
| 105 |
+
#[serde(with = "serde_bytes")]
|
| 106 |
+
pub data: Vec<u8>,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
#[derive(Debug, Deserialize)]
|
| 110 |
+
#[serde(rename_all = "camelCase")]
|
| 111 |
+
pub struct OpenDocumentsPayload {
|
| 112 |
+
pub files: Vec<FileEntry>,
|
| 113 |
}
|
| 114 |
|
| 115 |
pub async fn open_documents(
|
| 116 |
+
state: AppResources,
|
| 117 |
+
payload: OpenDocumentsPayload,
|
| 118 |
+
) -> anyhow::Result<usize> {
|
| 119 |
+
let inputs: Vec<(PathBuf, Vec<u8>)> = payload
|
| 120 |
+
.files
|
| 121 |
+
.into_iter()
|
| 122 |
+
.map(|f| (PathBuf::from(f.name), f.data))
|
| 123 |
+
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
if inputs.is_empty() {
|
| 126 |
+
anyhow::bail!("No files uploaded");
|
| 127 |
}
|
| 128 |
|
| 129 |
let docs = load_documents(inputs)?;
|
| 130 |
let count = docs.len();
|
| 131 |
let mut guard = state.state.write().await;
|
| 132 |
guard.documents = docs;
|
| 133 |
+
Ok(count)
|
| 134 |
}
|
| 135 |
|
| 136 |
+
#[derive(Debug, Serialize)]
|
| 137 |
+
#[serde(rename_all = "camelCase")]
|
| 138 |
+
pub struct FileResult {
|
| 139 |
+
pub filename: String,
|
| 140 |
+
#[serde(with = "serde_bytes")]
|
| 141 |
+
pub data: Vec<u8>,
|
| 142 |
+
pub content_type: String,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
pub async fn save_documents(state: AppResources) -> anyhow::Result<FileResult> {
|
| 146 |
let guard = state.state.read().await;
|
| 147 |
if guard.documents.is_empty() {
|
| 148 |
+
anyhow::bail!("No documents to save");
|
| 149 |
}
|
| 150 |
|
| 151 |
let filename = if guard.documents.len() == 1 {
|
|
|
|
| 162 |
let bytes = serialize_khr(&guard.documents)?;
|
| 163 |
drop(guard);
|
| 164 |
|
| 165 |
+
Ok(FileResult {
|
| 166 |
+
filename,
|
| 167 |
+
data: bytes,
|
| 168 |
+
content_type: "application/octet-stream".to_string(),
|
| 169 |
+
})
|
| 170 |
}
|
| 171 |
|
| 172 |
pub async fn export_document(
|
| 173 |
+
state: AppResources,
|
| 174 |
+
payload: IndexPayload,
|
| 175 |
+
) -> anyhow::Result<FileResult> {
|
| 176 |
let guard = state.state.read().await;
|
| 177 |
let document = guard
|
| 178 |
.documents
|
|
|
|
| 193 |
|
| 194 |
let bytes = encode_image(rendered, &ext)?;
|
| 195 |
let filename = format!("{}_koharu.{}", document.name, ext);
|
| 196 |
+
let content_type = mime_from_ext(&ext).to_string();
|
| 197 |
drop(guard);
|
| 198 |
|
| 199 |
+
Ok(FileResult {
|
| 200 |
+
filename,
|
| 201 |
+
data: bytes,
|
| 202 |
+
content_type,
|
| 203 |
+
})
|
| 204 |
}
|
| 205 |
|
| 206 |
#[instrument(level = "info", skip_all)]
|
| 207 |
+
pub async fn detect(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 208 |
let mut snapshot = {
|
| 209 |
let guard = state.state.read().await;
|
| 210 |
guard
|
|
|
|
| 222 |
.get_mut(payload.index)
|
| 223 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 224 |
*document = snapshot;
|
| 225 |
+
Ok(())
|
| 226 |
}
|
| 227 |
|
| 228 |
#[instrument(level = "info", skip_all)]
|
| 229 |
+
pub async fn ocr(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 230 |
let mut snapshot = {
|
| 231 |
let guard = state.state.read().await;
|
| 232 |
guard
|
|
|
|
| 244 |
.get_mut(payload.index)
|
| 245 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 246 |
*document = snapshot;
|
| 247 |
+
Ok(())
|
| 248 |
}
|
| 249 |
|
| 250 |
#[instrument(level = "info", skip_all)]
|
| 251 |
+
pub async fn inpaint(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 252 |
let mut snapshot = {
|
| 253 |
let guard = state.state.read().await;
|
| 254 |
guard
|
|
|
|
| 266 |
.get_mut(payload.index)
|
| 267 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 268 |
*document = snapshot;
|
| 269 |
+
Ok(())
|
| 270 |
}
|
| 271 |
|
| 272 |
#[derive(Debug, Deserialize)]
|
| 273 |
#[serde(rename_all = "camelCase")]
|
| 274 |
pub struct UpdateInpaintMaskPayload {
|
| 275 |
pub index: usize,
|
| 276 |
+
#[serde(with = "serde_bytes")]
|
| 277 |
pub mask: Vec<u8>,
|
| 278 |
pub region: Option<InpaintRegion>,
|
| 279 |
}
|
| 280 |
|
| 281 |
pub async fn update_inpaint_mask(
|
| 282 |
+
state: AppResources,
|
| 283 |
+
payload: UpdateInpaintMaskPayload,
|
| 284 |
+
) -> anyhow::Result<()> {
|
| 285 |
let snapshot = {
|
| 286 |
let guard = state.state.read().await;
|
| 287 |
guard
|
|
|
|
| 304 |
Some(region) => {
|
| 305 |
let (patch_width, patch_height) = update_image.dimensions();
|
| 306 |
if patch_width != region.width || patch_height != region.height {
|
| 307 |
+
anyhow::bail!(
|
| 308 |
"Mask patch size mismatch: expected {}x{}, got {}x{}",
|
| 309 |
region.width,
|
| 310 |
region.height,
|
| 311 |
patch_width,
|
| 312 |
patch_height
|
| 313 |
+
);
|
| 314 |
}
|
| 315 |
|
| 316 |
let x0 = region.x.min(doc_width.saturating_sub(1));
|
|
|
|
| 319 |
let y1 = region.y.saturating_add(region.height).min(doc_height);
|
| 320 |
|
| 321 |
if x1 <= x0 || y1 <= y0 {
|
| 322 |
+
return Ok(());
|
| 323 |
}
|
| 324 |
|
| 325 |
let patch_rgba = update_image.to_rgba8();
|
|
|
|
| 332 |
None => {
|
| 333 |
let (mask_width, mask_height) = update_image.dimensions();
|
| 334 |
if mask_width != doc_width || mask_height != doc_height {
|
| 335 |
+
anyhow::bail!(
|
| 336 |
"Mask size mismatch: expected {}x{}, got {}x{}",
|
| 337 |
doc_width,
|
| 338 |
doc_height,
|
| 339 |
mask_width,
|
| 340 |
mask_height
|
| 341 |
+
);
|
| 342 |
}
|
| 343 |
base_mask = update_image.to_rgba8();
|
| 344 |
}
|
|
|
|
| 353 |
.get_mut(payload.index)
|
| 354 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 355 |
*document = updated;
|
| 356 |
+
Ok(())
|
| 357 |
}
|
| 358 |
|
| 359 |
#[derive(Debug, Deserialize)]
|
| 360 |
#[serde(rename_all = "camelCase")]
|
| 361 |
pub struct UpdateBrushLayerPayload {
|
| 362 |
pub index: usize,
|
| 363 |
+
#[serde(with = "serde_bytes")]
|
| 364 |
pub patch: Vec<u8>,
|
| 365 |
pub region: InpaintRegion,
|
| 366 |
}
|
| 367 |
|
| 368 |
pub async fn update_brush_layer(
|
| 369 |
+
state: AppResources,
|
| 370 |
+
payload: UpdateBrushLayerPayload,
|
| 371 |
+
) -> anyhow::Result<()> {
|
| 372 |
let snapshot = {
|
| 373 |
let guard = state.state.read().await;
|
| 374 |
guard
|
|
|
|
| 380 |
|
| 381 |
let (img_width, img_height) = (snapshot.width, snapshot.height);
|
| 382 |
let Some((x0, y0, width, height)) = clamp_region(&payload.region, img_width, img_height) else {
|
| 383 |
+
return Ok(());
|
| 384 |
};
|
| 385 |
|
| 386 |
let patch_image = image::load_from_memory(&payload.patch)?;
|
| 387 |
let (patch_width, patch_height) = patch_image.dimensions();
|
| 388 |
|
| 389 |
if patch_width != payload.region.width || patch_height != payload.region.height {
|
| 390 |
+
anyhow::bail!(
|
| 391 |
"Brush patch size mismatch: expected {}x{}, got {}x{}",
|
| 392 |
payload.region.width,
|
| 393 |
payload.region.height,
|
| 394 |
patch_width,
|
| 395 |
patch_height
|
| 396 |
+
);
|
| 397 |
}
|
| 398 |
|
| 399 |
let brush_rgba = patch_image.to_rgba8();
|
|
|
|
| 418 |
.get_mut(payload.index)
|
| 419 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 420 |
*document = updated;
|
| 421 |
+
Ok(())
|
| 422 |
}
|
| 423 |
|
| 424 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 430 |
|
| 431 |
#[instrument(level = "info", skip_all)]
|
| 432 |
pub async fn inpaint_partial(
|
| 433 |
+
state: AppResources,
|
| 434 |
+
payload: InpaintPartialPayload,
|
| 435 |
+
) -> anyhow::Result<()> {
|
| 436 |
let snapshot = {
|
| 437 |
let guard = state.state.read().await;
|
| 438 |
guard
|
|
|
|
| 448 |
.ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
|
| 449 |
|
| 450 |
if payload.region.width == 0 || payload.region.height == 0 {
|
| 451 |
+
return Ok(());
|
| 452 |
}
|
| 453 |
|
| 454 |
let (img_width, img_height) = (snapshot.width, snapshot.height);
|
|
|
|
| 468 |
let crop_height = y1.saturating_sub(y0);
|
| 469 |
|
| 470 |
if crop_width == 0 || crop_height == 0 {
|
| 471 |
+
return Ok(());
|
| 472 |
}
|
| 473 |
|
| 474 |
let patch_x1 = x0 + crop_width;
|
|
|
|
| 483 |
});
|
| 484 |
|
| 485 |
if !overlaps_text {
|
| 486 |
+
return Ok(());
|
| 487 |
}
|
| 488 |
|
| 489 |
let image_crop =
|
|
|
|
| 524 |
.get_mut(payload.index)
|
| 525 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 526 |
*document = updated;
|
| 527 |
+
Ok(())
|
| 528 |
}
|
| 529 |
|
| 530 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 536 |
}
|
| 537 |
|
| 538 |
#[instrument(level = "info", skip_all)]
|
| 539 |
+
pub async fn render(state: AppResources, payload: RenderPayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 540 |
let snapshot = {
|
| 541 |
let guard = state.state.read().await;
|
| 542 |
guard
|
|
|
|
| 559 |
.get_mut(payload.index)
|
| 560 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 561 |
*document = updated;
|
| 562 |
+
Ok(())
|
| 563 |
}
|
| 564 |
|
| 565 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 570 |
}
|
| 571 |
|
| 572 |
pub async fn update_text_blocks(
|
| 573 |
+
state: AppResources,
|
| 574 |
+
payload: UpdateTextBlocksPayload,
|
| 575 |
+
) -> anyhow::Result<()> {
|
| 576 |
let mut guard = state.state.write().await;
|
| 577 |
let document = guard
|
| 578 |
.documents
|
| 579 |
.get_mut(payload.index)
|
| 580 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 581 |
document.text_blocks = payload.text_blocks;
|
| 582 |
+
Ok(())
|
| 583 |
}
|
| 584 |
|
| 585 |
+
pub async fn list_font_families(state: AppResources) -> anyhow::Result<Vec<String>> {
|
| 586 |
+
state.renderer.available_fonts()
|
| 587 |
}
|
| 588 |
|
| 589 |
+
pub async fn llm_list(state: AppResources) -> anyhow::Result<Vec<llm::ModelInfo>> {
|
| 590 |
let mut models: Vec<ModelId> = ModelId::iter().collect();
|
| 591 |
let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 };
|
| 592 |
let zh_locale_factor = match get_locale().unwrap_or_default() {
|
|
|
|
| 606 |
ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
|
| 607 |
});
|
| 608 |
|
| 609 |
+
Ok(models.into_iter().map(llm::ModelInfo::new).collect())
|
| 610 |
}
|
| 611 |
|
| 612 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 616 |
}
|
| 617 |
|
| 618 |
#[instrument(level = "info", skip_all)]
|
| 619 |
+
pub async fn llm_load(state: AppResources, payload: LlmLoadPayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 620 |
let id = ModelId::from_str(&payload.id)?;
|
| 621 |
state.llm.load(id).await;
|
| 622 |
+
Ok(())
|
| 623 |
}
|
| 624 |
|
| 625 |
+
pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> {
|
| 626 |
state.llm.offload().await;
|
| 627 |
+
Ok(())
|
| 628 |
}
|
| 629 |
|
| 630 |
+
pub async fn llm_ready(state: AppResources) -> anyhow::Result<bool> {
|
| 631 |
+
Ok(state.llm.ready().await)
|
| 632 |
}
|
| 633 |
|
| 634 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 640 |
}
|
| 641 |
|
| 642 |
#[instrument(level = "info", skip_all)]
|
| 643 |
+
pub async fn llm_generate(state: AppResources, payload: LlmGeneratePayload) -> anyhow::Result<()> {
|
|
|
|
|
|
|
|
|
|
| 644 |
let snapshot = {
|
| 645 |
let guard = state.state.read().await;
|
| 646 |
guard
|
|
|
|
| 675 |
.get_mut(payload.index)
|
| 676 |
.ok_or_else(|| anyhow::anyhow!("Document not found"))?;
|
| 677 |
*document = updated;
|
| 678 |
+
Ok(())
|
| 679 |
}
|
| 680 |
|
| 681 |
+
// --- Auto-processing pipeline endpoints ---
|
| 682 |
+
|
| 683 |
+
pub async fn process(
|
| 684 |
+
state: AppResources,
|
| 685 |
+
payload: crate::pipeline::ProcessRequest,
|
| 686 |
+
) -> anyhow::Result<()> {
|
| 687 |
+
{
|
| 688 |
+
let guard = state.pipeline.read().await;
|
| 689 |
+
if guard.is_some() {
|
| 690 |
+
anyhow::bail!("A processing pipeline is already running");
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
| 695 |
+
{
|
| 696 |
+
let mut guard = state.pipeline.write().await;
|
| 697 |
+
*guard = Some(crate::pipeline::PipelineHandle {
|
| 698 |
+
cancel: cancel.clone(),
|
| 699 |
+
});
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
let resources = state.clone();
|
| 703 |
+
tokio::spawn(async move {
|
| 704 |
+
crate::pipeline::run_pipeline(resources, payload, cancel).await;
|
| 705 |
+
});
|
| 706 |
+
|
| 707 |
+
Ok(())
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> {
|
| 711 |
+
let guard = state.pipeline.read().await;
|
| 712 |
+
if let Some(handle) = guard.as_ref() {
|
| 713 |
+
handle
|
| 714 |
+
.cancel
|
| 715 |
+
.store(true, std::sync::atomic::Ordering::Relaxed);
|
| 716 |
+
}
|
| 717 |
+
Ok(())
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
// --- Helpers ---
|
| 721 |
|
| 722 |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 723 |
#[serde(rename_all = "camelCase")]
|
|
|
|
| 752 |
Ok(buf)
|
| 753 |
}
|
| 754 |
|
| 755 |
+
fn mime_from_ext(ext: &str) -> &'static str {
|
| 756 |
+
match ext {
|
| 757 |
+
"png" => "image/png",
|
| 758 |
+
"jpg" | "jpeg" => "image/jpeg",
|
| 759 |
+
"webp" => "image/webp",
|
| 760 |
+
_ => "application/octet-stream",
|
| 761 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
}
|
| 763 |
|
| 764 |
pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Document>> {
|
|
|
|
| 789 |
Ok(documents)
|
| 790 |
}
|
| 791 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDynamicImage {
|
| 793 |
let blank = RgbaImage::from_pixel(width, height, color);
|
| 794 |
image::DynamicImage::ImageRgba8(blank).into()
|
| 795 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
koharu/src/result.rs
CHANGED
|
@@ -14,9 +14,6 @@ pub enum CommandError {
|
|
| 14 |
|
| 15 |
#[error(transparent)]
|
| 16 |
Image(#[from] image::ImageError),
|
| 17 |
-
|
| 18 |
-
#[error(transparent)]
|
| 19 |
-
Multipart(#[from] axum::extract::multipart::MultipartError),
|
| 20 |
}
|
| 21 |
|
| 22 |
impl serde::Serialize for CommandError {
|
|
|
|
| 14 |
|
| 15 |
#[error(transparent)]
|
| 16 |
Image(#[from] image::ImageError),
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
impl serde::Serialize for CommandError {
|
koharu/src/rpc.rs
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::sync::Arc;
|
| 2 |
+
use std::time::Duration;
|
| 3 |
+
|
| 4 |
+
use anyhow::Result;
|
| 5 |
+
use async_trait::async_trait;
|
| 6 |
+
use axum::{
|
| 7 |
+
extract::{
|
| 8 |
+
State,
|
| 9 |
+
ws::{Message, WebSocket, WebSocketUpgrade},
|
| 10 |
+
},
|
| 11 |
+
response::IntoResponse,
|
| 12 |
+
};
|
| 13 |
+
use futures::{SinkExt, StreamExt};
|
| 14 |
+
use serde::{Deserialize, Serialize};
|
| 15 |
+
use tokio::sync::{broadcast, mpsc};
|
| 16 |
+
use tower::{Service, ServiceBuilder, ServiceExt, service_fn, timeout::TimeoutLayer};
|
| 17 |
+
|
| 18 |
+
use crate::app::AppResources;
|
| 19 |
+
use crate::operations;
|
| 20 |
+
|
| 21 |
+
// --- Shared Resources (lazy init) ---
|
| 22 |
+
|
| 23 |
+
pub type SharedResources = Arc<tokio::sync::OnceCell<AppResources>>;
|
| 24 |
+
|
| 25 |
+
// --- Wire Protocol ---
|
| 26 |
+
|
| 27 |
+
#[derive(Debug, Deserialize)]
|
| 28 |
+
struct RawIncoming {
|
| 29 |
+
id: u32,
|
| 30 |
+
method: String,
|
| 31 |
+
params: Option<rmpv::Value>,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
#[derive(Debug, Serialize)]
|
| 35 |
+
#[serde(tag = "type")]
|
| 36 |
+
pub enum OutgoingMessage {
|
| 37 |
+
#[serde(rename = "res")]
|
| 38 |
+
Response {
|
| 39 |
+
id: u32,
|
| 40 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 41 |
+
result: Option<rmpv::Value>,
|
| 42 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 43 |
+
error: Option<String>,
|
| 44 |
+
},
|
| 45 |
+
#[serde(rename = "ntf")]
|
| 46 |
+
Notification { method: String, params: rmpv::Value },
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
fn ok_response(id: u32, result: rmpv::Value) -> OutgoingMessage {
|
| 50 |
+
OutgoingMessage::Response {
|
| 51 |
+
id,
|
| 52 |
+
result: Some(result),
|
| 53 |
+
error: None,
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
fn err_response(id: u32, msg: &str) -> OutgoingMessage {
|
| 58 |
+
OutgoingMessage::Response {
|
| 59 |
+
id,
|
| 60 |
+
result: None,
|
| 61 |
+
error: Some(msg.to_string()),
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// --- Value conversion helpers ---
|
| 66 |
+
|
| 67 |
+
fn to_value<T: Serialize>(val: &T) -> Result<rmpv::Value> {
|
| 68 |
+
let bytes = rmp_serde::to_vec_named(val)?;
|
| 69 |
+
Ok(rmp_serde::from_slice(&bytes)?)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
fn from_value<T: serde::de::DeserializeOwned>(val: rmpv::Value) -> Result<T> {
|
| 73 |
+
let bytes = rmp_serde::to_vec_named(&val)?;
|
| 74 |
+
Ok(rmp_serde::from_slice(&bytes)?)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// --- Handler Trait ---
|
| 78 |
+
|
| 79 |
+
#[async_trait]
|
| 80 |
+
#[enum_dispatch::enum_dispatch]
|
| 81 |
+
pub trait RpcCall {
|
| 82 |
+
async fn call(self, res: AppResources) -> Result<rmpv::Value>;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// --- Handler Structs ---
|
| 86 |
+
|
| 87 |
+
// No-param handlers
|
| 88 |
+
|
| 89 |
+
#[derive(Deserialize)]
|
| 90 |
+
pub struct AppVersionHandler;
|
| 91 |
+
|
| 92 |
+
#[async_trait]
|
| 93 |
+
impl RpcCall for AppVersionHandler {
|
| 94 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 95 |
+
let v = operations::app_version(state).await?;
|
| 96 |
+
to_value(&v)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
#[derive(Deserialize)]
|
| 101 |
+
pub struct DeviceHandler;
|
| 102 |
+
|
| 103 |
+
#[async_trait]
|
| 104 |
+
impl RpcCall for DeviceHandler {
|
| 105 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 106 |
+
let v = operations::device(state).await?;
|
| 107 |
+
to_value(&v)
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#[derive(Deserialize)]
|
| 112 |
+
pub struct GetDocumentsHandler;
|
| 113 |
+
|
| 114 |
+
#[async_trait]
|
| 115 |
+
impl RpcCall for GetDocumentsHandler {
|
| 116 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 117 |
+
let v = operations::get_documents(state).await?;
|
| 118 |
+
to_value(&v)
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#[derive(Deserialize)]
|
| 123 |
+
pub struct ListFontFamiliesHandler;
|
| 124 |
+
|
| 125 |
+
#[async_trait]
|
| 126 |
+
impl RpcCall for ListFontFamiliesHandler {
|
| 127 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 128 |
+
let v = operations::list_font_families(state).await?;
|
| 129 |
+
to_value(&v)
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
#[derive(Deserialize)]
|
| 134 |
+
pub struct LlmListHandler;
|
| 135 |
+
|
| 136 |
+
#[async_trait]
|
| 137 |
+
impl RpcCall for LlmListHandler {
|
| 138 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 139 |
+
let v = operations::llm_list(state).await?;
|
| 140 |
+
to_value(&v)
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
#[derive(Deserialize)]
|
| 145 |
+
pub struct LlmOffloadHandler;
|
| 146 |
+
|
| 147 |
+
#[async_trait]
|
| 148 |
+
impl RpcCall for LlmOffloadHandler {
|
| 149 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 150 |
+
operations::llm_offload(state).await?;
|
| 151 |
+
Ok(rmpv::Value::Nil)
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[derive(Deserialize)]
|
| 156 |
+
pub struct LlmReadyHandler;
|
| 157 |
+
|
| 158 |
+
#[async_trait]
|
| 159 |
+
impl RpcCall for LlmReadyHandler {
|
| 160 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 161 |
+
let v = operations::llm_ready(state).await?;
|
| 162 |
+
to_value(&v)
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
#[derive(Deserialize)]
|
| 167 |
+
pub struct SaveDocumentsHandler;
|
| 168 |
+
|
| 169 |
+
#[async_trait]
|
| 170 |
+
impl RpcCall for SaveDocumentsHandler {
|
| 171 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 172 |
+
let v = operations::save_documents(state).await?;
|
| 173 |
+
to_value(&v)
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
#[derive(Deserialize)]
|
| 178 |
+
pub struct ProcessCancelHandler;
|
| 179 |
+
|
| 180 |
+
#[async_trait]
|
| 181 |
+
impl RpcCall for ProcessCancelHandler {
|
| 182 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 183 |
+
operations::process_cancel(state).await?;
|
| 184 |
+
Ok(rmpv::Value::Nil)
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// Index-based handlers (wrapping IndexPayload)
|
| 189 |
+
|
| 190 |
+
#[derive(Deserialize)]
|
| 191 |
+
#[serde(transparent)]
|
| 192 |
+
pub struct GetDocumentHandler(operations::IndexPayload);
|
| 193 |
+
|
| 194 |
+
#[async_trait]
|
| 195 |
+
impl RpcCall for GetDocumentHandler {
|
| 196 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 197 |
+
let v = operations::get_document(state, self.0).await?;
|
| 198 |
+
to_value(&v)
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
#[derive(Deserialize)]
|
| 203 |
+
#[serde(transparent)]
|
| 204 |
+
pub struct GetThumbnailHandler(operations::IndexPayload);
|
| 205 |
+
|
| 206 |
+
#[async_trait]
|
| 207 |
+
impl RpcCall for GetThumbnailHandler {
|
| 208 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 209 |
+
let v = operations::get_thumbnail(state, self.0).await?;
|
| 210 |
+
to_value(&v)
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
#[derive(Deserialize)]
|
| 215 |
+
#[serde(transparent)]
|
| 216 |
+
pub struct ExportDocumentHandler(operations::IndexPayload);
|
| 217 |
+
|
| 218 |
+
#[async_trait]
|
| 219 |
+
impl RpcCall for ExportDocumentHandler {
|
| 220 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 221 |
+
let v = operations::export_document(state, self.0).await?;
|
| 222 |
+
to_value(&v)
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
#[derive(Deserialize)]
|
| 227 |
+
#[serde(transparent)]
|
| 228 |
+
pub struct DetectHandler(operations::IndexPayload);
|
| 229 |
+
|
| 230 |
+
#[async_trait]
|
| 231 |
+
impl RpcCall for DetectHandler {
|
| 232 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 233 |
+
operations::detect(state, self.0).await?;
|
| 234 |
+
Ok(rmpv::Value::Nil)
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
#[derive(Deserialize)]
|
| 239 |
+
#[serde(transparent)]
|
| 240 |
+
pub struct OcrHandler(operations::IndexPayload);
|
| 241 |
+
|
| 242 |
+
#[async_trait]
|
| 243 |
+
impl RpcCall for OcrHandler {
|
| 244 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 245 |
+
operations::ocr(state, self.0).await?;
|
| 246 |
+
Ok(rmpv::Value::Nil)
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
#[derive(Deserialize)]
|
| 251 |
+
#[serde(transparent)]
|
| 252 |
+
pub struct InpaintHandler(operations::IndexPayload);
|
| 253 |
+
|
| 254 |
+
#[async_trait]
|
| 255 |
+
impl RpcCall for InpaintHandler {
|
| 256 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 257 |
+
operations::inpaint(state, self.0).await?;
|
| 258 |
+
Ok(rmpv::Value::Nil)
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// Complex payload handlers
|
| 263 |
+
|
| 264 |
+
#[derive(Deserialize)]
|
| 265 |
+
#[serde(transparent)]
|
| 266 |
+
pub struct OpenExternalHandler(operations::OpenExternalPayload);
|
| 267 |
+
|
| 268 |
+
#[async_trait]
|
| 269 |
+
impl RpcCall for OpenExternalHandler {
|
| 270 |
+
async fn call(self, _state: AppResources) -> Result<rmpv::Value> {
|
| 271 |
+
operations::open_external(self.0)?;
|
| 272 |
+
Ok(rmpv::Value::Nil)
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
#[derive(Deserialize)]
|
| 277 |
+
#[serde(transparent)]
|
| 278 |
+
pub struct OpenDocumentsHandler(operations::OpenDocumentsPayload);
|
| 279 |
+
|
| 280 |
+
#[async_trait]
|
| 281 |
+
impl RpcCall for OpenDocumentsHandler {
|
| 282 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 283 |
+
let v = operations::open_documents(state, self.0).await?;
|
| 284 |
+
to_value(&v)
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
#[derive(Deserialize)]
|
| 289 |
+
#[serde(transparent)]
|
| 290 |
+
pub struct UpdateInpaintMaskHandler(operations::UpdateInpaintMaskPayload);
|
| 291 |
+
|
| 292 |
+
#[async_trait]
|
| 293 |
+
impl RpcCall for UpdateInpaintMaskHandler {
|
| 294 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 295 |
+
operations::update_inpaint_mask(state, self.0).await?;
|
| 296 |
+
Ok(rmpv::Value::Nil)
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
#[derive(Deserialize)]
|
| 301 |
+
#[serde(transparent)]
|
| 302 |
+
pub struct UpdateBrushLayerHandler(operations::UpdateBrushLayerPayload);
|
| 303 |
+
|
| 304 |
+
#[async_trait]
|
| 305 |
+
impl RpcCall for UpdateBrushLayerHandler {
|
| 306 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 307 |
+
operations::update_brush_layer(state, self.0).await?;
|
| 308 |
+
Ok(rmpv::Value::Nil)
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
#[derive(Deserialize)]
|
| 313 |
+
#[serde(transparent)]
|
| 314 |
+
pub struct InpaintPartialHandler(operations::InpaintPartialPayload);
|
| 315 |
+
|
| 316 |
+
#[async_trait]
|
| 317 |
+
impl RpcCall for InpaintPartialHandler {
|
| 318 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 319 |
+
operations::inpaint_partial(state, self.0).await?;
|
| 320 |
+
Ok(rmpv::Value::Nil)
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
#[derive(Deserialize)]
|
| 325 |
+
#[serde(transparent)]
|
| 326 |
+
pub struct RenderHandler(operations::RenderPayload);
|
| 327 |
+
|
| 328 |
+
#[async_trait]
|
| 329 |
+
impl RpcCall for RenderHandler {
|
| 330 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 331 |
+
operations::render(state, self.0).await?;
|
| 332 |
+
Ok(rmpv::Value::Nil)
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
#[derive(Deserialize)]
|
| 337 |
+
#[serde(transparent)]
|
| 338 |
+
pub struct UpdateTextBlocksHandler(operations::UpdateTextBlocksPayload);
|
| 339 |
+
|
| 340 |
+
#[async_trait]
|
| 341 |
+
impl RpcCall for UpdateTextBlocksHandler {
|
| 342 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 343 |
+
operations::update_text_blocks(state, self.0).await?;
|
| 344 |
+
Ok(rmpv::Value::Nil)
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
#[derive(Deserialize)]
|
| 349 |
+
#[serde(transparent)]
|
| 350 |
+
pub struct LlmLoadHandler(operations::LlmLoadPayload);
|
| 351 |
+
|
| 352 |
+
#[async_trait]
|
| 353 |
+
impl RpcCall for LlmLoadHandler {
|
| 354 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 355 |
+
operations::llm_load(state, self.0).await?;
|
| 356 |
+
Ok(rmpv::Value::Nil)
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
#[derive(Deserialize)]
|
| 361 |
+
#[serde(transparent)]
|
| 362 |
+
pub struct LlmGenerateHandler(operations::LlmGeneratePayload);
|
| 363 |
+
|
| 364 |
+
#[async_trait]
|
| 365 |
+
impl RpcCall for LlmGenerateHandler {
|
| 366 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 367 |
+
operations::llm_generate(state, self.0).await?;
|
| 368 |
+
Ok(rmpv::Value::Nil)
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
#[derive(Deserialize)]
|
| 373 |
+
#[serde(transparent)]
|
| 374 |
+
pub struct ProcessHandler(crate::pipeline::ProcessRequest);
|
| 375 |
+
|
| 376 |
+
#[async_trait]
|
| 377 |
+
impl RpcCall for ProcessHandler {
|
| 378 |
+
async fn call(self, state: AppResources) -> Result<rmpv::Value> {
|
| 379 |
+
operations::process(state, self.0).await?;
|
| 380 |
+
Ok(rmpv::Value::Nil)
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// --- Method Dispatch Enum ---
|
| 385 |
+
|
| 386 |
+
#[derive(Deserialize)]
|
| 387 |
+
#[serde(tag = "method", content = "params")]
|
| 388 |
+
#[enum_dispatch::enum_dispatch(RpcCall)]
|
| 389 |
+
pub enum Method {
|
| 390 |
+
#[serde(rename = "app_version")]
|
| 391 |
+
AppVersion(AppVersionHandler),
|
| 392 |
+
#[serde(rename = "device")]
|
| 393 |
+
Device(DeviceHandler),
|
| 394 |
+
#[serde(rename = "open_external")]
|
| 395 |
+
OpenExternal(OpenExternalHandler),
|
| 396 |
+
#[serde(rename = "get_documents")]
|
| 397 |
+
GetDocuments(GetDocumentsHandler),
|
| 398 |
+
#[serde(rename = "get_document")]
|
| 399 |
+
GetDocument(GetDocumentHandler),
|
| 400 |
+
#[serde(rename = "get_thumbnail")]
|
| 401 |
+
GetThumbnail(GetThumbnailHandler),
|
| 402 |
+
#[serde(rename = "open_documents")]
|
| 403 |
+
OpenDocuments(OpenDocumentsHandler),
|
| 404 |
+
#[serde(rename = "save_documents")]
|
| 405 |
+
SaveDocuments(SaveDocumentsHandler),
|
| 406 |
+
#[serde(rename = "export_document")]
|
| 407 |
+
ExportDocument(ExportDocumentHandler),
|
| 408 |
+
#[serde(rename = "detect")]
|
| 409 |
+
Detect(DetectHandler),
|
| 410 |
+
#[serde(rename = "ocr")]
|
| 411 |
+
Ocr(OcrHandler),
|
| 412 |
+
#[serde(rename = "inpaint")]
|
| 413 |
+
Inpaint(InpaintHandler),
|
| 414 |
+
#[serde(rename = "update_inpaint_mask")]
|
| 415 |
+
UpdateInpaintMask(UpdateInpaintMaskHandler),
|
| 416 |
+
#[serde(rename = "update_brush_layer")]
|
| 417 |
+
UpdateBrushLayer(UpdateBrushLayerHandler),
|
| 418 |
+
#[serde(rename = "inpaint_partial")]
|
| 419 |
+
InpaintPartial(InpaintPartialHandler),
|
| 420 |
+
#[serde(rename = "render")]
|
| 421 |
+
Render(RenderHandler),
|
| 422 |
+
#[serde(rename = "update_text_blocks")]
|
| 423 |
+
UpdateTextBlocks(UpdateTextBlocksHandler),
|
| 424 |
+
#[serde(rename = "list_font_families")]
|
| 425 |
+
ListFontFamilies(ListFontFamiliesHandler),
|
| 426 |
+
#[serde(rename = "llm_list")]
|
| 427 |
+
LlmList(LlmListHandler),
|
| 428 |
+
#[serde(rename = "llm_load")]
|
| 429 |
+
LlmLoad(LlmLoadHandler),
|
| 430 |
+
#[serde(rename = "llm_offload")]
|
| 431 |
+
LlmOffload(LlmOffloadHandler),
|
| 432 |
+
#[serde(rename = "llm_ready")]
|
| 433 |
+
LlmReady(LlmReadyHandler),
|
| 434 |
+
#[serde(rename = "llm_generate")]
|
| 435 |
+
LlmGenerate(LlmGenerateHandler),
|
| 436 |
+
#[serde(rename = "process")]
|
| 437 |
+
Process(ProcessHandler),
|
| 438 |
+
#[serde(rename = "process_cancel")]
|
| 439 |
+
ProcessCancel(ProcessCancelHandler),
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
/// Parse method name + params into a Method enum using serde adjacently-tagged deserialization.
|
| 443 |
+
fn parse_call(method: &str, params: rmpv::Value) -> Result<Method> {
|
| 444 |
+
let tagged = rmpv::Value::Map(vec![
|
| 445 |
+
(
|
| 446 |
+
rmpv::Value::String("method".into()),
|
| 447 |
+
rmpv::Value::String(method.into()),
|
| 448 |
+
),
|
| 449 |
+
(rmpv::Value::String("params".into()), params),
|
| 450 |
+
]);
|
| 451 |
+
from_value(tagged)
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
// --- WebSocket State ---
|
| 455 |
+
|
| 456 |
+
#[derive(Clone)]
|
| 457 |
+
pub struct WsState {
|
| 458 |
+
pub resources: SharedResources,
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// --- WebSocket Handler ---
|
| 462 |
+
|
| 463 |
+
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
|
| 464 |
+
ws.max_message_size(1024 * 1024 * 1024)
|
| 465 |
+
.on_upgrade(|socket| handle_socket(socket, state))
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
async fn handle_socket(socket: WebSocket, state: WsState) {
|
| 469 |
+
let (mut ws_sender, mut ws_receiver) = socket.split();
|
| 470 |
+
let (tx, mut send_rx) = mpsc::channel::<OutgoingMessage>(256);
|
| 471 |
+
|
| 472 |
+
// Notification forwarders
|
| 473 |
+
spawn_notification_forwarder(
|
| 474 |
+
"download_progress",
|
| 475 |
+
koharu_core::download::subscribe(),
|
| 476 |
+
tx.clone(),
|
| 477 |
+
);
|
| 478 |
+
spawn_notification_forwarder("process_progress", crate::pipeline::subscribe(), tx.clone());
|
| 479 |
+
|
| 480 |
+
// Sender task: drain mpsc → WebSocket binary frames
|
| 481 |
+
let send_task = tokio::spawn(async move {
|
| 482 |
+
while let Some(msg) = send_rx.recv().await {
|
| 483 |
+
let Ok(bytes) = rmp_serde::to_vec_named(&msg) else {
|
| 484 |
+
continue;
|
| 485 |
+
};
|
| 486 |
+
if ws_sender.send(Message::Binary(bytes.into())).await.is_err() {
|
| 487 |
+
break;
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
});
|
| 491 |
+
|
| 492 |
+
// Tower dispatch service (cloned per request)
|
| 493 |
+
let dispatch_svc = ServiceBuilder::new()
|
| 494 |
+
.layer(TimeoutLayer::new(Duration::from_secs(300)))
|
| 495 |
+
.service(service_fn(
|
| 496 |
+
|(method, res): (Method, AppResources)| async move { method.call(res).await },
|
| 497 |
+
));
|
| 498 |
+
|
| 499 |
+
// Receive loop
|
| 500 |
+
while let Some(Ok(msg)) = ws_receiver.next().await {
|
| 501 |
+
let data = match msg {
|
| 502 |
+
Message::Binary(data) => data,
|
| 503 |
+
Message::Close(_) => break,
|
| 504 |
+
_ => continue,
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
let raw: RawIncoming = match rmp_serde::from_slice(&data) {
|
| 508 |
+
Ok(r) => r,
|
| 509 |
+
Err(e) => {
|
| 510 |
+
let _ = tx
|
| 511 |
+
.send(err_response(0, &format!("Decode error: {e}")))
|
| 512 |
+
.await;
|
| 513 |
+
continue;
|
| 514 |
+
}
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
let id = raw.id;
|
| 518 |
+
let tx = tx.clone();
|
| 519 |
+
let resources = state.resources.clone();
|
| 520 |
+
let mut svc = dispatch_svc.clone();
|
| 521 |
+
|
| 522 |
+
tokio::spawn(async move {
|
| 523 |
+
let response = match resources.get() {
|
| 524 |
+
Some(res) => {
|
| 525 |
+
let params = raw.params.unwrap_or(rmpv::Value::Nil);
|
| 526 |
+
match parse_call(&raw.method, params) {
|
| 527 |
+
Ok(method) => match svc.ready().await {
|
| 528 |
+
Ok(ready) => match ready.call((method, res.clone())).await {
|
| 529 |
+
Ok(result) => ok_response(id, result),
|
| 530 |
+
Err(e) => err_response(id, &format!("{e:#}")),
|
| 531 |
+
},
|
| 532 |
+
Err(e) => err_response(id, &format!("{e}")),
|
| 533 |
+
},
|
| 534 |
+
Err(e) => err_response(id, &format!("{e:#}")),
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
None => err_response(id, "Resources not initialized"),
|
| 538 |
+
};
|
| 539 |
+
let _ = tx.send(response).await;
|
| 540 |
+
});
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
send_task.abort();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
fn spawn_notification_forwarder<T: Serialize + Clone + Send + 'static>(
|
| 547 |
+
method: &'static str,
|
| 548 |
+
mut rx: broadcast::Receiver<T>,
|
| 549 |
+
tx: mpsc::Sender<OutgoingMessage>,
|
| 550 |
+
) {
|
| 551 |
+
tokio::spawn(async move {
|
| 552 |
+
loop {
|
| 553 |
+
match rx.recv().await {
|
| 554 |
+
Ok(payload) => {
|
| 555 |
+
let params = to_value(&payload).unwrap_or(rmpv::Value::Nil);
|
| 556 |
+
let msg = OutgoingMessage::Notification {
|
| 557 |
+
method: method.to_string(),
|
| 558 |
+
params,
|
| 559 |
+
};
|
| 560 |
+
if tx.send(msg).await.is_err() {
|
| 561 |
+
break;
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
| 565 |
+
Err(broadcast::error::RecvError::Closed) => break,
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
});
|
| 569 |
+
}
|
koharu/src/server.rs
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
|
|
|
|
|
| 1 |
use anyhow::Result;
|
| 2 |
use axum::{
|
| 3 |
Router,
|
| 4 |
body::Body,
|
| 5 |
-
extract::DefaultBodyLimit,
|
| 6 |
http::{HeaderValue, StatusCode, Uri, header},
|
| 7 |
response::{IntoResponse, Response},
|
| 8 |
-
routing::
|
| 9 |
};
|
| 10 |
-
use
|
| 11 |
use tokio::net::TcpListener;
|
| 12 |
-
use tower_http::cors::{CorsLayer, ExposeHeaders};
|
| 13 |
|
| 14 |
-
use crate::
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
let path = uri.path();
|
| 29 |
let target = if path == "/" {
|
| 30 |
"index.html"
|
|
@@ -32,64 +33,26 @@ async fn serve_embedded(uri: Uri) -> impl IntoResponse {
|
|
| 32 |
path.trim_start_matches('/')
|
| 33 |
};
|
| 34 |
|
| 35 |
-
|
| 36 |
-
.or_else(||
|
| 37 |
.unwrap_or_else(|| (StatusCode::NOT_FOUND, "Not Found").into_response())
|
| 38 |
}
|
| 39 |
|
| 40 |
-
fn
|
| 41 |
-
let asset =
|
| 42 |
-
let
|
| 43 |
-
let
|
| 44 |
-
if let Ok(ct) = HeaderValue::from_str(mime) {
|
| 45 |
response.headers_mut().insert(header::CONTENT_TYPE, ct);
|
| 46 |
}
|
| 47 |
Some(response)
|
| 48 |
}
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
.route("/api/get_document", get(get_document).post(get_document))
|
| 57 |
-
.route("/api/get_thumbnail", get(get_thumbnail).post(get_thumbnail))
|
| 58 |
-
.route("/api/open_documents", post(open_documents))
|
| 59 |
-
.route("/api/save_documents", post(save_documents))
|
| 60 |
-
.route("/api/export_document", post(export_document))
|
| 61 |
-
.route("/api/detect", post(detect))
|
| 62 |
-
.route("/api/ocr", post(ocr))
|
| 63 |
-
.route("/api/inpaint", post(inpaint))
|
| 64 |
-
.route("/api/update_inpaint_mask", post(update_inpaint_mask))
|
| 65 |
-
.route("/api/update_brush_layer", post(update_brush_layer))
|
| 66 |
-
.route("/api/inpaint_partial", post(inpaint_partial))
|
| 67 |
-
.route("/api/render", post(render))
|
| 68 |
-
.route("/api/update_text_blocks", post(update_text_blocks))
|
| 69 |
-
.route(
|
| 70 |
-
"/api/list_font_families",
|
| 71 |
-
get(list_font_families).post(list_font_families),
|
| 72 |
-
)
|
| 73 |
-
.route("/api/llm_list", get(llm_list).post(llm_list))
|
| 74 |
-
.route("/api/llm_load", post(llm_load))
|
| 75 |
-
.route("/api/llm_offload", post(llm_offload))
|
| 76 |
-
.route("/api/llm_ready", get(llm_ready).post(llm_ready))
|
| 77 |
-
.route("/api/llm_generate", post(llm_generate))
|
| 78 |
-
.route("/api/download_progress", get(download_progress))
|
| 79 |
-
.route("/api/process", post(process))
|
| 80 |
-
.route("/api/process_cancel", post(process_cancel))
|
| 81 |
-
.route("/api/process_progress", get(process_progress))
|
| 82 |
-
.with_state(state)
|
| 83 |
-
.layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
|
| 84 |
-
.layer(
|
| 85 |
-
CorsLayer::very_permissive()
|
| 86 |
-
.expose_headers(ExposeHeaders::list([header::CONTENT_DISPOSITION])),
|
| 87 |
-
)
|
| 88 |
-
.fallback(serve_embedded)
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
pub async fn serve_with_listener(listener: TcpListener, resources: AppResources) -> Result<()> {
|
| 92 |
-
let router = build_router(resources);
|
| 93 |
tracing::info!("HTTP server listening on http://{}", listener.local_addr()?);
|
| 94 |
axum::serve(listener, router.into_make_service()).await?;
|
| 95 |
Ok(())
|
|
|
|
| 1 |
+
use std::sync::Arc;
|
| 2 |
+
|
| 3 |
use anyhow::Result;
|
| 4 |
use axum::{
|
| 5 |
Router,
|
| 6 |
body::Body,
|
|
|
|
| 7 |
http::{HeaderValue, StatusCode, Uri, header},
|
| 8 |
response::{IntoResponse, Response},
|
| 9 |
+
routing::get,
|
| 10 |
};
|
| 11 |
+
use tauri::AssetResolver;
|
| 12 |
use tokio::net::TcpListener;
|
|
|
|
| 13 |
|
| 14 |
+
use crate::rpc::{self, SharedResources, WsState};
|
| 15 |
|
| 16 |
+
fn build_router(shared: SharedResources, resolver: Arc<AssetResolver<tauri::Wry>>) -> Router {
|
| 17 |
+
let ws_state = WsState { resources: shared };
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
Router::new()
|
| 20 |
+
.route("/ws", get(rpc::ws_handler))
|
| 21 |
+
.with_state(ws_state)
|
| 22 |
+
.fallback(move |uri: Uri| {
|
| 23 |
+
let resolver = resolver.clone();
|
| 24 |
+
async move { serve_asset(&resolver, uri) }
|
| 25 |
+
})
|
| 26 |
+
}
|
| 27 |
|
| 28 |
+
fn serve_asset(resolver: &AssetResolver<tauri::Wry>, uri: Uri) -> Response {
|
| 29 |
let path = uri.path();
|
| 30 |
let target = if path == "/" {
|
| 31 |
"index.html"
|
|
|
|
| 33 |
path.trim_start_matches('/')
|
| 34 |
};
|
| 35 |
|
| 36 |
+
resolve_asset(resolver, target)
|
| 37 |
+
.or_else(|| resolve_asset(resolver, "index.html"))
|
| 38 |
.unwrap_or_else(|| (StatusCode::NOT_FOUND, "Not Found").into_response())
|
| 39 |
}
|
| 40 |
|
| 41 |
+
fn resolve_asset(resolver: &AssetResolver<tauri::Wry>, path: &str) -> Option<Response> {
|
| 42 |
+
let asset = resolver.get(path.to_string())?;
|
| 43 |
+
let mut response = Response::new(Body::from(asset.bytes));
|
| 44 |
+
if let Ok(ct) = HeaderValue::from_str(&asset.mime_type) {
|
|
|
|
| 45 |
response.headers_mut().insert(header::CONTENT_TYPE, ct);
|
| 46 |
}
|
| 47 |
Some(response)
|
| 48 |
}
|
| 49 |
|
| 50 |
+
pub async fn serve_with_listener(
|
| 51 |
+
listener: TcpListener,
|
| 52 |
+
shared: SharedResources,
|
| 53 |
+
resolver: Arc<AssetResolver<tauri::Wry>>,
|
| 54 |
+
) -> Result<()> {
|
| 55 |
+
let router = build_router(shared, resolver);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
tracing::info!("HTTP server listening on http://{}", listener.local_addr()?);
|
| 57 |
axum::serve(listener, router.into_make_service()).await?;
|
| 58 |
Ok(())
|
ui/app/(app)/about/page.tsx
CHANGED
|
@@ -30,7 +30,7 @@ export default function AboutPage() {
|
|
| 30 |
const checkVersion = async () => {
|
| 31 |
try {
|
| 32 |
if (isTauri()) {
|
| 33 |
-
const version = await invoke
|
| 34 |
setAppVersion(version)
|
| 35 |
|
| 36 |
const res = await fetch(
|
|
|
|
| 30 |
const checkVersion = async () => {
|
| 31 |
try {
|
| 32 |
if (isTauri()) {
|
| 33 |
+
const version = await invoke('app_version')
|
| 34 |
setAppVersion(version)
|
| 35 |
|
| 36 |
const res = await fetch(
|
ui/app/(app)/settings/page.tsx
CHANGED
|
@@ -20,19 +20,7 @@ import {
|
|
| 20 |
SelectValue,
|
| 21 |
} from '@/components/ui/select'
|
| 22 |
import { invoke, isTauri } from '@/lib/backend'
|
| 23 |
-
|
| 24 |
-
interface WgpuDeviceInfo {
|
| 25 |
-
name: string
|
| 26 |
-
backend: string
|
| 27 |
-
deviceType: string
|
| 28 |
-
driver: string
|
| 29 |
-
driverInfo: string
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
interface DeviceInfo {
|
| 33 |
-
mlDevice: string
|
| 34 |
-
wgpu: WgpuDeviceInfo
|
| 35 |
-
}
|
| 36 |
|
| 37 |
const THEME_OPTIONS = [
|
| 38 |
{ value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
|
|
@@ -54,7 +42,7 @@ export default function SettingsPage() {
|
|
| 54 |
|
| 55 |
const loadDeviceInfo = async () => {
|
| 56 |
try {
|
| 57 |
-
const info = await invoke
|
| 58 |
setDeviceInfo(info)
|
| 59 |
} catch (error) {
|
| 60 |
console.error('Failed to load device info', error)
|
|
|
|
| 20 |
SelectValue,
|
| 21 |
} from '@/components/ui/select'
|
| 22 |
import { invoke, isTauri } from '@/lib/backend'
|
| 23 |
+
import type { DeviceInfo } from '@/lib/rpc-types'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
const THEME_OPTIONS = [
|
| 26 |
{ value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
|
|
|
|
| 42 |
|
| 43 |
const loadDeviceInfo = async () => {
|
| 44 |
try {
|
| 45 |
+
const info = await invoke('device')
|
| 46 |
setDeviceInfo(info)
|
| 47 |
} catch (error) {
|
| 48 |
console.error('Failed to load device info', error)
|
ui/app/providers.tsx
CHANGED
|
@@ -16,7 +16,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|
| 16 |
let unlisten: (() => void) | undefined
|
| 17 |
;(async () => {
|
| 18 |
try {
|
| 19 |
-
const count = await invoke
|
| 20 |
if (count > 0) {
|
| 21 |
setTotalPages(count)
|
| 22 |
}
|
|
|
|
| 16 |
let unlisten: (() => void) | undefined
|
| 17 |
;(async () => {
|
| 18 |
try {
|
| 19 |
+
const count = await invoke('get_documents')
|
| 20 |
if (count > 0) {
|
| 21 |
setTotalPages(count)
|
| 22 |
}
|
ui/app/splashscreen/page.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
-
import {
|
| 6 |
|
| 7 |
type AggregateProgress = {
|
| 8 |
filename: string
|
|
@@ -12,71 +12,65 @@ type AggregateProgress = {
|
|
| 12 |
export default function SplashScreen() {
|
| 13 |
const { t } = useTranslation()
|
| 14 |
const [progress, setProgress] = useState<AggregateProgress | null>(null)
|
| 15 |
-
const channelRef = useRef<unknown>(null)
|
| 16 |
const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
|
| 17 |
new Map(),
|
| 18 |
)
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
const { Channel, invoke } = await import('@tauri-apps/api/core')
|
| 24 |
-
const channel = new Channel<DownloadProgress>()
|
| 25 |
-
channelRef.current = channel
|
| 26 |
-
channel.onmessage = (msg) => {
|
| 27 |
-
const files = filesRef.current
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
} else {
|
| 37 |
-
files.set(msg.filename, {
|
| 38 |
-
downloaded: msg.downloaded,
|
| 39 |
-
total: msg.total ?? 0,
|
| 40 |
-
})
|
| 41 |
-
}
|
| 42 |
} else {
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
}
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
//
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
totalBytes += entry.total
|
| 55 |
-
downloadedBytes += entry.downloaded
|
| 56 |
}
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
}, [])
|
| 81 |
|
| 82 |
return (
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
+
import { subscribeDownloadProgress, type DownloadProgress } from '@/lib/backend'
|
| 6 |
|
| 7 |
type AggregateProgress = {
|
| 8 |
filename: string
|
|
|
|
| 12 |
export default function SplashScreen() {
|
| 13 |
const { t } = useTranslation()
|
| 14 |
const [progress, setProgress] = useState<AggregateProgress | null>(null)
|
|
|
|
| 15 |
const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
|
| 16 |
new Map(),
|
| 17 |
)
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
+
const unsub = subscribeDownloadProgress((msg: DownloadProgress) => {
|
| 21 |
+
const files = filesRef.current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
if (msg.status === 'Started') {
|
| 24 |
+
files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
|
| 25 |
+
} else if (msg.status === 'Downloading') {
|
| 26 |
+
const entry = files.get(msg.filename)
|
| 27 |
+
if (entry) {
|
| 28 |
+
entry.downloaded = msg.downloaded
|
| 29 |
+
if (msg.total) entry.total = msg.total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
} else {
|
| 31 |
+
files.set(msg.filename, {
|
| 32 |
+
downloaded: msg.downloaded,
|
| 33 |
+
total: msg.total ?? 0,
|
| 34 |
+
})
|
|
|
|
| 35 |
}
|
| 36 |
+
} else {
|
| 37 |
+
// Completed or Failed — lock this file at 100%
|
| 38 |
+
const entry = files.get(msg.filename)
|
| 39 |
+
if (entry) {
|
| 40 |
+
entry.downloaded = entry.total
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
+
}
|
| 43 |
|
| 44 |
+
// Compute aggregate
|
| 45 |
+
let totalBytes = 0
|
| 46 |
+
let downloadedBytes = 0
|
| 47 |
+
for (const entry of files.values()) {
|
| 48 |
+
totalBytes += entry.total
|
| 49 |
+
downloadedBytes += entry.downloaded
|
| 50 |
+
}
|
| 51 |
|
| 52 |
+
// Find current active file (last non-completed)
|
| 53 |
+
const activeFilename =
|
| 54 |
+
msg.status === 'Started' || msg.status === 'Downloading'
|
| 55 |
+
? msg.filename
|
| 56 |
+
: null
|
| 57 |
|
| 58 |
+
const percent =
|
| 59 |
+
totalBytes > 0
|
| 60 |
+
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
|
| 61 |
+
: undefined
|
| 62 |
+
|
| 63 |
+
if (activeFilename) {
|
| 64 |
+
setProgress({ filename: activeFilename, percent })
|
| 65 |
+
} else {
|
| 66 |
+
// All done — keep showing 100% briefly
|
| 67 |
+
setProgress((prev) =>
|
| 68 |
+
prev ? { ...prev, percent: percent ?? 100 } : null,
|
| 69 |
+
)
|
| 70 |
}
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
return () => unsub()
|
| 74 |
}, [])
|
| 75 |
|
| 76 |
return (
|
ui/components/Image.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
| 5 |
import { convertToBlob } from '@/lib/util'
|
| 6 |
|
| 7 |
type ImageProps = {
|
| 8 |
-
data?:
|
| 9 |
visible?: boolean
|
| 10 |
opacity?: number
|
| 11 |
transition?: boolean
|
|
|
|
| 5 |
import { convertToBlob } from '@/lib/util'
|
| 6 |
|
| 7 |
type ImageProps = {
|
| 8 |
+
data?: Uint8Array
|
| 9 |
visible?: boolean
|
| 10 |
opacity?: number
|
| 11 |
transition?: boolean
|
ui/hooks/useMaskDrawing.ts
CHANGED
|
@@ -201,7 +201,7 @@ export function useMaskDrawing({
|
|
| 201 |
ctx.restore()
|
| 202 |
}
|
| 203 |
|
| 204 |
-
const exportMaskBytes = async (): Promise<
|
| 205 |
const canvas = canvasRef.current
|
| 206 |
if (!canvas) return null
|
| 207 |
const blob = await new Promise<Blob | null>((resolve) => {
|
|
@@ -213,7 +213,7 @@ export function useMaskDrawing({
|
|
| 213 |
|
| 214 |
const exportMaskPatch = async (
|
| 215 |
region: InpaintRegion,
|
| 216 |
-
): Promise<
|
| 217 |
const canvas = canvasRef.current
|
| 218 |
if (!canvas || region.width <= 0 || region.height <= 0) return null
|
| 219 |
|
|
|
|
| 201 |
ctx.restore()
|
| 202 |
}
|
| 203 |
|
| 204 |
+
const exportMaskBytes = async (): Promise<Uint8Array | null> => {
|
| 205 |
const canvas = canvasRef.current
|
| 206 |
if (!canvas) return null
|
| 207 |
const blob = await new Promise<Blob | null>((resolve) => {
|
|
|
|
| 213 |
|
| 214 |
const exportMaskPatch = async (
|
| 215 |
region: InpaintRegion,
|
| 216 |
+
): Promise<Uint8Array | null> => {
|
| 217 |
const canvas = canvasRef.current
|
| 218 |
if (!canvas || region.width <= 0 || region.height <= 0) return null
|
| 219 |
|
ui/hooks/useRenderBrushDrawing.ts
CHANGED
|
@@ -152,7 +152,7 @@ export function useRenderBrushDrawing({
|
|
| 152 |
|
| 153 |
const exportPatch = async (
|
| 154 |
region: InpaintRegion,
|
| 155 |
-
): Promise<
|
| 156 |
const canvas = targetCanvasRef?.current ?? canvasRef.current
|
| 157 |
if (!canvas || region.width <= 0 || region.height <= 0) return null
|
| 158 |
|
|
|
|
| 152 |
|
| 153 |
const exportPatch = async (
|
| 154 |
region: InpaintRegion,
|
| 155 |
+
): Promise<Uint8Array | null> => {
|
| 156 |
const canvas = targetCanvasRef?.current ?? canvasRef.current
|
| 157 |
if (!canvas || region.width <= 0 || region.height <= 0) return null
|
| 158 |
|
ui/lib/backend.ts
CHANGED
|
@@ -1,32 +1,50 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { pickFiles, saveFile } from './fs'
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
let _initPromise: Promise<void> | null = null
|
| 7 |
|
| 8 |
-
|
| 9 |
-
typeof window !== 'undefined' && !!(window as any).__TAURI_INTERNALS__
|
| 10 |
|
| 11 |
-
|
| 12 |
-
if (
|
| 13 |
-
// In browser/headless mode, use relative URLs
|
| 14 |
-
return
|
| 15 |
-
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
|
| 30 |
export enum ProgressBarStatus {
|
| 31 |
None = 'none',
|
| 32 |
Normal = 'normal',
|
|
@@ -54,12 +72,13 @@ export function getCurrentWindow(): ProgressTarget {
|
|
| 54 |
|
| 55 |
return {
|
| 56 |
async setProgressBar() {
|
| 57 |
-
// no-op in headless mode
|
| 58 |
return
|
| 59 |
},
|
| 60 |
}
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
| 63 |
export async function listen<T>(
|
| 64 |
event: string,
|
| 65 |
handler: (event: { payload: T }) => void,
|
|
@@ -78,227 +97,123 @@ export async function listen<T>(
|
|
| 78 |
return async () => {}
|
| 79 |
}
|
| 80 |
|
| 81 |
-
|
| 82 |
-
cmd: string,
|
| 83 |
-
args?: Record<string, any>,
|
| 84 |
-
): Promise<T> {
|
| 85 |
-
await ensureInitialized()
|
| 86 |
-
|
| 87 |
-
// Browser-only implementations (no Tauri available)
|
| 88 |
-
if (!isTauriEnv()) {
|
| 89 |
-
switch (cmd) {
|
| 90 |
-
case 'open_external': {
|
| 91 |
-
const url = typeof args?.url === 'string' ? args.url : undefined
|
| 92 |
-
if (url) {
|
| 93 |
-
window.open(url, '_blank', 'noopener,noreferrer')
|
| 94 |
-
}
|
| 95 |
-
return undefined as T
|
| 96 |
-
}
|
| 97 |
-
}
|
| 98 |
-
}
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
-
|
| 131 |
-
if (
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
-
|
| 136 |
-
return buffer as unknown as T
|
| 137 |
}
|
| 138 |
|
| 139 |
-
async function
|
| 140 |
const files = await pickFiles(
|
| 141 |
[
|
| 142 |
{
|
| 143 |
description: 'Documents',
|
| 144 |
accept: {
|
| 145 |
-
// 'application/octet-stream': ['.khr'],
|
| 146 |
'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
|
| 147 |
},
|
| 148 |
},
|
| 149 |
],
|
| 150 |
true,
|
| 151 |
)
|
| 152 |
-
if (!files.length)
|
| 153 |
-
return 0 as unknown as T
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
const formData = new FormData()
|
| 157 |
-
for (const file of files) {
|
| 158 |
-
formData.append('files', file, file.name)
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
const res = await fetch(`${apiBase}/open_documents`, {
|
| 162 |
-
method: 'POST',
|
| 163 |
-
body: formData,
|
| 164 |
-
})
|
| 165 |
-
if (!res.ok) {
|
| 166 |
-
throw new Error(await readError(res))
|
| 167 |
-
}
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
): Promise<void> {
|
| 176 |
-
const hasBody = args && Object.keys(args).length > 0
|
| 177 |
-
const res = await fetch(endpoint, {
|
| 178 |
-
method: 'POST',
|
| 179 |
-
headers: hasBody ? { 'Content-Type': 'application/json' } : undefined,
|
| 180 |
-
body: hasBody ? JSON.stringify(args) : undefined,
|
| 181 |
-
})
|
| 182 |
-
|
| 183 |
-
if (!res.ok) {
|
| 184 |
-
throw new Error(await readError(res))
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
const blob = await res.blob()
|
| 188 |
-
const suggestedName =
|
| 189 |
-
parseFilename(res.headers.get('content-disposition')) ?? 'download.bin'
|
| 190 |
-
await saveFile(blob, suggestedName)
|
| 191 |
-
}
|
| 192 |
|
| 193 |
-
|
| 194 |
-
if (!disposition) return undefined
|
| 195 |
-
const match = /filename="?([^\";]+)"?/i.exec(disposition)
|
| 196 |
-
return match?.[1]
|
| 197 |
}
|
| 198 |
|
| 199 |
-
|
| 200 |
-
const contentType = res.headers.get('content-type') ?? ''
|
| 201 |
-
if (contentType.includes('application/json')) {
|
| 202 |
-
try {
|
| 203 |
-
const body = (await res.json()) as { error?: string }
|
| 204 |
-
if (body?.error) return body.error
|
| 205 |
-
} catch (_) {}
|
| 206 |
-
}
|
| 207 |
-
try {
|
| 208 |
-
return await res.text()
|
| 209 |
-
} catch (_) {
|
| 210 |
-
return res.statusText || 'Request failed'
|
| 211 |
-
}
|
| 212 |
-
}
|
| 213 |
|
| 214 |
export async function fetchThumbnail(index: number): Promise<Blob> {
|
| 215 |
-
await
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
| 220 |
})
|
| 221 |
-
if (!res.ok) {
|
| 222 |
-
throw new Error(await readError(res))
|
| 223 |
-
}
|
| 224 |
-
return res.blob()
|
| 225 |
}
|
| 226 |
|
| 227 |
-
|
| 228 |
-
filename: string
|
| 229 |
-
downloaded: number
|
| 230 |
-
total?: number
|
| 231 |
-
status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
|
| 232 |
-
}
|
| 233 |
|
| 234 |
-
export type ProcessProgress
|
| 235 |
-
status: 'running' | 'completed' | 'cancelled' | { failed: string }
|
| 236 |
-
step: string | null
|
| 237 |
-
currentDocument: number
|
| 238 |
-
totalDocuments: number
|
| 239 |
-
currentStepIndex: number
|
| 240 |
-
totalSteps: number
|
| 241 |
-
overallPercent: number
|
| 242 |
-
}
|
| 243 |
|
| 244 |
-
function
|
| 245 |
-
|
| 246 |
-
callback: (data: T) => void,
|
| 247 |
): () => void {
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
es = new EventSource(`${apiBase}/${endpoint}`)
|
| 253 |
-
es.onmessage = (event) => {
|
| 254 |
-
try {
|
| 255 |
-
callback(JSON.parse(event.data) as T)
|
| 256 |
-
} catch (_) {}
|
| 257 |
-
}
|
| 258 |
-
})()
|
| 259 |
-
|
| 260 |
-
return () => {
|
| 261 |
-
es?.close()
|
| 262 |
-
}
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
export const subscribeDownloadProgress = (cb: (p: DownloadProgress) => void) =>
|
| 266 |
-
subscribeSSE<DownloadProgress>('download_progress', cb)
|
| 267 |
-
|
| 268 |
-
export const subscribeProcessProgress = (cb: (p: ProcessProgress) => void) =>
|
| 269 |
-
subscribeSSE<ProcessProgress>('process_progress', cb)
|
| 270 |
-
|
| 271 |
-
export const isTauri = isTauriEnv
|
| 272 |
-
|
| 273 |
-
export const isMacOS = (): boolean => {
|
| 274 |
-
if (typeof window === 'undefined') return false
|
| 275 |
-
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
| 276 |
}
|
| 277 |
|
| 278 |
-
export
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
async toggleMaximize() {
|
| 286 |
-
if (isTauriEnv()) {
|
| 287 |
-
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 288 |
-
return getCurrentWindow().toggleMaximize()
|
| 289 |
-
}
|
| 290 |
-
},
|
| 291 |
-
async close() {
|
| 292 |
-
if (isTauriEnv()) {
|
| 293 |
-
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 294 |
-
return getCurrentWindow().close()
|
| 295 |
-
}
|
| 296 |
-
},
|
| 297 |
-
async isMaximized(): Promise<boolean> {
|
| 298 |
-
if (isTauriEnv()) {
|
| 299 |
-
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 300 |
-
return getCurrentWindow().isMaximized()
|
| 301 |
-
}
|
| 302 |
-
return false
|
| 303 |
-
},
|
| 304 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { WsRpcClient } from './ws'
|
| 4 |
import { pickFiles, saveFile } from './fs'
|
| 5 |
+
import { toArrayBuffer } from './util'
|
| 6 |
+
import type { RpcMethodMap, RpcNotificationMap, FileResult } from './rpc-types'
|
| 7 |
|
| 8 |
+
// --- Singleton client ---
|
|
|
|
| 9 |
|
| 10 |
+
let client: WsRpcClient | null = null
|
|
|
|
| 11 |
|
| 12 |
+
function getClient(): WsRpcClient {
|
| 13 |
+
if (client) return client
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
let url: string
|
| 16 |
+
if (typeof window !== 'undefined' && (window as any).__KOHARU_WS_PORT__) {
|
| 17 |
+
const port = (window as any).__KOHARU_WS_PORT__ as number
|
| 18 |
+
url = `ws://127.0.0.1:${port}/ws`
|
| 19 |
+
} else {
|
| 20 |
+
// Browser / headless mode: derive from current location
|
| 21 |
+
const proto =
|
| 22 |
+
typeof location !== 'undefined' && location.protocol === 'https:'
|
| 23 |
+
? 'wss:'
|
| 24 |
+
: 'ws:'
|
| 25 |
+
const host = typeof location !== 'undefined' ? location.host : '127.0.0.1'
|
| 26 |
+
url = `${proto}//${host}/ws`
|
| 27 |
}
|
| 28 |
|
| 29 |
+
client = new WsRpcClient(url)
|
| 30 |
+
client.connect()
|
| 31 |
+
return client
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// --- Environment helpers ---
|
| 35 |
+
|
| 36 |
+
const isTauriEnv = (): boolean =>
|
| 37 |
+
typeof window !== 'undefined' && !!(window as any).__TAURI_INTERNALS__
|
| 38 |
+
|
| 39 |
+
export const isTauri = isTauriEnv
|
| 40 |
|
| 41 |
+
export const isMacOS = (): boolean => {
|
| 42 |
+
if (typeof window === 'undefined') return false
|
| 43 |
+
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
| 44 |
}
|
| 45 |
|
| 46 |
+
// --- Progress bar ---
|
| 47 |
+
|
| 48 |
export enum ProgressBarStatus {
|
| 49 |
None = 'none',
|
| 50 |
Normal = 'normal',
|
|
|
|
| 72 |
|
| 73 |
return {
|
| 74 |
async setProgressBar() {
|
|
|
|
| 75 |
return
|
| 76 |
},
|
| 77 |
}
|
| 78 |
}
|
| 79 |
|
| 80 |
+
// --- Window resize listener ---
|
| 81 |
+
|
| 82 |
export async function listen<T>(
|
| 83 |
event: string,
|
| 84 |
handler: (event: { payload: T }) => void,
|
|
|
|
| 97 |
return async () => {}
|
| 98 |
}
|
| 99 |
|
| 100 |
+
// --- Window controls ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
export const windowControls = {
|
| 103 |
+
async minimize() {
|
| 104 |
+
if (isTauriEnv()) {
|
| 105 |
+
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 106 |
+
return getCurrentWindow().minimize()
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
async toggleMaximize() {
|
| 110 |
+
if (isTauriEnv()) {
|
| 111 |
+
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 112 |
+
return getCurrentWindow().toggleMaximize()
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
async close() {
|
| 116 |
+
if (isTauriEnv()) {
|
| 117 |
+
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 118 |
+
return getCurrentWindow().close()
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
async isMaximized(): Promise<boolean> {
|
| 122 |
+
if (isTauriEnv()) {
|
| 123 |
+
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
| 124 |
+
return getCurrentWindow().isMaximized()
|
| 125 |
+
}
|
| 126 |
+
return false
|
| 127 |
+
},
|
| 128 |
}
|
| 129 |
|
| 130 |
+
// --- Typed RPC invoke ---
|
| 131 |
+
|
| 132 |
+
export async function invoke<M extends keyof RpcMethodMap>(
|
| 133 |
+
method: M,
|
| 134 |
+
...args: RpcMethodMap[M][0] extends void ? [] : [RpcMethodMap[M][0]]
|
| 135 |
+
): Promise<RpcMethodMap[M][1]> {
|
| 136 |
+
const params = args[0]
|
| 137 |
+
|
| 138 |
+
// Browser-only: open_external in a new tab
|
| 139 |
+
if (!isTauriEnv() && method === 'open_external') {
|
| 140 |
+
const p = params as { url: string }
|
| 141 |
+
if (p?.url) {
|
| 142 |
+
window.open(p.url, '_blank', 'noopener,noreferrer')
|
| 143 |
+
}
|
| 144 |
+
return undefined as RpcMethodMap[M][1]
|
| 145 |
+
}
|
| 146 |
|
| 147 |
+
// Special file-pick flow for open_documents
|
| 148 |
+
if (method === 'open_documents') {
|
| 149 |
+
return (await openDocumentsRpc()) as RpcMethodMap[M][1]
|
| 150 |
}
|
| 151 |
|
| 152 |
+
// Special file-save flow for save_documents / export_document
|
| 153 |
+
if (method === 'save_documents' || method === 'export_document') {
|
| 154 |
+
const result = await getClient().invoke<FileResult>(method, params)
|
| 155 |
+
const blob = new Blob([toArrayBuffer(result.data)])
|
| 156 |
+
await saveFile(blob, result.filename)
|
| 157 |
+
return undefined as RpcMethodMap[M][1]
|
| 158 |
}
|
| 159 |
|
| 160 |
+
return getClient().invoke<RpcMethodMap[M][1]>(method, params)
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
+
async function openDocumentsRpc(): Promise<number> {
|
| 164 |
const files = await pickFiles(
|
| 165 |
[
|
| 166 |
{
|
| 167 |
description: 'Documents',
|
| 168 |
accept: {
|
|
|
|
| 169 |
'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
|
| 170 |
},
|
| 171 |
},
|
| 172 |
],
|
| 173 |
true,
|
| 174 |
)
|
| 175 |
+
if (!files.length) return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
const entries = await Promise.all(
|
| 178 |
+
files.map(async (file) => ({
|
| 179 |
+
name: file.name,
|
| 180 |
+
data: new Uint8Array(await file.arrayBuffer()),
|
| 181 |
+
})),
|
| 182 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
return getClient().invoke<number>('open_documents', { files: entries })
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
|
| 187 |
+
// --- Thumbnail fetch ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
export async function fetchThumbnail(index: number): Promise<Blob> {
|
| 190 |
+
const result = await getClient().invoke<{
|
| 191 |
+
data: Uint8Array
|
| 192 |
+
contentType: string
|
| 193 |
+
}>('get_thumbnail', { index })
|
| 194 |
+
return new Blob([toArrayBuffer(result.data)], {
|
| 195 |
+
type: result.contentType,
|
| 196 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
+
// --- Notification subscriptions ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
+
export type { DownloadProgress, ProcessProgress } from './rpc-types'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
+
export function subscribeDownloadProgress(
|
| 204 |
+
cb: (p: RpcNotificationMap['download_progress']) => void,
|
|
|
|
| 205 |
): () => void {
|
| 206 |
+
return getClient().onNotification<RpcNotificationMap['download_progress']>(
|
| 207 |
+
'download_progress',
|
| 208 |
+
cb,
|
| 209 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
+
export function subscribeProcessProgress(
|
| 213 |
+
cb: (p: RpcNotificationMap['process_progress']) => void,
|
| 214 |
+
): () => void {
|
| 215 |
+
return getClient().onNotification<RpcNotificationMap['process_progress']>(
|
| 216 |
+
'process_progress',
|
| 217 |
+
cb,
|
| 218 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
ui/lib/rpc-types.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Document, InpaintRegion, RenderEffect, TextBlock } from '@/types'
|
| 2 |
+
|
| 3 |
+
// Params → Result type map for all RPC methods
|
| 4 |
+
|
| 5 |
+
export type ThumbnailResult = {
|
| 6 |
+
data: Uint8Array
|
| 7 |
+
contentType: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export type FileEntry = {
|
| 11 |
+
name: string
|
| 12 |
+
data: Uint8Array
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export type FileResult = {
|
| 16 |
+
filename: string
|
| 17 |
+
data: Uint8Array
|
| 18 |
+
contentType: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export type WgpuDeviceInfo = {
|
| 22 |
+
name: string
|
| 23 |
+
backend: string
|
| 24 |
+
deviceType: string
|
| 25 |
+
driver: string
|
| 26 |
+
driverInfo: string
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export type DeviceInfo = {
|
| 30 |
+
mlDevice: string
|
| 31 |
+
wgpu: WgpuDeviceInfo
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export type LlmModelInfo = {
|
| 35 |
+
id: string
|
| 36 |
+
languages: string[]
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export type RpcMethodMap = {
|
| 40 |
+
app_version: [void, string]
|
| 41 |
+
device: [void, DeviceInfo]
|
| 42 |
+
open_external: [{ url: string }, void]
|
| 43 |
+
get_documents: [void, number]
|
| 44 |
+
get_document: [{ index: number }, Document]
|
| 45 |
+
get_thumbnail: [{ index: number }, ThumbnailResult]
|
| 46 |
+
open_documents: [void, number]
|
| 47 |
+
save_documents: [void, void]
|
| 48 |
+
export_document: [{ index: number }, void]
|
| 49 |
+
detect: [{ index: number }, void]
|
| 50 |
+
ocr: [{ index: number }, void]
|
| 51 |
+
inpaint: [{ index: number }, void]
|
| 52 |
+
update_inpaint_mask: [
|
| 53 |
+
{ index: number; mask: Uint8Array; region?: InpaintRegion },
|
| 54 |
+
void,
|
| 55 |
+
]
|
| 56 |
+
update_brush_layer: [
|
| 57 |
+
{ index: number; patch: Uint8Array; region: InpaintRegion },
|
| 58 |
+
void,
|
| 59 |
+
]
|
| 60 |
+
inpaint_partial: [{ index: number; region: InpaintRegion }, void]
|
| 61 |
+
render: [
|
| 62 |
+
{
|
| 63 |
+
index: number
|
| 64 |
+
textBlockIndex?: number
|
| 65 |
+
shaderEffect?: RenderEffect
|
| 66 |
+
},
|
| 67 |
+
void,
|
| 68 |
+
]
|
| 69 |
+
update_text_blocks: [{ index: number; textBlocks: TextBlock[] }, void]
|
| 70 |
+
list_font_families: [void, string[]]
|
| 71 |
+
llm_list: [void, LlmModelInfo[]]
|
| 72 |
+
llm_load: [{ id: string }, void]
|
| 73 |
+
llm_offload: [void, void]
|
| 74 |
+
llm_ready: [void, boolean]
|
| 75 |
+
llm_generate: [
|
| 76 |
+
{ index: number; textBlockIndex?: number; language?: string },
|
| 77 |
+
void,
|
| 78 |
+
]
|
| 79 |
+
process: [
|
| 80 |
+
{
|
| 81 |
+
index?: number
|
| 82 |
+
llmModelId?: string
|
| 83 |
+
language?: string
|
| 84 |
+
shaderEffect?: RenderEffect
|
| 85 |
+
},
|
| 86 |
+
void,
|
| 87 |
+
]
|
| 88 |
+
process_cancel: [void, void]
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export type DownloadProgress = {
|
| 92 |
+
filename: string
|
| 93 |
+
downloaded: number
|
| 94 |
+
total?: number
|
| 95 |
+
status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export type ProcessProgress = {
|
| 99 |
+
status: 'running' | 'completed' | 'cancelled' | { failed: string }
|
| 100 |
+
step: string | null
|
| 101 |
+
currentDocument: number
|
| 102 |
+
totalDocuments: number
|
| 103 |
+
currentStepIndex: number
|
| 104 |
+
totalSteps: number
|
| 105 |
+
overallPercent: number
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export type RpcNotificationMap = {
|
| 109 |
+
download_progress: DownloadProgress
|
| 110 |
+
process_progress: ProcessProgress
|
| 111 |
+
}
|
ui/lib/store.ts
CHANGED
|
@@ -60,7 +60,7 @@ const textBlockSyncer = createTextBlockSyncer()
|
|
| 60 |
const createMaskSyncer = () => {
|
| 61 |
type MaskUpdate = {
|
| 62 |
index: number
|
| 63 |
-
mask:
|
| 64 |
region?: InpaintRegion
|
| 65 |
}
|
| 66 |
let pending: MaskUpdate[] = []
|
|
@@ -176,15 +176,15 @@ type AppState = OperationSlice & {
|
|
| 176 |
fetchAvailableFonts: () => Promise<void>
|
| 177 |
updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
|
| 178 |
updateMask: (
|
| 179 |
-
mask:
|
| 180 |
options?: {
|
| 181 |
sync?: boolean
|
| 182 |
-
patch?:
|
| 183 |
patchRegion?: InpaintRegion
|
| 184 |
},
|
| 185 |
) => Promise<void>
|
| 186 |
paintRendered: (
|
| 187 |
-
patch:
|
| 188 |
region: InpaintRegion,
|
| 189 |
options?: { index?: number },
|
| 190 |
) => Promise<void>
|
|
@@ -265,7 +265,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|
| 265 |
|
| 266 |
set({ currentDocumentLoading: true })
|
| 267 |
try {
|
| 268 |
-
const doc = await invoke
|
| 269 |
// Only update if we're still on the same index
|
| 270 |
if (get().currentDocumentIndex === index) {
|
| 271 |
set({ currentDocument: doc, currentDocumentLoading: false })
|
|
@@ -283,7 +283,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|
| 283 |
openDocuments: async () => {
|
| 284 |
get().startOperation({ type: 'load-khr', cancellable: false })
|
| 285 |
try {
|
| 286 |
-
const count = await invoke
|
| 287 |
get().setTotalPages(count)
|
| 288 |
} finally {
|
| 289 |
get().finishOperation()
|
|
@@ -364,7 +364,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|
| 364 |
setRenderEffect: (effect: RenderEffect) => set({ renderEffect: effect }),
|
| 365 |
fetchAvailableFonts: async () => {
|
| 366 |
try {
|
| 367 |
-
const fonts = await invoke
|
| 368 |
set({ availableFonts: fonts })
|
| 369 |
} catch (_) {}
|
| 370 |
},
|
|
@@ -484,7 +484,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|
| 484 |
},
|
| 485 |
llmList: async () => {
|
| 486 |
try {
|
| 487 |
-
const models = await invoke
|
| 488 |
set({ llmModels: models })
|
| 489 |
const currentModel = get().llmSelectedModel
|
| 490 |
const currentLanguage = get().llmSelectedLanguage
|
|
@@ -574,7 +574,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|
| 574 |
return
|
| 575 |
}
|
| 576 |
try {
|
| 577 |
-
llmReadyCheckInFlight = invoke
|
| 578 |
const ready = await llmReadyCheckInFlight
|
| 579 |
set({ llmReady: ready })
|
| 580 |
} catch (_) {}
|
|
|
|
| 60 |
const createMaskSyncer = () => {
|
| 61 |
type MaskUpdate = {
|
| 62 |
index: number
|
| 63 |
+
mask: Uint8Array
|
| 64 |
region?: InpaintRegion
|
| 65 |
}
|
| 66 |
let pending: MaskUpdate[] = []
|
|
|
|
| 176 |
fetchAvailableFonts: () => Promise<void>
|
| 177 |
updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
|
| 178 |
updateMask: (
|
| 179 |
+
mask: Uint8Array,
|
| 180 |
options?: {
|
| 181 |
sync?: boolean
|
| 182 |
+
patch?: Uint8Array
|
| 183 |
patchRegion?: InpaintRegion
|
| 184 |
},
|
| 185 |
) => Promise<void>
|
| 186 |
paintRendered: (
|
| 187 |
+
patch: Uint8Array,
|
| 188 |
region: InpaintRegion,
|
| 189 |
options?: { index?: number },
|
| 190 |
) => Promise<void>
|
|
|
|
| 265 |
|
| 266 |
set({ currentDocumentLoading: true })
|
| 267 |
try {
|
| 268 |
+
const doc = await invoke('get_document', { index })
|
| 269 |
// Only update if we're still on the same index
|
| 270 |
if (get().currentDocumentIndex === index) {
|
| 271 |
set({ currentDocument: doc, currentDocumentLoading: false })
|
|
|
|
| 283 |
openDocuments: async () => {
|
| 284 |
get().startOperation({ type: 'load-khr', cancellable: false })
|
| 285 |
try {
|
| 286 |
+
const count = await invoke('open_documents')
|
| 287 |
get().setTotalPages(count)
|
| 288 |
} finally {
|
| 289 |
get().finishOperation()
|
|
|
|
| 364 |
setRenderEffect: (effect: RenderEffect) => set({ renderEffect: effect }),
|
| 365 |
fetchAvailableFonts: async () => {
|
| 366 |
try {
|
| 367 |
+
const fonts = await invoke('list_font_families')
|
| 368 |
set({ availableFonts: fonts })
|
| 369 |
} catch (_) {}
|
| 370 |
},
|
|
|
|
| 484 |
},
|
| 485 |
llmList: async () => {
|
| 486 |
try {
|
| 487 |
+
const models = await invoke('llm_list')
|
| 488 |
set({ llmModels: models })
|
| 489 |
const currentModel = get().llmSelectedModel
|
| 490 |
const currentLanguage = get().llmSelectedLanguage
|
|
|
|
| 574 |
return
|
| 575 |
}
|
| 576 |
try {
|
| 577 |
+
llmReadyCheckInFlight = invoke('llm_ready')
|
| 578 |
const ready = await llmReadyCheckInFlight
|
| 579 |
set({ llmReady: ready })
|
| 580 |
} catch (_) {}
|
ui/lib/util.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
| 4 |
|
| 5 |
-
export function
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
const blob = convertToBlob(bytes)
|
| 7 |
return createImageBitmap(blob)
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export async function blobToUint8Array(blob: Blob): Promise<
|
| 11 |
const buffer = await blob.arrayBuffer()
|
| 12 |
-
return
|
| 13 |
}
|
|
|
|
| 1 |
+
/** Extract a standalone ArrayBuffer from a Uint8Array view (msgpack may return views into a shared decode buffer). */
|
| 2 |
+
export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
| 3 |
+
return bytes.buffer.slice(
|
| 4 |
+
bytes.byteOffset,
|
| 5 |
+
bytes.byteOffset + bytes.byteLength,
|
| 6 |
+
) as ArrayBuffer
|
| 7 |
}
|
| 8 |
|
| 9 |
+
export function convertToBlob(bytes: Uint8Array): Blob {
|
| 10 |
+
return new Blob([toArrayBuffer(bytes)])
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function convertToImageBitmap(bytes: Uint8Array): Promise<ImageBitmap> {
|
| 14 |
const blob = convertToBlob(bytes)
|
| 15 |
return createImageBitmap(blob)
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
| 19 |
const buffer = await blob.arrayBuffer()
|
| 20 |
+
return new Uint8Array(buffer)
|
| 21 |
}
|
ui/lib/ws.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { encode, decode } from '@msgpack/msgpack'
|
| 4 |
+
|
| 5 |
+
type OutgoingRequest = {
|
| 6 |
+
type: 'req'
|
| 7 |
+
id: number
|
| 8 |
+
method: string
|
| 9 |
+
params?: unknown
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
type IncomingResponse = {
|
| 13 |
+
type: 'res'
|
| 14 |
+
id: number
|
| 15 |
+
result?: unknown
|
| 16 |
+
error?: string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
type IncomingNotification = {
|
| 20 |
+
type: 'ntf'
|
| 21 |
+
method: string
|
| 22 |
+
params: unknown
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type IncomingMessage = IncomingResponse | IncomingNotification
|
| 26 |
+
|
| 27 |
+
type PendingRequest = {
|
| 28 |
+
resolve: (value: unknown) => void
|
| 29 |
+
reject: (reason: Error) => void
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export class WsRpcClient {
|
| 33 |
+
private ws: WebSocket | null = null
|
| 34 |
+
private nextId = 1
|
| 35 |
+
private pending = new Map<number, PendingRequest>()
|
| 36 |
+
private notificationHandlers = new Map<
|
| 37 |
+
string,
|
| 38 |
+
Set<(params: unknown) => void>
|
| 39 |
+
>()
|
| 40 |
+
private url: string
|
| 41 |
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
| 42 |
+
private closed = false
|
| 43 |
+
private readyQueue: Array<() => void> = []
|
| 44 |
+
|
| 45 |
+
constructor(url: string) {
|
| 46 |
+
this.url = url
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
connect(): void {
|
| 50 |
+
if (this.ws) return
|
| 51 |
+
this.closed = false
|
| 52 |
+
|
| 53 |
+
const ws = new WebSocket(this.url)
|
| 54 |
+
ws.binaryType = 'arraybuffer'
|
| 55 |
+
|
| 56 |
+
ws.onopen = () => {
|
| 57 |
+
if (this.reconnectTimer) {
|
| 58 |
+
clearTimeout(this.reconnectTimer)
|
| 59 |
+
this.reconnectTimer = null
|
| 60 |
+
}
|
| 61 |
+
const queue = this.readyQueue.splice(0)
|
| 62 |
+
for (const fn of queue) fn()
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
ws.onmessage = (event: MessageEvent) => {
|
| 66 |
+
if (!(event.data instanceof ArrayBuffer)) return
|
| 67 |
+
let msg: IncomingMessage
|
| 68 |
+
try {
|
| 69 |
+
msg = decode(new Uint8Array(event.data)) as IncomingMessage
|
| 70 |
+
} catch {
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (msg.type === 'res') {
|
| 75 |
+
const p = this.pending.get(msg.id)
|
| 76 |
+
if (p) {
|
| 77 |
+
this.pending.delete(msg.id)
|
| 78 |
+
if (msg.error) {
|
| 79 |
+
p.reject(new Error(msg.error))
|
| 80 |
+
} else {
|
| 81 |
+
p.resolve(msg.result)
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
} else if (msg.type === 'ntf') {
|
| 85 |
+
const handlers = this.notificationHandlers.get(msg.method)
|
| 86 |
+
if (handlers) {
|
| 87 |
+
for (const handler of handlers) {
|
| 88 |
+
try {
|
| 89 |
+
handler(msg.params)
|
| 90 |
+
} catch {}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
ws.onclose = () => {
|
| 97 |
+
this.ws = null
|
| 98 |
+
// Reject queued and pending requests
|
| 99 |
+
const queue = this.readyQueue.splice(0)
|
| 100 |
+
for (const fn of queue) fn()
|
| 101 |
+
for (const [, p] of this.pending) {
|
| 102 |
+
p.reject(new Error('WebSocket closed'))
|
| 103 |
+
}
|
| 104 |
+
this.pending.clear()
|
| 105 |
+
|
| 106 |
+
if (!this.closed) {
|
| 107 |
+
this.scheduleReconnect()
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
ws.onerror = () => {
|
| 112 |
+
// onclose will fire after this
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
this.ws = ws
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
close(): void {
|
| 119 |
+
this.closed = true
|
| 120 |
+
if (this.reconnectTimer) {
|
| 121 |
+
clearTimeout(this.reconnectTimer)
|
| 122 |
+
this.reconnectTimer = null
|
| 123 |
+
}
|
| 124 |
+
this.ws?.close()
|
| 125 |
+
this.ws = null
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
invoke<T = unknown>(method: string, params?: unknown): Promise<T> {
|
| 129 |
+
return new Promise((resolve, reject) => {
|
| 130 |
+
const send = () => {
|
| 131 |
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
| 132 |
+
reject(new Error('WebSocket not connected'))
|
| 133 |
+
return
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const id = this.nextId++
|
| 137 |
+
this.pending.set(id, {
|
| 138 |
+
resolve: resolve as (v: unknown) => void,
|
| 139 |
+
reject,
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
const msg: OutgoingRequest = { type: 'req', id, method }
|
| 143 |
+
if (params !== undefined) {
|
| 144 |
+
msg.params = params
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const bytes = encode(msg)
|
| 148 |
+
this.ws.send(bytes)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
| 152 |
+
this.readyQueue.push(send)
|
| 153 |
+
} else {
|
| 154 |
+
send()
|
| 155 |
+
}
|
| 156 |
+
})
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
onNotification<T = unknown>(
|
| 160 |
+
method: string,
|
| 161 |
+
handler: (params: T) => void,
|
| 162 |
+
): () => void {
|
| 163 |
+
let handlers = this.notificationHandlers.get(method)
|
| 164 |
+
if (!handlers) {
|
| 165 |
+
handlers = new Set()
|
| 166 |
+
this.notificationHandlers.set(method, handlers)
|
| 167 |
+
}
|
| 168 |
+
const wrapped = handler as (params: unknown) => void
|
| 169 |
+
handlers.add(wrapped)
|
| 170 |
+
return () => {
|
| 171 |
+
handlers!.delete(wrapped)
|
| 172 |
+
if (handlers!.size === 0) {
|
| 173 |
+
this.notificationHandlers.delete(method)
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
get connected(): boolean {
|
| 179 |
+
return this.ws?.readyState === WebSocket.OPEN
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
private scheduleReconnect(): void {
|
| 183 |
+
if (this.reconnectTimer) return
|
| 184 |
+
this.reconnectTimer = setTimeout(() => {
|
| 185 |
+
this.reconnectTimer = null
|
| 186 |
+
this.connect()
|
| 187 |
+
}, 1000)
|
| 188 |
+
}
|
| 189 |
+
}
|
ui/package.json
CHANGED
|
@@ -39,6 +39,7 @@
|
|
| 39 |
"react-rnd": "^10.5.2",
|
| 40 |
"remark-gfm": "^4.0.1",
|
| 41 |
"tailwind-merge": "^3.4.0",
|
|
|
|
| 42 |
"zustand": "^5.0.11"
|
| 43 |
},
|
| 44 |
"devDependencies": {
|
|
|
|
| 39 |
"react-rnd": "^10.5.2",
|
| 40 |
"remark-gfm": "^4.0.1",
|
| 41 |
"tailwind-merge": "^3.4.0",
|
| 42 |
+
"@msgpack/msgpack": "^3.0.0-beta.3",
|
| 43 |
"zustand": "^5.0.11"
|
| 44 |
},
|
| 45 |
"devDependencies": {
|
ui/types.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export type TextBlock = {
|
|
| 36 |
translation?: string
|
| 37 |
style?: TextStyle
|
| 38 |
fontPrediction?: FontPrediction
|
| 39 |
-
rendered?:
|
| 40 |
}
|
| 41 |
|
| 42 |
export type ToolMode = 'select' | 'block' | 'brush' | 'repairBrush' | 'eraser'
|
|
@@ -52,12 +52,12 @@ export type Document = {
|
|
| 52 |
id: string
|
| 53 |
path: string
|
| 54 |
name: string
|
| 55 |
-
image:
|
| 56 |
width: number
|
| 57 |
height: number
|
| 58 |
textBlocks: TextBlock[]
|
| 59 |
-
segment?:
|
| 60 |
-
inpainted?:
|
| 61 |
-
brushLayer?:
|
| 62 |
-
rendered?:
|
| 63 |
}
|
|
|
|
| 36 |
translation?: string
|
| 37 |
style?: TextStyle
|
| 38 |
fontPrediction?: FontPrediction
|
| 39 |
+
rendered?: Uint8Array
|
| 40 |
}
|
| 41 |
|
| 42 |
export type ToolMode = 'select' | 'block' | 'brush' | 'repairBrush' | 'eraser'
|
|
|
|
| 52 |
id: string
|
| 53 |
path: string
|
| 54 |
name: string
|
| 55 |
+
image: Uint8Array
|
| 56 |
width: number
|
| 57 |
height: number
|
| 58 |
textBlocks: TextBlock[]
|
| 59 |
+
segment?: Uint8Array
|
| 60 |
+
inpainted?: Uint8Array
|
| 61 |
+
brushLayer?: Uint8Array
|
| 62 |
+
rendered?: Uint8Array
|
| 63 |
}
|