Mayo commited on
fix: make clippy happy
Browse files- bun.lock +3 -0
- koharu-app/bin/pipeline.rs +2 -4
- koharu-app/src/app.rs +1 -1
- koharu-app/src/config.rs +4 -4
- koharu-app/src/pipeline/engines/support.rs +9 -12
- koharu-app/src/pipeline/mod.rs +1 -0
- koharu-app/src/projects.rs +3 -5
- koharu-app/src/renderer.rs +12 -13
- koharu-core/src/op.rs +6 -0
- koharu-core/src/scene.rs +4 -0
- koharu-renderer/src/layout.rs +5 -4
- koharu-rpc/src/binary.rs +1 -1
- koharu-rpc/src/events.rs +1 -1
- koharu-rpc/src/psd_export.rs +23 -16
- koharu-rpc/src/routes/pages.rs +13 -15
- koharu-rpc/src/routes/projects.rs +7 -9
- koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap +194 -0
- tests/integration-tests/client/src/lib.rs +4 -1
- ui/package.json +1 -0
bun.lock
CHANGED
|
@@ -39,6 +39,7 @@
|
|
| 39 |
"@tanstack/react-query": "^5.99.0",
|
| 40 |
"@tanstack/react-virtual": "^3.13.24",
|
| 41 |
"@tauri-apps/api": "^2.10.1",
|
|
|
|
| 42 |
"@tauri-apps/plugin-opener": "^2.4.0",
|
| 43 |
"@tauri-apps/plugin-process": "^2.3.1",
|
| 44 |
"@tauri-apps/plugin-updater": "^2.10.1",
|
|
@@ -855,6 +856,8 @@
|
|
| 855 |
|
| 856 |
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
| 857 |
|
|
|
|
|
|
|
| 858 |
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ=="],
|
| 859 |
|
| 860 |
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
|
|
|
| 39 |
"@tanstack/react-query": "^5.99.0",
|
| 40 |
"@tanstack/react-virtual": "^3.13.24",
|
| 41 |
"@tauri-apps/api": "^2.10.1",
|
| 42 |
+
"@tauri-apps/plugin-dialog": "^2.7.0",
|
| 43 |
"@tauri-apps/plugin-opener": "^2.4.0",
|
| 44 |
"@tauri-apps/plugin-process": "^2.3.1",
|
| 45 |
"@tauri-apps/plugin-updater": "^2.10.1",
|
|
|
|
| 856 |
|
| 857 |
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
| 858 |
|
| 859 |
+
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
|
| 860 |
+
|
| 861 |
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ=="],
|
| 862 |
|
| 863 |
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
koharu-app/bin/pipeline.rs
CHANGED
|
@@ -253,10 +253,8 @@ async fn run() -> Result<()> {
|
|
| 253 |
Err(e) => eprintln!("=> pipeline failed: {e:#}"),
|
| 254 |
}
|
| 255 |
|
| 256 |
-
if ensure_translation_fallback {
|
| 257 |
-
|
| 258 |
-
eprintln!("warn: failed to synthesize translations: {e:#}");
|
| 259 |
-
}
|
| 260 |
}
|
| 261 |
|
| 262 |
dump_artifacts(&session, page_id, &cli.output_dir)
|
|
|
|
| 253 |
Err(e) => eprintln!("=> pipeline failed: {e:#}"),
|
| 254 |
}
|
| 255 |
|
| 256 |
+
if ensure_translation_fallback && let Err(e) = synthesize_translations(&app, page_id).await {
|
| 257 |
+
eprintln!("warn: failed to synthesize translations: {e:#}");
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
dump_artifacts(&session, page_id, &cli.output_dir)
|
koharu-app/src/app.rs
CHANGED
|
@@ -257,7 +257,7 @@ pub fn shared_llama_backend(runtime: &RuntimeManager) -> Result<Arc<LlamaBackend
|
|
| 257 |
if let Some(backend) = LLAMA_BACKEND.get() {
|
| 258 |
return Ok(backend.clone());
|
| 259 |
}
|
| 260 |
-
koharu_llm::sys::initialize(runtime)
|
| 261 |
let backend = Arc::new(LlamaBackend::init().map_err(anyhow::Error::from)?);
|
| 262 |
let _ = LLAMA_BACKEND.set(backend.clone());
|
| 263 |
Ok(backend)
|
|
|
|
| 257 |
if let Some(backend) = LLAMA_BACKEND.get() {
|
| 258 |
return Ok(backend.clone());
|
| 259 |
}
|
| 260 |
+
koharu_llm::sys::initialize(runtime)?;
|
| 261 |
let backend = Arc::new(LlamaBackend::init().map_err(anyhow::Error::from)?);
|
| 262 |
let _ = LLAMA_BACKEND.set(backend.clone());
|
| 263 |
Ok(backend)
|
koharu-app/src/config.rs
CHANGED
|
@@ -187,10 +187,10 @@ pub fn save(config: &AppConfig) -> Result<()> {
|
|
| 187 |
/// Apply a `ConfigPatch` in-place. Missing fields leave the existing value
|
| 188 |
/// alone. Providers are replaced wholesale (the list, not field-by-field).
|
| 189 |
pub fn apply_patch(config: &mut AppConfig, patch: koharu_core::ConfigPatch) {
|
| 190 |
-
if let Some(data) = patch.data
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
}
|
| 195 |
if let Some(http) = patch.http {
|
| 196 |
if let Some(v) = http.connect_timeout {
|
|
|
|
| 187 |
/// Apply a `ConfigPatch` in-place. Missing fields leave the existing value
|
| 188 |
/// alone. Providers are replaced wholesale (the list, not field-by-field).
|
| 189 |
pub fn apply_patch(config: &mut AppConfig, patch: koharu_core::ConfigPatch) {
|
| 190 |
+
if let Some(data) = patch.data
|
| 191 |
+
&& let Some(path) = data.path
|
| 192 |
+
{
|
| 193 |
+
config.data.path = camino::Utf8PathBuf::from(path);
|
| 194 |
}
|
| 195 |
if let Some(http) = patch.http {
|
| 196 |
if let Some(v) = http.connect_timeout {
|
koharu-app/src/pipeline/engines/support.rs
CHANGED
|
@@ -18,7 +18,7 @@ use crate::blobs::BlobStore;
|
|
| 18 |
|
| 19 |
/// Find the Source image node on `page`. Returns `(node_id, image_data)`.
|
| 20 |
/// Every valid page has exactly one; absence means the page is malformed.
|
| 21 |
-
pub fn source_node
|
| 22 |
let page = scene
|
| 23 |
.page(page)
|
| 24 |
.with_context(|| format!("page {} not found", page))?;
|
|
@@ -58,10 +58,7 @@ pub fn find_mask_node(scene: &Scene, page: PageId, role: MaskRole) -> Option<(No
|
|
| 58 |
|
| 59 |
/// Collect `(NodeId, &Transform, &TextData)` for every text node on `page`,
|
| 60 |
/// in stacking order.
|
| 61 |
-
pub fn text_nodes
|
| 62 |
-
scene: &'a Scene,
|
| 63 |
-
page: PageId,
|
| 64 |
-
) -> Vec<(NodeId, &'a Transform, &'a TextData)> {
|
| 65 |
let Some(page) = scene.page(page) else {
|
| 66 |
return Vec::new();
|
| 67 |
};
|
|
@@ -122,6 +119,7 @@ pub fn core_text_direction_to_ml(d: koharu_core::TextDirection) -> koharu_ml::ty
|
|
| 122 |
// ---------------------------------------------------------------------------
|
| 123 |
|
| 124 |
/// Build an `AddNode` for a new `Image { role }` layer.
|
|
|
|
| 125 |
pub fn add_image_node_op(
|
| 126 |
page: PageId,
|
| 127 |
role: ImageRole,
|
|
@@ -309,13 +307,12 @@ pub fn clear_text_nodes_ops(scene: &Scene, page: PageId) -> Vec<Op> {
|
|
| 309 |
.nodes
|
| 310 |
.iter()
|
| 311 |
.enumerate()
|
| 312 |
-
.
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
})
|
| 319 |
})
|
| 320 |
.collect()
|
| 321 |
}
|
|
|
|
| 18 |
|
| 19 |
/// Find the Source image node on `page`. Returns `(node_id, image_data)`.
|
| 20 |
/// Every valid page has exactly one; absence means the page is malformed.
|
| 21 |
+
pub fn source_node(scene: &Scene, page: PageId) -> Result<(NodeId, &ImageData)> {
|
| 22 |
let page = scene
|
| 23 |
.page(page)
|
| 24 |
.with_context(|| format!("page {} not found", page))?;
|
|
|
|
| 58 |
|
| 59 |
/// Collect `(NodeId, &Transform, &TextData)` for every text node on `page`,
|
| 60 |
/// in stacking order.
|
| 61 |
+
pub fn text_nodes(scene: &Scene, page: PageId) -> Vec<(NodeId, &Transform, &TextData)> {
|
|
|
|
|
|
|
|
|
|
| 62 |
let Some(page) = scene.page(page) else {
|
| 63 |
return Vec::new();
|
| 64 |
};
|
|
|
|
| 119 |
// ---------------------------------------------------------------------------
|
| 120 |
|
| 121 |
/// Build an `AddNode` for a new `Image { role }` layer.
|
| 122 |
+
#[allow(clippy::too_many_arguments)]
|
| 123 |
pub fn add_image_node_op(
|
| 124 |
page: PageId,
|
| 125 |
role: ImageRole,
|
|
|
|
| 307 |
.nodes
|
| 308 |
.iter()
|
| 309 |
.enumerate()
|
| 310 |
+
.filter(|(_, (_, node))| matches!(&node.kind, NodeKind::Text(_)))
|
| 311 |
+
.map(|(idx, (id, node))| Op::RemoveNode {
|
| 312 |
+
page,
|
| 313 |
+
id: *id,
|
| 314 |
+
prev_node: node.clone(),
|
| 315 |
+
prev_index: idx,
|
|
|
|
| 316 |
})
|
| 317 |
.collect()
|
| 318 |
}
|
koharu-app/src/pipeline/mod.rs
CHANGED
|
@@ -89,6 +89,7 @@ pub enum Scope {
|
|
| 89 |
|
| 90 |
/// Execute `spec` against `session`. Each engine step becomes one `Op::Batch`
|
| 91 |
/// applied via the session's history (one undo step per step per page).
|
|
|
|
| 92 |
#[tracing::instrument(level = "info", skip_all)]
|
| 93 |
pub async fn run(
|
| 94 |
session: Arc<ProjectSession>,
|
|
|
|
| 89 |
|
| 90 |
/// Execute `spec` against `session`. Each engine step becomes one `Op::Batch`
|
| 91 |
/// applied via the session's history (one undo step per step per page).
|
| 92 |
+
#[allow(clippy::too_many_arguments)]
|
| 93 |
#[tracing::instrument(level = "info", skip_all)]
|
| 94 |
pub async fn run(
|
| 95 |
session: Arc<ProjectSession>,
|
koharu-app/src/projects.rs
CHANGED
|
@@ -167,11 +167,9 @@ fn slugify(input: &str) -> String {
|
|
| 167 |
out.push('-');
|
| 168 |
prev_dash = true;
|
| 169 |
}
|
| 170 |
-
} else if c.is_whitespace() {
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
prev_dash = true;
|
| 174 |
-
}
|
| 175 |
}
|
| 176 |
// Other chars dropped silently.
|
| 177 |
}
|
|
|
|
| 167 |
out.push('-');
|
| 168 |
prev_dash = true;
|
| 169 |
}
|
| 170 |
+
} else if c.is_whitespace() && !out.is_empty() && !prev_dash {
|
| 171 |
+
out.push('-');
|
| 172 |
+
prev_dash = true;
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
// Other chars dropped silently.
|
| 175 |
}
|
koharu-app/src/renderer.rs
CHANGED
|
@@ -117,7 +117,7 @@ impl Renderer {
|
|
| 117 |
.first()
|
| 118 |
.map(|(family, _)| family.clone())
|
| 119 |
.unwrap_or_else(|| face.post_script_name.clone());
|
| 120 |
-
seen.insert(family_name.clone()).
|
| 121 |
family_name,
|
| 122 |
post_script_name: face.post_script_name,
|
| 123 |
source: FontSource::System,
|
|
@@ -146,6 +146,7 @@ impl Renderer {
|
|
| 146 |
/// the full page + per-block sprites. Blocks with an empty translation
|
| 147 |
/// are skipped (they appear as holes in the composite, falling through to
|
| 148 |
/// the inpainted plane).
|
|
|
|
| 149 |
#[tracing::instrument(level = "info", skip_all, fields(blocks = blocks.len()))]
|
| 150 |
pub fn render_page(
|
| 151 |
&self,
|
|
@@ -186,8 +187,7 @@ impl Renderer {
|
|
| 186 |
imageops::overlay(&mut canvas, &brush.to_rgba8(), 0, 0);
|
| 187 |
}
|
| 188 |
for out in &rendered_blocks {
|
| 189 |
-
let (x, y) =
|
| 190 |
-
placement_origin(&find_input(blocks, out.node_id), &out.expanded_transform);
|
| 191 |
imageops::overlay(&mut canvas, &out.sprite.to_rgba8(), x as i64, y as i64);
|
| 192 |
}
|
| 193 |
Ok(RenderOutput {
|
|
@@ -237,11 +237,10 @@ impl Renderer {
|
|
| 237 |
|
| 238 |
let font = self.select_font(&style)?;
|
| 239 |
let block_effect = style.effect.unwrap_or(*effect);
|
| 240 |
-
let color = style
|
| 241 |
-
.
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
.unwrap_or_else(|| {
|
| 245 |
if block.style.is_some() {
|
| 246 |
style.color
|
| 247 |
} else if let Some(pred) = &block.font_prediction {
|
|
@@ -254,7 +253,8 @@ impl Renderer {
|
|
| 254 |
} else {
|
| 255 |
[0, 0, 0, 255]
|
| 256 |
}
|
| 257 |
-
}
|
|
|
|
| 258 |
|
| 259 |
let writing_mode = writing_mode_for_block(&layout_source);
|
| 260 |
// Translations default to centre alignment inside a bubble — each
|
|
@@ -296,13 +296,12 @@ impl Renderer {
|
|
| 296 |
// Re-run the layout with `max_width = actual_content_width` so
|
| 297 |
// every line is centred relative to the block's widest line.
|
| 298 |
let layout = if layout.width > layout_box.width + 0.5 {
|
| 299 |
-
|
| 300 |
.clone()
|
| 301 |
.with_font_size(layout.font_size)
|
| 302 |
.with_max_width(layout.width)
|
| 303 |
.with_max_height(layout_box.height)
|
| 304 |
-
.run(&normalized)?
|
| 305 |
-
expanded
|
| 306 |
} else {
|
| 307 |
layout
|
| 308 |
};
|
|
@@ -588,7 +587,7 @@ fn core_align_to_renderer(a: koharu_core::TextAlign) -> RendererTextAlign {
|
|
| 588 |
// Helpers: placement
|
| 589 |
// ---------------------------------------------------------------------------
|
| 590 |
|
| 591 |
-
fn find_input
|
| 592 |
blocks
|
| 593 |
.iter()
|
| 594 |
.find(|b| b.node_id == id)
|
|
|
|
| 117 |
.first()
|
| 118 |
.map(|(family, _)| family.clone())
|
| 119 |
.unwrap_or_else(|| face.post_script_name.clone());
|
| 120 |
+
seen.insert(family_name.clone()).then_some(FontFaceInfo {
|
| 121 |
family_name,
|
| 122 |
post_script_name: face.post_script_name,
|
| 123 |
source: FontSource::System,
|
|
|
|
| 146 |
/// the full page + per-block sprites. Blocks with an empty translation
|
| 147 |
/// are skipped (they appear as holes in the composite, falling through to
|
| 148 |
/// the inpainted plane).
|
| 149 |
+
#[allow(clippy::too_many_arguments)]
|
| 150 |
#[tracing::instrument(level = "info", skip_all, fields(blocks = blocks.len()))]
|
| 151 |
pub fn render_page(
|
| 152 |
&self,
|
|
|
|
| 187 |
imageops::overlay(&mut canvas, &brush.to_rgba8(), 0, 0);
|
| 188 |
}
|
| 189 |
for out in &rendered_blocks {
|
| 190 |
+
let (x, y) = placement_origin(find_input(blocks, out.node_id), &out.expanded_transform);
|
|
|
|
| 191 |
imageops::overlay(&mut canvas, &out.sprite.to_rgba8(), x as i64, y as i64);
|
| 192 |
}
|
| 193 |
Ok(RenderOutput {
|
|
|
|
| 237 |
|
| 238 |
let font = self.select_font(&style)?;
|
| 239 |
let block_effect = style.effect.unwrap_or(*effect);
|
| 240 |
+
let color = if style.effect.is_some() {
|
| 241 |
+
style.color
|
| 242 |
+
} else {
|
| 243 |
+
{
|
|
|
|
| 244 |
if block.style.is_some() {
|
| 245 |
style.color
|
| 246 |
} else if let Some(pred) = &block.font_prediction {
|
|
|
|
| 253 |
} else {
|
| 254 |
[0, 0, 0, 255]
|
| 255 |
}
|
| 256 |
+
}
|
| 257 |
+
};
|
| 258 |
|
| 259 |
let writing_mode = writing_mode_for_block(&layout_source);
|
| 260 |
// Translations default to centre alignment inside a bubble — each
|
|
|
|
| 296 |
// Re-run the layout with `max_width = actual_content_width` so
|
| 297 |
// every line is centred relative to the block's widest line.
|
| 298 |
let layout = if layout.width > layout_box.width + 0.5 {
|
| 299 |
+
layout_builder
|
| 300 |
.clone()
|
| 301 |
.with_font_size(layout.font_size)
|
| 302 |
.with_max_width(layout.width)
|
| 303 |
.with_max_height(layout_box.height)
|
| 304 |
+
.run(&normalized)?
|
|
|
|
| 305 |
} else {
|
| 306 |
layout
|
| 307 |
};
|
|
|
|
| 587 |
// Helpers: placement
|
| 588 |
// ---------------------------------------------------------------------------
|
| 589 |
|
| 590 |
+
fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput {
|
| 591 |
blocks
|
| 592 |
.iter()
|
| 593 |
.find(|b| b.node_id == id)
|
koharu-core/src/op.rs
CHANGED
|
@@ -10,6 +10,12 @@
|
|
| 10 |
//! let undo = op.inverse(); // pure
|
| 11 |
//! ```
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
use chrono::{DateTime, Utc};
|
| 14 |
use schemars::JsonSchema;
|
| 15 |
use serde::{Deserialize, Serialize};
|
|
|
|
| 10 |
//! let undo = op.inverse(); // pure
|
| 11 |
//! ```
|
| 12 |
|
| 13 |
+
// `Op` / `NodeDataPatch` are wire-format data types; their variant-size
|
| 14 |
+
// asymmetry is inherent (Text patches carry many optional fields), and
|
| 15 |
+
// boxing would change the serialised representation. Silence clippy here
|
| 16 |
+
// rather than smear `#[allow]` across every producer.
|
| 17 |
+
#![allow(clippy::large_enum_variant)]
|
| 18 |
+
|
| 19 |
use chrono::{DateTime, Utc};
|
| 20 |
use schemars::JsonSchema;
|
| 21 |
use serde::{Deserialize, Serialize};
|
koharu-core/src/scene.rs
CHANGED
|
@@ -3,6 +3,10 @@
|
|
| 3 |
//! Three primitives: `Node`, `Blob` (via `BlobRef`), `Op` (in `op.rs`).
|
| 4 |
//! Everything visual on a page is a `Node`; scene mutations flow through `Op`s.
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
use chrono::{DateTime, Utc};
|
| 7 |
use indexmap::IndexMap;
|
| 8 |
use schemars::JsonSchema;
|
|
|
|
| 3 |
//! Three primitives: `Node`, `Blob` (via `BlobRef`), `Op` (in `op.rs`).
|
| 4 |
//! Everything visual on a page is a `Node`; scene mutations flow through `Op`s.
|
| 5 |
|
| 6 |
+
// `NodeKind::Text` naturally carries more data than `Image`/`Mask`, and
|
| 7 |
+
// boxing would change the wire format. Same reasoning as in `op.rs`.
|
| 8 |
+
#![allow(clippy::large_enum_variant)]
|
| 9 |
+
|
| 10 |
use chrono::{DateTime, Utc};
|
| 11 |
use indexmap::IndexMap;
|
| 12 |
use schemars::JsonSchema;
|
koharu-renderer/src/layout.rs
CHANGED
|
@@ -636,10 +636,11 @@ mod tests {
|
|
| 636 |
}
|
| 637 |
|
| 638 |
fn assert_approx_eq(actual: f32, expected: f32) {
|
| 639 |
-
if actual.is_infinite()
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
|
|
|
| 643 |
}
|
| 644 |
let eps = 1e-4;
|
| 645 |
assert!(
|
|
|
|
| 636 |
}
|
| 637 |
|
| 638 |
fn assert_approx_eq(actual: f32, expected: f32) {
|
| 639 |
+
if actual.is_infinite()
|
| 640 |
+
&& expected.is_infinite()
|
| 641 |
+
&& actual.is_sign_positive() == expected.is_sign_positive()
|
| 642 |
+
{
|
| 643 |
+
return;
|
| 644 |
}
|
| 645 |
let eps = 1e-4;
|
| 646 |
assert!(
|
koharu-rpc/src/binary.rs
CHANGED
|
@@ -66,7 +66,7 @@ async fn get_scene_bin(State(app): State<AppState>) -> ApiResult<Response> {
|
|
| 66 |
let epoch = session.epoch();
|
| 67 |
let bytes = postcard::to_allocvec(&WireSnapshot {
|
| 68 |
epoch,
|
| 69 |
-
scene: &
|
| 70 |
})
|
| 71 |
.map_err(|e| ApiError::internal(anyhow::Error::new(e)))?;
|
| 72 |
(epoch, bytes)
|
|
|
|
| 66 |
let epoch = session.epoch();
|
| 67 |
let bytes = postcard::to_allocvec(&WireSnapshot {
|
| 68 |
epoch,
|
| 69 |
+
scene: &scene,
|
| 70 |
})
|
| 71 |
.map_err(|e| ApiError::internal(anyhow::Error::new(e)))?;
|
| 72 |
(epoch, bytes)
|
koharu-rpc/src/events.rs
CHANGED
|
@@ -101,7 +101,7 @@ async fn events(
|
|
| 101 |
fn snapshot_frame(app: &AppState) -> Result<Event, axum::Error> {
|
| 102 |
let snap = snapshot_from(app);
|
| 103 |
Event::default()
|
| 104 |
-
.json_data(
|
| 105 |
.map_err(axum::Error::new)
|
| 106 |
}
|
| 107 |
|
|
|
|
| 101 |
fn snapshot_frame(app: &AppState) -> Result<Event, axum::Error> {
|
| 102 |
let snap = snapshot_from(app);
|
| 103 |
Event::default()
|
| 104 |
+
.json_data(AppEvent::Snapshot(snap))
|
| 105 |
.map_err(axum::Error::new)
|
| 106 |
}
|
| 107 |
|
koharu-rpc/src/psd_export.rs
CHANGED
|
@@ -19,6 +19,17 @@ use koharu_psd::{
|
|
| 19 |
write_document,
|
| 20 |
};
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
/// Encode a single page as PSD bytes.
|
| 23 |
pub fn psd_bytes_for_page(session: &Arc<ProjectSession>, page_id: PageId) -> Result<Vec<u8>> {
|
| 24 |
let scene: Scene = session.scene_snapshot();
|
|
@@ -26,8 +37,15 @@ pub fn psd_bytes_for_page(session: &Arc<ProjectSession>, page_id: PageId) -> Res
|
|
| 26 |
.pages
|
| 27 |
.get(&page_id)
|
| 28 |
.ok_or_else(|| anyhow::anyhow!("page {page_id} not found"))?;
|
| 29 |
-
let
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
let resolved = ResolvedDocument {
|
| 32 |
document: &doc,
|
| 33 |
source: &source,
|
|
@@ -43,18 +61,7 @@ pub fn psd_bytes_for_page(session: &Arc<ProjectSession>, page_id: PageId) -> Res
|
|
| 43 |
Ok(buf)
|
| 44 |
}
|
| 45 |
|
| 46 |
-
fn resolve_page_blobs(
|
| 47 |
-
session: &ProjectSession,
|
| 48 |
-
page: &koharu_core::Page,
|
| 49 |
-
) -> Result<(
|
| 50 |
-
PsdDocument,
|
| 51 |
-
DynamicImage,
|
| 52 |
-
Option<DynamicImage>,
|
| 53 |
-
Option<DynamicImage>,
|
| 54 |
-
Option<DynamicImage>,
|
| 55 |
-
Option<DynamicImage>,
|
| 56 |
-
HashMap<PsdBlobRef, DynamicImage>,
|
| 57 |
-
)> {
|
| 58 |
let mut source: Option<DynamicImage> = None;
|
| 59 |
let mut segment: Option<DynamicImage> = None;
|
| 60 |
let mut inpainted: Option<DynamicImage> = None;
|
|
@@ -105,7 +112,7 @@ fn resolve_page_blobs(
|
|
| 105 |
height: page.height,
|
| 106 |
text_blocks,
|
| 107 |
};
|
| 108 |
-
Ok(
|
| 109 |
doc,
|
| 110 |
source,
|
| 111 |
segment,
|
|
@@ -113,7 +120,7 @@ fn resolve_page_blobs(
|
|
| 113 |
rendered,
|
| 114 |
brush,
|
| 115 |
block_images,
|
| 116 |
-
)
|
| 117 |
}
|
| 118 |
|
| 119 |
/// Encode a single page's image for `role` as PNG bytes. Returns `None` if
|
|
|
|
| 19 |
write_document,
|
| 20 |
};
|
| 21 |
|
| 22 |
+
/// Resolved page artifacts ready to hand to `koharu-psd`.
|
| 23 |
+
struct ResolvedPage {
|
| 24 |
+
doc: PsdDocument,
|
| 25 |
+
source: DynamicImage,
|
| 26 |
+
segment: Option<DynamicImage>,
|
| 27 |
+
inpainted: Option<DynamicImage>,
|
| 28 |
+
rendered: Option<DynamicImage>,
|
| 29 |
+
brush: Option<DynamicImage>,
|
| 30 |
+
block_images: HashMap<PsdBlobRef, DynamicImage>,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
/// Encode a single page as PSD bytes.
|
| 34 |
pub fn psd_bytes_for_page(session: &Arc<ProjectSession>, page_id: PageId) -> Result<Vec<u8>> {
|
| 35 |
let scene: Scene = session.scene_snapshot();
|
|
|
|
| 37 |
.pages
|
| 38 |
.get(&page_id)
|
| 39 |
.ok_or_else(|| anyhow::anyhow!("page {page_id} not found"))?;
|
| 40 |
+
let ResolvedPage {
|
| 41 |
+
doc,
|
| 42 |
+
source,
|
| 43 |
+
segment,
|
| 44 |
+
inpainted,
|
| 45 |
+
rendered,
|
| 46 |
+
brush,
|
| 47 |
+
block_images,
|
| 48 |
+
} = resolve_page_blobs(session, page).with_context(|| format!("page {page_id}"))?;
|
| 49 |
let resolved = ResolvedDocument {
|
| 50 |
document: &doc,
|
| 51 |
source: &source,
|
|
|
|
| 61 |
Ok(buf)
|
| 62 |
}
|
| 63 |
|
| 64 |
+
fn resolve_page_blobs(session: &ProjectSession, page: &koharu_core::Page) -> Result<ResolvedPage> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
let mut source: Option<DynamicImage> = None;
|
| 66 |
let mut segment: Option<DynamicImage> = None;
|
| 67 |
let mut inpainted: Option<DynamicImage> = None;
|
|
|
|
| 112 |
height: page.height,
|
| 113 |
text_blocks,
|
| 114 |
};
|
| 115 |
+
Ok(ResolvedPage {
|
| 116 |
doc,
|
| 117 |
source,
|
| 118 |
segment,
|
|
|
|
| 120 |
rendered,
|
| 121 |
brush,
|
| 122 |
block_images,
|
| 123 |
+
})
|
| 124 |
}
|
| 125 |
|
| 126 |
/// Encode a single page's image for `role` as PNG bytes. Returns `None` if
|
koharu-rpc/src/routes/pages.rs
CHANGED
|
@@ -346,24 +346,22 @@ async fn add_image_layer(
|
|
| 346 |
.len()
|
| 347 |
};
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
.next_field()
|
| 353 |
.await
|
| 354 |
.map_err(|e| ApiError::bad_request(format!("multipart: {e}")))?
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
}
|
| 366 |
-
let bytes = bytes.ok_or_else(|| ApiError::bad_request("no file uploaded"))?;
|
| 367 |
|
| 368 |
let decoded = image::load_from_memory(&bytes)
|
| 369 |
.map_err(|e| ApiError::bad_request(format!("decode: {e}")))?;
|
|
|
|
| 346 |
.len()
|
| 347 |
};
|
| 348 |
|
| 349 |
+
// The handler only accepts a single image layer per request, so we
|
| 350 |
+
// pull the first multipart field and ignore the rest.
|
| 351 |
+
let field = multipart
|
| 352 |
.next_field()
|
| 353 |
.await
|
| 354 |
.map_err(|e| ApiError::bad_request(format!("multipart: {e}")))?
|
| 355 |
+
.ok_or_else(|| ApiError::bad_request("no file uploaded"))?;
|
| 356 |
+
let filename = field
|
| 357 |
+
.file_name()
|
| 358 |
+
.map(|s| s.to_string())
|
| 359 |
+
.unwrap_or_else(|| String::from("layer.png"));
|
| 360 |
+
let bytes = field
|
| 361 |
+
.bytes()
|
| 362 |
+
.await
|
| 363 |
+
.map_err(|e| ApiError::bad_request(format!("read file: {e}")))?
|
| 364 |
+
.to_vec();
|
|
|
|
|
|
|
| 365 |
|
| 366 |
let decoded = image::load_from_memory(&bytes)
|
| 367 |
.map_err(|e| ApiError::bad_request(format!("decode: {e}")))?;
|
koharu-rpc/src/routes/projects.rs
CHANGED
|
@@ -2,14 +2,12 @@
|
|
| 2 |
//! `{data.path}/projects/` directory; clients never supply filesystem
|
| 3 |
//! paths. A project's `id` is the `.khrproj/` directory basename.
|
| 4 |
//!
|
| 5 |
-
//! - `GET /projects`
|
| 6 |
-
//! - `POST /projects`
|
| 7 |
-
//!
|
| 8 |
-
//! - `
|
| 9 |
-
//!
|
| 10 |
-
//! - `
|
| 11 |
-
//! - `DELETE /projects/current` — close current session
|
| 12 |
-
//! - `POST /projects/current/export` — export current; returns bytes
|
| 13 |
|
| 14 |
use axum::Json;
|
| 15 |
use axum::body::{Body, Bytes};
|
|
@@ -311,7 +309,7 @@ async fn export_image_role(
|
|
| 311 |
"no pages have the requested layer populated",
|
| 312 |
));
|
| 313 |
}
|
| 314 |
-
|
| 315 |
}
|
| 316 |
|
| 317 |
fn resolve_page_ids(
|
|
|
|
| 2 |
//! `{data.path}/projects/` directory; clients never supply filesystem
|
| 3 |
//! paths. A project's `id` is the `.khrproj/` directory basename.
|
| 4 |
//!
|
| 5 |
+
//! - `GET /projects` — list managed projects
|
| 6 |
+
//! - `POST /projects` — create a new project (`{name}`), server allocates path
|
| 7 |
+
//! - `POST /projects/import` — extract a `.khr` archive into a fresh dir + open
|
| 8 |
+
//! - `PUT /projects/current` — open a managed project by `id`
|
| 9 |
+
//! - `DELETE /projects/current` — close current session
|
| 10 |
+
//! - `POST /projects/current/export` — export current; returns bytes
|
|
|
|
|
|
|
| 11 |
|
| 12 |
use axum::Json;
|
| 13 |
use axum::body::{Body, Bytes};
|
|
|
|
| 309 |
"no pages have the requested layer populated",
|
| 310 |
));
|
| 311 |
}
|
| 312 |
+
files_to_response(files, project_name, role_ext(role))
|
| 313 |
}
|
| 314 |
|
| 315 |
fn resolve_page_ids(
|
koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: koharu-rpc/tests/openapi.rs
|
| 3 |
+
assertion_line: 34
|
| 4 |
+
expression: paths
|
| 5 |
+
---
|
| 6 |
+
[
|
| 7 |
+
(
|
| 8 |
+
"/blobs/{hash}",
|
| 9 |
+
[
|
| 10 |
+
"get",
|
| 11 |
+
],
|
| 12 |
+
),
|
| 13 |
+
(
|
| 14 |
+
"/config",
|
| 15 |
+
[
|
| 16 |
+
"get",
|
| 17 |
+
"patch",
|
| 18 |
+
],
|
| 19 |
+
),
|
| 20 |
+
(
|
| 21 |
+
"/config/providers/{id}/secret",
|
| 22 |
+
[
|
| 23 |
+
"delete",
|
| 24 |
+
"put",
|
| 25 |
+
],
|
| 26 |
+
),
|
| 27 |
+
(
|
| 28 |
+
"/downloads",
|
| 29 |
+
[
|
| 30 |
+
"get",
|
| 31 |
+
"post",
|
| 32 |
+
],
|
| 33 |
+
),
|
| 34 |
+
(
|
| 35 |
+
"/engines",
|
| 36 |
+
[
|
| 37 |
+
"get",
|
| 38 |
+
],
|
| 39 |
+
),
|
| 40 |
+
(
|
| 41 |
+
"/events",
|
| 42 |
+
[
|
| 43 |
+
"get",
|
| 44 |
+
],
|
| 45 |
+
),
|
| 46 |
+
(
|
| 47 |
+
"/fonts",
|
| 48 |
+
[
|
| 49 |
+
"get",
|
| 50 |
+
],
|
| 51 |
+
),
|
| 52 |
+
(
|
| 53 |
+
"/google-fonts",
|
| 54 |
+
[
|
| 55 |
+
"get",
|
| 56 |
+
],
|
| 57 |
+
),
|
| 58 |
+
(
|
| 59 |
+
"/google-fonts/{family}/fetch",
|
| 60 |
+
[
|
| 61 |
+
"post",
|
| 62 |
+
],
|
| 63 |
+
),
|
| 64 |
+
(
|
| 65 |
+
"/google-fonts/{family}/{file}",
|
| 66 |
+
[
|
| 67 |
+
"get",
|
| 68 |
+
],
|
| 69 |
+
),
|
| 70 |
+
(
|
| 71 |
+
"/history/apply",
|
| 72 |
+
[
|
| 73 |
+
"post",
|
| 74 |
+
],
|
| 75 |
+
),
|
| 76 |
+
(
|
| 77 |
+
"/history/redo",
|
| 78 |
+
[
|
| 79 |
+
"post",
|
| 80 |
+
],
|
| 81 |
+
),
|
| 82 |
+
(
|
| 83 |
+
"/history/undo",
|
| 84 |
+
[
|
| 85 |
+
"post",
|
| 86 |
+
],
|
| 87 |
+
),
|
| 88 |
+
(
|
| 89 |
+
"/llm/catalog",
|
| 90 |
+
[
|
| 91 |
+
"get",
|
| 92 |
+
],
|
| 93 |
+
),
|
| 94 |
+
(
|
| 95 |
+
"/llm/current",
|
| 96 |
+
[
|
| 97 |
+
"delete",
|
| 98 |
+
"get",
|
| 99 |
+
"put",
|
| 100 |
+
],
|
| 101 |
+
),
|
| 102 |
+
(
|
| 103 |
+
"/meta",
|
| 104 |
+
[
|
| 105 |
+
"get",
|
| 106 |
+
],
|
| 107 |
+
),
|
| 108 |
+
(
|
| 109 |
+
"/operations",
|
| 110 |
+
[
|
| 111 |
+
"get",
|
| 112 |
+
],
|
| 113 |
+
),
|
| 114 |
+
(
|
| 115 |
+
"/operations/{id}",
|
| 116 |
+
[
|
| 117 |
+
"delete",
|
| 118 |
+
],
|
| 119 |
+
),
|
| 120 |
+
(
|
| 121 |
+
"/pages",
|
| 122 |
+
[
|
| 123 |
+
"post",
|
| 124 |
+
],
|
| 125 |
+
),
|
| 126 |
+
(
|
| 127 |
+
"/pages/from-paths",
|
| 128 |
+
[
|
| 129 |
+
"post",
|
| 130 |
+
],
|
| 131 |
+
),
|
| 132 |
+
(
|
| 133 |
+
"/pages/{id}/image-layers",
|
| 134 |
+
[
|
| 135 |
+
"post",
|
| 136 |
+
],
|
| 137 |
+
),
|
| 138 |
+
(
|
| 139 |
+
"/pages/{id}/masks/{role}",
|
| 140 |
+
[
|
| 141 |
+
"put",
|
| 142 |
+
],
|
| 143 |
+
),
|
| 144 |
+
(
|
| 145 |
+
"/pages/{id}/thumbnail",
|
| 146 |
+
[
|
| 147 |
+
"get",
|
| 148 |
+
],
|
| 149 |
+
),
|
| 150 |
+
(
|
| 151 |
+
"/pipelines",
|
| 152 |
+
[
|
| 153 |
+
"post",
|
| 154 |
+
],
|
| 155 |
+
),
|
| 156 |
+
(
|
| 157 |
+
"/projects",
|
| 158 |
+
[
|
| 159 |
+
"get",
|
| 160 |
+
"post",
|
| 161 |
+
],
|
| 162 |
+
),
|
| 163 |
+
(
|
| 164 |
+
"/projects/current",
|
| 165 |
+
[
|
| 166 |
+
"delete",
|
| 167 |
+
"put",
|
| 168 |
+
],
|
| 169 |
+
),
|
| 170 |
+
(
|
| 171 |
+
"/projects/current/export",
|
| 172 |
+
[
|
| 173 |
+
"post",
|
| 174 |
+
],
|
| 175 |
+
),
|
| 176 |
+
(
|
| 177 |
+
"/projects/import",
|
| 178 |
+
[
|
| 179 |
+
"post",
|
| 180 |
+
],
|
| 181 |
+
),
|
| 182 |
+
(
|
| 183 |
+
"/scene.bin",
|
| 184 |
+
[
|
| 185 |
+
"get",
|
| 186 |
+
],
|
| 187 |
+
),
|
| 188 |
+
(
|
| 189 |
+
"/scene.json",
|
| 190 |
+
[
|
| 191 |
+
"get",
|
| 192 |
+
],
|
| 193 |
+
),
|
| 194 |
+
]
|
tests/integration-tests/client/src/lib.rs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
|
|
|
|
|
| 1 |
#![allow(unused_imports)]
|
| 2 |
-
#![allow(clippy::
|
|
|
|
| 3 |
|
| 4 |
extern crate reqwest;
|
| 5 |
extern crate serde;
|
|
|
|
| 1 |
+
// Auto-generated by openapi-generator — silence clippy across the whole
|
| 2 |
+
// crate so CI stays green without hand-patching generator output.
|
| 3 |
#![allow(unused_imports)]
|
| 4 |
+
#![allow(clippy::all)]
|
| 5 |
+
#![allow(clippy::pedantic)]
|
| 6 |
|
| 7 |
extern crate reqwest;
|
| 8 |
extern crate serde;
|
ui/package.json
CHANGED
|
@@ -38,6 +38,7 @@
|
|
| 38 |
"@tanstack/react-query": "^5.99.0",
|
| 39 |
"@tanstack/react-virtual": "^3.13.24",
|
| 40 |
"@tauri-apps/api": "^2.10.1",
|
|
|
|
| 41 |
"@tauri-apps/plugin-opener": "^2.4.0",
|
| 42 |
"@tauri-apps/plugin-process": "^2.3.1",
|
| 43 |
"@tauri-apps/plugin-updater": "^2.10.1",
|
|
|
|
| 38 |
"@tanstack/react-query": "^5.99.0",
|
| 39 |
"@tanstack/react-virtual": "^3.13.24",
|
| 40 |
"@tauri-apps/api": "^2.10.1",
|
| 41 |
+
"@tauri-apps/plugin-dialog": "^2.7.0",
|
| 42 |
"@tauri-apps/plugin-opener": "^2.4.0",
|
| 43 |
"@tauri-apps/plugin-process": "^2.3.1",
|
| 44 |
"@tauri-apps/plugin-updater": "^2.10.1",
|