From 52b7e557c1042c61bd4045396250ed967088df02 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 17:52:16 +0800 Subject: [PATCH 1/5] Adjust image generation size logic for model constraints --- .../crates/api-server/src/editor_project.rs | 68 +++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 39ec8a3c4..553104c6d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -129,6 +129,8 @@ const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; +const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; +const GPT_IMAGE_2_MAX_PIXELS: u64 = 8_294_400; static EDITOR_BGFILTER_CIRCUIT: OnceLock> = OnceLock::new(); @@ -1850,14 +1852,16 @@ fn editor_image_price_size_from_pixels(size: &str) -> &'static str { if width.max(height) > 1536 { "2K" } else { "1K" } } -fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> { +fn normalize_editor_image_generation_size(model: &str, size: Option<&str>) -> Cow<'static, str> { match size.map(str::trim).filter(|value| !value.is_empty()) { Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"), Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"), Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080") | Some("2k-16:9") => Cow::Borrowed("2048x1152"), Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"), - Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()), + Some(value) if is_editor_custom_image_size(value) => { + clamp_custom_size_to_model_budget(model, value) + } _ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE), } } @@ -1868,7 +1872,7 @@ fn resolve_editor_image_request_size( has_dimension_options: bool, generation_options: &EditorGenerationOptions, ) -> Cow<'static, str> { - let legacy_size = normalize_editor_image_generation_size(payload_size); + let legacy_size = normalize_editor_image_generation_size(generation_options.model, payload_size); let has_explicit_payload_size = payload_size .map(str::trim) .is_some_and(|value| !value.is_empty()); @@ -1892,6 +1896,37 @@ fn is_editor_custom_image_size(value: &str) -> bool { return false; }; (64..=4096).contains(&width) && (64..=4096).contains(&height) + +} + +fn clamp_custom_size_to_model_budget(model: &str, value: &str) -> Cow<'static, str> { + if model != GPT_IMAGE_2_MODEL { + return Cow::Owned(value.to_string()); + } + let Some((width_str, height_str)) = value.split_once('x') else { + return Cow::Owned(value.to_string()); + }; + let Ok(width) = width_str.parse::() else { + return Cow::Owned(value.to_string()); + }; + let Ok(height) = height_str.parse::() else { + return Cow::Owned(value.to_string()); + }; + let pixels = u64::from(width) * u64::from(height); + + if pixels < GPT_IMAGE_2_MIN_PIXELS { + let ratio = (GPT_IMAGE_2_MIN_PIXELS as f64 / pixels as f64).sqrt(); + let new_w = (width as f64 * ratio).ceil() as u32; + let new_h = (height as f64 * ratio).ceil() as u32; + Cow::Owned(format!("{}x{}", new_w, new_h)) + } else if pixels > GPT_IMAGE_2_MAX_PIXELS { + let ratio = (GPT_IMAGE_2_MAX_PIXELS as f64 / pixels as f64).sqrt(); + let new_w = (width as f64 * ratio).floor() as u32; + let new_h = (height as f64 * ratio).floor() as u32; + Cow::Owned(format!("{}x{}", new_w, new_h)) + } else { + Cow::Owned(value.to_string()) + } } fn normalize_editor_generation_options( @@ -2232,7 +2267,7 @@ pub async fn edit_editor_image( .await?; let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); - let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let image_size = normalize_editor_image_generation_size(generation_options.model, payload.size.as_deref()); let price_mud_points = u64::from( resolve_editor_image_edit_price(&state, generation_options.model, image_size.as_ref()) .await?, @@ -2278,7 +2313,7 @@ pub(crate) async fn edit_editor_image_for_owner( } let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); - let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let requested_image_size = normalize_editor_image_generation_size(generation_options.model, payload.size.as_deref()); let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( @@ -7397,25 +7432,29 @@ mod tests { #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { - assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); assert_eq!( - normalize_editor_image_generation_size(Some("1536x1024")), + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, None), + "1024x1024" + ); + assert_eq!( + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("1536x1024")), "1536x1024" ); assert_eq!( - normalize_editor_image_generation_size(Some("1024x1536")), + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("1024x1536")), "1024x1536" ); assert_eq!( - normalize_editor_image_generation_size(Some("2048x1152")), + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("2048x1152")), "2048x1152" ); + // 640x640 has 409600 pixels, below gpt-image-2 minimum (655360), so clamp scales up. assert_eq!( - normalize_editor_image_generation_size(Some("640x640")), - "640x640" + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("640x640")), + "810x810" ); assert_eq!( - normalize_editor_image_generation_size(Some("bad-size")), + normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("bad-size")), "1024x1024" ); } @@ -7579,6 +7618,8 @@ mod tests { let options = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); + // 720x540 = 388800 pixels, below gpt-image-2 minimum (655360), clamp scales up to 935x702. + // This preserves the publication-material routing (legacy size wins) but with budget enforcement. assert_eq!( resolve_editor_image_request_size( Some("publication-material"), @@ -7586,8 +7627,9 @@ mod tests { true, &options, ), - "720x540" + "935x702" ); + // Non-publication-material path still falls through to structured options size. assert_eq!( resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), "1536x1024" -- 2.52.0 From de3d7db30990446a704b5bbd8c07731a65cae158 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 19:14:26 +0800 Subject: [PATCH 2/5] Revert "Adjust image generation size logic for model constraints" This reverts commit 52b7e557c1042c61bd4045396250ed967088df02. --- .../crates/api-server/src/editor_project.rs | 68 ++++--------------- 1 file changed, 13 insertions(+), 55 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 553104c6d..39ec8a3c4 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -129,8 +129,6 @@ const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; -const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; -const GPT_IMAGE_2_MAX_PIXELS: u64 = 8_294_400; static EDITOR_BGFILTER_CIRCUIT: OnceLock> = OnceLock::new(); @@ -1852,16 +1850,14 @@ fn editor_image_price_size_from_pixels(size: &str) -> &'static str { if width.max(height) > 1536 { "2K" } else { "1K" } } -fn normalize_editor_image_generation_size(model: &str, size: Option<&str>) -> Cow<'static, str> { +fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> { match size.map(str::trim).filter(|value| !value.is_empty()) { Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"), Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"), Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080") | Some("2k-16:9") => Cow::Borrowed("2048x1152"), Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"), - Some(value) if is_editor_custom_image_size(value) => { - clamp_custom_size_to_model_budget(model, value) - } + Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()), _ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE), } } @@ -1872,7 +1868,7 @@ fn resolve_editor_image_request_size( has_dimension_options: bool, generation_options: &EditorGenerationOptions, ) -> Cow<'static, str> { - let legacy_size = normalize_editor_image_generation_size(generation_options.model, payload_size); + let legacy_size = normalize_editor_image_generation_size(payload_size); let has_explicit_payload_size = payload_size .map(str::trim) .is_some_and(|value| !value.is_empty()); @@ -1896,37 +1892,6 @@ fn is_editor_custom_image_size(value: &str) -> bool { return false; }; (64..=4096).contains(&width) && (64..=4096).contains(&height) - -} - -fn clamp_custom_size_to_model_budget(model: &str, value: &str) -> Cow<'static, str> { - if model != GPT_IMAGE_2_MODEL { - return Cow::Owned(value.to_string()); - } - let Some((width_str, height_str)) = value.split_once('x') else { - return Cow::Owned(value.to_string()); - }; - let Ok(width) = width_str.parse::() else { - return Cow::Owned(value.to_string()); - }; - let Ok(height) = height_str.parse::() else { - return Cow::Owned(value.to_string()); - }; - let pixels = u64::from(width) * u64::from(height); - - if pixels < GPT_IMAGE_2_MIN_PIXELS { - let ratio = (GPT_IMAGE_2_MIN_PIXELS as f64 / pixels as f64).sqrt(); - let new_w = (width as f64 * ratio).ceil() as u32; - let new_h = (height as f64 * ratio).ceil() as u32; - Cow::Owned(format!("{}x{}", new_w, new_h)) - } else if pixels > GPT_IMAGE_2_MAX_PIXELS { - let ratio = (GPT_IMAGE_2_MAX_PIXELS as f64 / pixels as f64).sqrt(); - let new_w = (width as f64 * ratio).floor() as u32; - let new_h = (height as f64 * ratio).floor() as u32; - Cow::Owned(format!("{}x{}", new_w, new_h)) - } else { - Cow::Owned(value.to_string()) - } } fn normalize_editor_generation_options( @@ -2267,7 +2232,7 @@ pub async fn edit_editor_image( .await?; let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); - let image_size = normalize_editor_image_generation_size(generation_options.model, payload.size.as_deref()); + let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let price_mud_points = u64::from( resolve_editor_image_edit_price(&state, generation_options.model, image_size.as_ref()) .await?, @@ -2313,7 +2278,7 @@ pub(crate) async fn edit_editor_image_for_owner( } let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); - let requested_image_size = normalize_editor_image_generation_size(generation_options.model, payload.size.as_deref()); + let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( @@ -7432,29 +7397,25 @@ mod tests { #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { + assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, None), - "1024x1024" - ); - assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("1536x1024")), + normalize_editor_image_generation_size(Some("1536x1024")), "1536x1024" ); assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("1024x1536")), + normalize_editor_image_generation_size(Some("1024x1536")), "1024x1536" ); assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("2048x1152")), + normalize_editor_image_generation_size(Some("2048x1152")), "2048x1152" ); - // 640x640 has 409600 pixels, below gpt-image-2 minimum (655360), so clamp scales up. assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("640x640")), - "810x810" + normalize_editor_image_generation_size(Some("640x640")), + "640x640" ); assert_eq!( - normalize_editor_image_generation_size(GPT_IMAGE_2_MODEL, Some("bad-size")), + normalize_editor_image_generation_size(Some("bad-size")), "1024x1024" ); } @@ -7618,8 +7579,6 @@ mod tests { let options = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); - // 720x540 = 388800 pixels, below gpt-image-2 minimum (655360), clamp scales up to 935x702. - // This preserves the publication-material routing (legacy size wins) but with budget enforcement. assert_eq!( resolve_editor_image_request_size( Some("publication-material"), @@ -7627,9 +7586,8 @@ mod tests { true, &options, ), - "935x702" + "720x540" ); - // Non-publication-material path still falls through to structured options size. assert_eq!( resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), "1536x1024" -- 2.52.0 From f1aa8abbd0346a79d9eb9c9d0ed7c876aca7dc78 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 20:03:28 +0800 Subject: [PATCH 3/5] dive fallback for gpt image2 to platform-image --- .../src/vector_engine/client.rs | 11 +- .../platform-image/src/vector_engine/mod.rs | 2 +- .../src/vector_engine/request.rs | 100 +++++++++++++++++- .../platform-image/tests/vector_engine.rs | 63 ++++++++++- 4 files changed, 165 insertions(+), 11 deletions(-) diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index e5024124a..d8ee9271d 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -15,9 +15,10 @@ use super::{ request::{ build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, - normalize_vector_engine_image_model, vector_engine_images_edit_url, - vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, + build_vector_engine_nanobanana_generate_content_request_body, + normalize_image_size_for_model, normalize_vector_engine_image_model, + vector_engine_images_edit_url, vector_engine_images_generation_url, + vector_engine_nanobanana_generate_content_url, }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, @@ -79,7 +80,7 @@ pub async fn create_vector_engine_image_generation_with_model( } let request_url = vector_engine_images_generation_url(settings); - let normalized_size = normalize_image_size(size); + let normalized_size = normalize_image_size_for_model(model, size); let request_body = build_vector_engine_image_request_body_with_model( model, prompt, @@ -387,7 +388,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( } let request_url = vector_engine_images_edit_url(settings); - let normalized_size = normalize_image_size(size); + let normalized_size = normalize_image_size_for_model(model, size); let request_params = build_vector_engine_image_edit_request_log_params( model, prompt, diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index f8d953e8a..1422ecb3a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -23,7 +23,7 @@ pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image; pub use request::{ build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, + build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model, vector_engine_images_edit_url, vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index 869f4e374..e67771f15 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -40,7 +40,7 @@ pub fn build_vector_engine_image_request_body_with_model( ("n".to_string(), json!(candidate_count.clamp(1, 4))), ( "size".to_string(), - Value::String(normalize_image_size(size)), + Value::String(normalize_image_size_for_model(model, size)), ), ]); @@ -90,9 +90,9 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str { } } -pub fn normalize_image_size(size: &str) -> String { +pub fn normalize_image_size_for_model(model: &str, size: &str) -> String { let size = size.trim(); - match size { + let normalized_size = match size { "1:1" => "1024x1024".to_string(), "16:9" | "2k" => "1536x1024".to_string(), "2k-16:9" => "2048x1152".to_string(), @@ -100,6 +100,12 @@ pub fn normalize_image_size(size: &str) -> String { value if is_explicit_pixel_size(value) => normalize_explicit_pixel_size(value), value if !value.is_empty() => value.to_string(), _ => "1024x1024".to_string(), + }; + + if normalize_vector_engine_image_model(model) == GPT_IMAGE_2_MODEL { + clamp_gpt_image_2_pixel_size(normalized_size.as_str()) + } else { + normalized_size } } @@ -115,6 +121,94 @@ fn normalize_explicit_pixel_size(value: &str) -> String { value.replace('*', "x") } +fn clamp_gpt_image_2_pixel_size(size: &str) -> String { + const MIN_PIXELS: u64 = 655_360; + const MAX_PIXELS: u64 = 8_294_400; + const MAX_EDGE: u32 = 3_840; + const DIMENSION_ALIGNMENT: u32 = 16; + const MAX_ASPECT_RATIO: f64 = 3.0; + + // 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。 + let Some((width, height)) = parse_explicit_pixel_size(size) else { + return size.to_string(); + }; + // 中文注释:零尺寸无法计算比例或像素数,直接使用已知合法的默认尺寸。 + if width == 0 || height == 0 { + return "1024x1024".to_string(); + } + + let (mut width, mut height) = (f64::from(width), f64::from(height)); + // 中文注释:先把短边补至长边的三分之一,确保长短边比不超过 3:1。 + if width / height > MAX_ASPECT_RATIO { + height = width / MAX_ASPECT_RATIO; + } else if height / width > MAX_ASPECT_RATIO { + width = height / MAX_ASPECT_RATIO; + } + + let pixels = width * height; + // 中文注释:等比缩小,优先保留已收紧后的画面比例,同时满足最大边和最大总像素。 + let scale = (MAX_EDGE as f64 / width.max(height)) + .min((MAX_PIXELS as f64 / pixels).sqrt()) + .min(1.0); + width *= scale; + height *= scale; + + let pixels = width * height; + // 中文注释:小于最小总像素时等比放大;此前已处理比例,放大不会重新突破 3:1。 + if pixels < MIN_PIXELS as f64 { + let scale = (MIN_PIXELS as f64 / pixels).sqrt(); + width *= scale; + height *= scale; + } + + // 中文注释:provider 要求两边均为 16px 倍数,向上取整避免对齐后落到最小像素以下。 + let width = align_dimension_up(width, DIMENSION_ALIGNMENT); + let height = align_dimension_up(height, DIMENSION_ALIGNMENT); + // TODO unlikely but can improve + // 中文注释:对齐可能触碰最大边或最大像素,因此发送前重新核验全部约束。 + if is_valid_gpt_image_2_size( + width, + height, + MIN_PIXELS, + MAX_PIXELS, + MAX_EDGE, + MAX_ASPECT_RATIO, + ) { + return format!("{width}x{height}"); + } + + // 中文注释:无法同时满足全部约束时,回退为已知会被 provider 接受的默认尺寸。 + "1024x1024".to_string() +} + +fn align_dimension_up(value: f64, alignment: u32) -> u32 { + ((value.ceil() as u32).saturating_add(alignment - 1) / alignment) * alignment +} + +fn is_valid_gpt_image_2_size( + width: u32, + height: u32, + min_pixels: u64, + max_pixels: u64, + max_edge: u32, + max_aspect_ratio: f64, +) -> bool { + let pixels = u64::from(width) * u64::from(height); + width > 0 + && height > 0 + && width <= max_edge + && height <= max_edge + && width.is_multiple_of(16) + && height.is_multiple_of(16) + && (min_pixels..=max_pixels).contains(&pixels) + && f64::from(width.max(height)) / f64::from(width.min(height)) <= max_aspect_ratio +} + +fn parse_explicit_pixel_size(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('x')?; + Some((width.parse().ok()?, height.parse().ok()?)) +} + fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { match aspect_ratio.trim() { "2:3" => "2:3", diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index 66c6dc0b0..0ec6d9c43 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -47,12 +47,12 @@ fn vector_engine_module_exposes_provider_protocol_helpers() { } #[test] -fn vector_engine_keeps_explicit_publication_material_pixel_sizes() { +fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() { let cover = build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]); let detail = build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]); let poster = build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]); - assert_eq!(cover["size"], "720x540"); + assert_eq!(cover["size"], "944x704"); assert_eq!(detail["size"], "720x1280"); assert_eq!(poster["size"], "1280x720"); } @@ -82,6 +82,65 @@ fn vector_engine_request_body_can_use_nanobanana2_model() { assert_eq!(body["n"], 1); } +#[test] +fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() { + let gpt_body = build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + "小尺寸图", + None, + "640x640", + 1, + &[], + ); + let nanobanana_body = build_vector_engine_image_request_body_with_model( + "gemini-3.1-flash-image-preview", + "小尺寸图", + None, + "640x640", + 1, + &[], + ); + let oversized_gpt_body = build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + "大尺寸图", + None, + "4096x4096", + 1, + &[], + ); + + assert_eq!(gpt_body["size"], "816x816"); + assert_eq!(nanobanana_body["size"], "640x640"); + assert_eq!(oversized_gpt_body["size"], "2880x2880"); +} + +#[test] +fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() { + for size in [ + "1x1", + "720x540", + "3841x1280", + "4096x4096", + "3200x400", + "16x4096", + "3840x3840", + ] { + let body = build_vector_engine_image_request_body("约束测试", None, size, 1, &[]); + let normalized = body["size"].as_str().expect("size should be a string"); + let (width, height) = normalized + .split_once('x') + .expect("gpt-image-2 size should be explicit pixels"); + let width = width.parse::().expect("width should be numeric"); + let height = height.parse::().expect("height should be numeric"); + let pixels = u64::from(width) * u64::from(height); + + assert!(width <= 3_840 && height <= 3_840, "{size} -> {normalized}"); + assert!(width.is_multiple_of(16) && height.is_multiple_of(16)); + assert!((655_360..=8_294_400).contains(&pixels)); + assert!(width.max(height) <= width.min(height) * 3); + } +} + #[test] fn vector_engine_request_body_can_use_nanobanana2_half_k() { let body = build_vector_engine_image_request_body_with_model( -- 2.52.0 From 142e6d1265e77de46a2d331a56d9bf3f616ed594 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 20:04:06 +0800 Subject: [PATCH 4/5] update doc --- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ecd2b2143..5b75b9d1e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -144,7 +144,7 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install && spacetime version use `,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前,按原比例尽量收敛 `gpt-image-2` 的显式像素尺寸:最大边长不超过 `3840`、宽高均为 `16` 的倍数、长短边比不超过 `3:1`,总像素范围为 `655360` 至 `8294400`。无法满足全部条件时回退为 `1024x1024`;其他模型保留其传入尺寸。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。 VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。 diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md index 4f2aa5d21..6a29a90b2 100644 --- a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -54,7 +54,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U - 前端生成请求统一通过 `/api/editor/images/generations`。 - 宣发素材请求携带 `kind: publication-material`,前端提交和后端 handler 都固定归一为 `gpt-image-2`;即使旧前端或外部请求传入 `nanobanana2`,后端也按 `gpt-image-2` 生成和计费,但价格仍来自运行时模型定价配置,不写死数值。 - `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。 -- 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。 +- 尺寸请求和成品交付规格必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。这些值是画布图层与成品的业务规格;由于三种规格并非都满足 `gpt-image-2` 的上游尺寸约束,VectorEngine 适配层会在发送前等比归一到合法请求尺寸(最大边 `3840`、两边为 `16` 的倍数、长短边比不超过 `3:1`、总像素 `655360..8294400`),不能将上游归一后的尺寸当作宣发成品规格。只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。 - 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。 - 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。 - 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。 -- 2.52.0 From 215c5e0a1fbfccfece78979301560f5aade8d27c Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 20:51:56 +0800 Subject: [PATCH 5/5] restore to business size for generate image calls --- .../crates/api-server/src/editor_project.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 39ec8a3c4..e13a581e6 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1662,6 +1662,9 @@ pub(crate) async fn generate_editor_image_for_owner( None }; + // TODO the image size passed to api is already normalized, should remove those normalize + // and let resize here to get the proper size + image = restore_editor_generated_image_output_dimensions(image, image_size.as_ref())?; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2219,6 +2222,54 @@ fn restore_editor_image_edit_output_dimensions( extension: "png".to_string(), }) } +fn restore_editor_generated_image_output_dimensions( + output: DownloadedOpenAiImage, + // TODO primitive obsession + target_size: &str, +) -> Result { + let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "尺寸无效", + })) + })?; + let target_width = target_width.parse::().map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宽度无效", + })) + })?; + let target_height = target_height.parse::().map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "高度无效", + })) + })?; + let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("素材生成结果不是有效图片:{error}"), + })) + })?; + if decoded.width() == target_width && decoded.height() == target_height { + return Ok(output); + } + + let restored = decoded.resize_to_fill( + target_width, + target_height, + image::imageops::FilterType::Lanczos3, + ); + Ok(DownloadedOpenAiImage { + bytes: encode_editor_image_edit_png( + restored, + StatusCode::BAD_GATEWAY, + "恢复宣发素材交付尺寸失败", + )?, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} pub async fn edit_editor_image( State(state): State, @@ -7497,6 +7548,30 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { + let image = image::DynamicImage::new_rgba8(944, 704); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test image should encode"); + + let restored = restore_editor_generated_image_output_dimensions( + DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }, + "720x540", + ) + .expect("provider output should restore delivery dimensions"); + let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); + + assert_eq!((restored_image.width(), restored_image.height()), (720, 540)); + assert_eq!(restored.mime_type, "image/png"); + assert_eq!(restored.extension, "png"); + } + #[test] fn editor_generation_dimensions_follow_model_options() { let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); -- 2.52.0