From e7af7cf4e4b2ebe4c5661e349b0c4e7d97ebdab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 11 Sep 2026 20:11:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=A4=84=E7=90=86=E5=9B=BE?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=B9=B6=E6=98=8E=E7=A1=AE=20multipart=20?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将处理图真实尺寸传入视觉绑定校验,提前拒绝零尺寸和越界区域。 保持非法区域直接失败,不转换为 NeedRework。 将 image 和 mask multipart 部分改为 Part::bytes。 --- .../src/ui_editor/commands/separation/mod.rs | 39 +++++++++++++++++-- .../src/ui_editor/commands/separation/tree.rs | 6 +++ .../commands/separation/workflow/binding.rs | 13 ++++++- .../commands/separation/workflow/extract.rs | 15 +++++-- .../commands/separation/workflow/mod.rs | 27 +++++++++---- .../commands/separation/workflow/patch.rs | 2 + .../src/vector_engine/raw_edit.rs | 4 +- 7 files changed, 89 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 067c83349..d6400e05d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -190,7 +190,38 @@ mod tests { children: vec![], rework_count: 0, }; - assert!(validate_binding_response(&BindingResp { decisions: vec![] }, &[&node]).is_err()); + assert!( + validate_binding_response(&BindingResp { decisions: vec![] }, &[&node], (1, 1)) + .is_err() + ); + } + + #[test] + fn binding_validation_rejects_area_outside_processed_image() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let response = BindingResp { + decisions: vec![BindingDecision::Ok { + to_node: node.id.clone(), + extracted_area: BindingArea { + global_pos_x_px: 1, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }, + }], + }; + let error = validate_binding_response(&response, &[&node], (1, 1)).unwrap_err(); + assert!(error.contains("超出处理图边界")); } #[test] @@ -236,6 +267,7 @@ mod tests { advice: note.to_string(), }], &paths, + (1, 1), ) .unwrap(); } @@ -275,7 +307,8 @@ mod tests { &BindingResp { decisions: vec![decision] }, - &[&node] + &[&node], + (1, 1) ) .is_err()); } @@ -310,7 +343,7 @@ mod tests { }, }]; let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]); - apply_batch_patch(&mut state, 0, &decisions, &paths).unwrap(); + apply_batch_patch(&mut state, 0, &decisions, &paths, (1, 1)).unwrap(); assert_eq!(state.bound[0].node_id, id); assert_eq!(state.trees[0].root.children.len(), 1); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs index 0b483d8e4..f1d024827 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs @@ -146,6 +146,7 @@ pub fn construct_separation_state(state: &State) -> SeparationState { pub fn validate_binding_response( response: &BindingResp, batch: &[&SeparationNode], + processed_dimensions: (u32, u32), ) -> Result<(), String> { let expected = batch .iter() @@ -165,6 +166,11 @@ pub fn validate_binding_response( if !seen.insert(node_id.clone()) { return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); } + if let BindingDecision::Ok { extracted_area, .. } = decision { + extracted_area + .validate_in(processed_dimensions.0, processed_dimensions.1) + .map_err(|error| format!("节点 {} 的分离区域无效:{error}", node_id.as_str()))?; + } if let BindingDecision::NeedRework { advice, .. } = decision { if advice.trim().is_empty() { return Err("NeedRework 必须包含问题描述".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs index 32d3f5416..7b13beaaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs @@ -19,9 +19,17 @@ pub(super) async fn visual_binding( processed_url: String, sidecar: PathBuf, nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), ) -> Result { let started = Instant::now(); - let result = visual_binding_inner(source_url, processed_url, sidecar, nodes).await; + let result = visual_binding_inner( + source_url, + processed_url, + sidecar, + nodes, + processed_dimensions, + ) + .await; app_log!( "ui_separation.visual_binding.timing outcome={} elapsed_ms={} nodes={}", if result.is_ok() { "ok" } else { "error" }, @@ -36,6 +44,7 @@ async fn visual_binding_inner( processed_url: String, sidecar: PathBuf, nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), ) -> Result { app_log!( "ui_separation.visual_binding.start nodes={} source_url_chars={} processed_url_chars={}", @@ -137,7 +146,7 @@ async fn visual_binding_inner( }) } }, - |value: &BindingResp| validate_binding_response(value, nodes), + |value: &BindingResp| validate_binding_response(value, nodes, processed_dimensions), ) .await; match &result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs index 45096b14a..a5f08e433 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -1,9 +1,9 @@ use crate::platform_session::PlatformSessionSnapshot; use base64::Engine as _; use serde::Deserialize; -use std::fs; use std::path::PathBuf; use std::time::Instant; +use std::{fs, io::Cursor}; #[derive(Deserialize)] struct RawEditResponse { @@ -125,7 +125,7 @@ async fn raw_extract_inner( pub(super) async fn write_processed_image( processed_url: String, target: PathBuf, -) -> Result<(), String> { +) -> Result<(u32, u32), String> { let started = Instant::now(); let result = write_processed_image_inner(processed_url, target).await; app_log!( @@ -136,7 +136,10 @@ pub(super) async fn write_processed_image( result } -async fn write_processed_image_inner(processed_url: String, target: PathBuf) -> Result<(), String> { +async fn write_processed_image_inner( + processed_url: String, + target: PathBuf, +) -> Result<(u32, u32), String> { app_log!( "ui_separation.processed_image.write.start target_file={} data_url_chars={}", target @@ -153,6 +156,11 @@ async fn write_processed_image_inner(processed_url: String, target: PathBuf) -> let processed_bytes = base64::engine::general_purpose::STANDARD .decode(encoded) .map_err(|error| format!("解析处理图失败:{error}"))?; + let dimensions = image::ImageReader::new(Cursor::new(processed_bytes.as_slice())) + .with_guessed_format() + .map_err(|error| format!("识别处理图格式失败:{error}"))? + .into_dimensions() + .map_err(|error| format!("读取处理图尺寸失败:{error}"))?; let byte_len = processed_bytes.len(); fs::write(&target, processed_bytes) .map_err(|error| format!("写入处理图失败:{}: {error}", target.display())) @@ -165,6 +173,7 @@ async fn write_processed_image_inner(processed_url: String, target: PathBuf) -> .unwrap_or(""), byte_len ); + dimensions }) }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs index c4195e9ad..fb1c1e4dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs @@ -153,18 +153,25 @@ pub(crate) async fn separate_ui_impl( } }; let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len())); - if let Err(error) = - extract::write_processed_image(processed_url.clone(), processed_path.clone()).await + let processed_dimensions = match extract::write_processed_image( + processed_url.clone(), + processed_path.clone(), + ) + .await { - app_log!("ui_separation.error stage=processed_image_write tree_index={} batch_index={} error={error}", tree_index, batch_index); - write_separation_state(state_path.clone(), separation.clone()).await?; - return Err(error); - } + Ok(dimensions) => dimensions, + Err(error) => { + app_log!("ui_separation.error stage=processed_image_write tree_index={} batch_index={} error={error}", tree_index, batch_index); + write_separation_state(state_path.clone(), separation.clone()).await?; + return Err(error); + } + }; let binding = match binding::visual_binding( source_url.clone(), processed_url, sidecar.clone(), &batch, + processed_dimensions, ) .await { @@ -225,7 +232,13 @@ pub(crate) async fn separate_ui_impl( write_separation_state(state_path.clone(), separation.clone()).await?; return Err(error); } - patch::apply_batch_patch(&mut separation, tree_index, &binding.decisions, &cut_paths)?; + patch::apply_batch_patch( + &mut separation, + tree_index, + &binding.decisions, + &cut_paths, + processed_dimensions, + )?; write_separation_state(state_path.clone(), separation.clone()).await?; app_log!( "ui_separation.batch_completed tree_index={} batch_index={} cuts={} bound={} problematic={} elapsed_ms={}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs index 341416fd6..42985ac4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs @@ -11,6 +11,7 @@ pub fn apply_batch_patch( tree_index: usize, decisions: &[BindingDecision], cut_paths: &HashMap, + processed_dimensions: (u32, u32), ) -> Result<(), String> { app_log!( "ui_separation.batch_patch.start tree_index={} decisions={} cut_paths={}", @@ -34,6 +35,7 @@ pub fn apply_batch_patch( decisions: decisions.to_vec(), }, &batch, + processed_dimensions, )?; let rework_counts = batch_nodes .iter() diff --git a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs index a1f13e20f..d15340b07 100644 --- a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs +++ b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs @@ -139,7 +139,7 @@ pub async fn create_vector_engine_raw_image_edit( // GPT-Image-2 原生只返回 b64_json;不要添加 URL 下载或 response_format 兼容分支。 .part( "image", - Part::stream_with_length(image_bytes.clone(), image_bytes.len() as u64) + Part::bytes(image_bytes.to_vec()) .file_name(image_file_name) .mime_str(image_mime_type.as_str()) .map_err(|error| invalid_request(failure_context, error.to_string()))?, @@ -156,7 +156,7 @@ pub async fn create_vector_engine_raw_image_edit( if let Some(mask) = options.mask { form = form.part( "mask", - Part::stream_with_length(mask.bytes.clone(), mask.bytes.len() as u64) + Part::bytes(mask.bytes.to_vec()) .file_name(mask.file_name) .mime_str(mask.mime_type.as_str()) .map_err(|error| invalid_request(failure_context, error.to_string()))?,