Merge remote-tracking branch 'origin/master' into editor-agent-refactored

# Conflicts:
#	server-rs/crates/api-server/src/editor_agent.rs
This commit is contained in:
2026-07-17 10:02:13 +08:00
12 changed files with 316 additions and 60 deletions
+204 -44
View File
@@ -267,6 +267,8 @@ pub struct EditorImageEditRequest {
pub(crate) source_image_src: String,
pub(crate) size: Option<String>,
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
@@ -2073,7 +2075,15 @@ async fn resolve_editor_image_edit_price(
state: &AppState,
model: &str,
size: &str,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let price_size = if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
image_size
.map(|value| normalize_editor_generation_image_size(model, Some(value)))
.unwrap_or_else(|| editor_image_price_size_from_pixels(size))
} else {
editor_image_price_size_from_pixels(size)
};
let expected_price_mud_points = state
.editor_generation_pricing()
.await
@@ -2083,11 +2093,7 @@ async fn resolve_editor_image_edit_price(
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
Some("quick-edit"),
Some(model),
Some(editor_image_price_size_from_pixels(size)),
);
.image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size));
Ok(expected_price_mud_points)
}
@@ -2104,6 +2110,63 @@ fn editor_image_price_size_from_pixels(size: &str) -> &'static str {
if width.max(height) > 1536 { "2K" } else { "1K" }
}
fn editor_image_edit_uses_nanobanana_generate_content(model: &str) -> bool {
model == EDITOR_IMAGE_MODEL_NANOBANANA2
}
fn normalize_editor_image_edit_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
legacy_size: Option<&str>,
) -> EditorGenerationOptions {
let model_options = normalize_editor_generation_options(model, None, None);
let inferred_aspect_ratio = aspect_ratio
.filter(|value| !value.trim().is_empty())
.or_else(|| infer_editor_image_edit_aspect_ratio(legacy_size));
let inferred_image_size = image_size
.filter(|value| !value.trim().is_empty())
.or_else(|| infer_editor_image_edit_size_tier(model_options.model, legacy_size));
normalize_editor_generation_options(model, inferred_aspect_ratio, inferred_image_size)
}
fn infer_editor_image_edit_aspect_ratio(size: Option<&str>) -> Option<&'static str> {
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
let source_ratio = f64::from(width) / f64::from(height);
[
("1:1", 1.0),
("4:3", 4.0 / 3.0),
("3:2", 3.0 / 2.0),
("2:3", 2.0 / 3.0),
("9:16", 9.0 / 16.0),
("16:9", 16.0 / 9.0),
]
.into_iter()
.min_by(|(_, left), (_, right)| {
(left - source_ratio)
.abs()
.total_cmp(&(right - source_ratio).abs())
})
.map(|(label, _)| label)
}
fn infer_editor_image_edit_size_tier(model: &str, size: Option<&str>) -> Option<&'static str> {
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
let long_edge = width.max(height);
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 && long_edge <= 768 {
return Some("0.5K");
}
Some(if long_edge > 1536 { "2K" } else { "1K" })
}
fn parse_editor_image_edit_pixel_size(size: &str) -> Option<(u32, u32)> {
let normalized = size.trim().replace('*', "x");
let (width, height) = normalized.split_once('x')?;
let width = width.parse::<u32>().ok()?;
let height = height.parse::<u32>().ok()?;
(width > 0 && height > 0).then_some((width, height))
}
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"),
@@ -2561,12 +2624,21 @@ pub async fn edit_editor_image(
if !state.config.external_generation_mode.is_inline() {
ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload)
.await?;
let generation_options =
normalize_editor_generation_options(payload.model.as_deref(), None, None);
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
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?,
resolve_editor_image_edit_price(
&state,
generation_options.model,
image_size.as_ref(),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
@@ -2607,8 +2679,12 @@ pub(crate) async fn edit_editor_image_for_owner(
})),
);
}
let generation_options =
normalize_editor_generation_options(payload.model.as_deref(), None, None);
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
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));
@@ -2637,33 +2713,71 @@ pub(crate) async fn edit_editor_image_for_owner(
);
let http_client = build_openai_image_http_client(&settings)?;
let edit_dimensions =
prepare_editor_image_edit_references(&mut reference_images, requested_image_size.as_ref())?;
let image_size = format!(
"{}x{}",
edit_dimensions.provider_width, edit_dimensions.provider_height
);
let expected_price_mud_points =
resolve_editor_image_edit_price(state, generation_options.model, image_size.as_ref())
.await?;
let generated = execute_billable_asset_operation_with_cost(
if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) {
None
} else {
Some(prepare_editor_image_edit_references(
&mut reference_images,
requested_image_size.as_ref(),
)?)
};
let provider_size = edit_dimensions
.map(|dimensions| {
format!(
"{}x{}",
dimensions.provider_width, dimensions.provider_height
)
})
.unwrap_or_else(|| generation_options.provider_image_size.to_string());
let expected_price_mud_points = resolve_editor_image_edit_price(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
image_size.as_ref(),
1,
reference_images.as_slice(),
"图片画布修改图片",
),
generation_options.model,
requested_image_size.as_ref(),
Some(generation_options.image_size),
)
.await?;
let generated = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model)
{
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
generation_options.aspect_ratio,
provider_size.as_str(),
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
} else {
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
provider_size.as_str(),
1,
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
};
let provider_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
@@ -2681,15 +2795,18 @@ pub(crate) async fn edit_editor_image_for_owner(
})?;
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果");
let (image, dimension_restore_error) =
restore_editor_image_edit_output_dimensions_or_original(provider_image, &edit_dimensions);
let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref() {
restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions)
} else {
(provider_image, None)
};
if let Some(error) = dimension_restore_error {
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_width = edit_dimensions.target_width,
target_height = edit_dimensions.target_height,
target_width = edit_dimensions.map(|dimensions| dimensions.target_width),
target_height = edit_dimensions.map(|dimensions| dimensions.target_height),
error = ?error,
"画板快速编辑尺寸恢复失败,保留并上传 provider 原图"
);
@@ -8159,23 +8276,51 @@ mod tests {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024")
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024", None)
.await
.unwrap(),
3
);
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152")
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", None)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(&state, EDITOR_IMAGE_MODEL_NANOBANANA2, "2048x2048")
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", Some("1K"),)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"2048x2048",
Some("2K"),
)
.await
.unwrap(),
24
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"512x512",
Some("0.5K"),
)
.await
.unwrap(),
8
);
assert!(editor_image_edit_uses_nanobanana_generate_content(
EDITOR_IMAGE_MODEL_NANOBANANA2
));
assert!(!editor_image_edit_uses_nanobanana_generate_content(
GPT_IMAGE_2_MODEL
));
}
#[test]
@@ -9708,7 +9853,9 @@ mod tests {
"prompt": "修改图片",
"sourceImageSrc": "data:image/png;base64,AAAA",
"size": "2048x1152",
"model": "gpt-image-2",
"model": "gemini-3.1-flash-image-preview",
"aspectRatio": "16:9",
"imageSize": "2K",
"referenceImageSrcs": ["data:image/png;base64,BBBB"],
"targetLayerId": "layer-source"
}))
@@ -9716,7 +9863,20 @@ mod tests {
assert_eq!(request.prompt, "修改图片");
assert_eq!(request.size.as_deref(), Some("2048x1152"));
assert_eq!(request.model.as_deref(), Some("gpt-image-2"));
assert_eq!(
request.model.as_deref(),
Some("gemini-3.1-flash-image-preview")
);
assert_eq!(request.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(request.image_size.as_deref(), Some("2K"));
let legacy_options = normalize_editor_image_edit_generation_options(
request.model.as_deref(),
None,
None,
Some("2048x1365"),
);
assert_eq!(legacy_options.aspect_ratio, "3:2");
assert_eq!(legacy_options.image_size, "2K");
assert_eq!(
request.reference_image_srcs,
Some(vec!["data:image/png;base64,BBBB".to_string()])
@@ -885,8 +885,8 @@ mod tests {
"#/components/schemas/EditorIconSpritesheetSliceWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]
["warning"]["anyOf"][0]["$ref"],
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(