修复图片改造尺寸保持

保持图片改造提交与画布回填的原图尺寸
在 VectorEngine 边界对齐非 16 倍数参考图并恢复目标尺寸
补充前后端回归测试与编辑器尺寸契约文档
This commit is contained in:
2026-07-11 18:10:00 +08:00
parent d756e8e04b
commit 300e69c97d
9 changed files with 243 additions and 54 deletions
@@ -1910,6 +1910,148 @@ fn editor_generation_provider_image_size_for_model(
image_size
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorImageEditDimensions {
target_width: u32,
target_height: u32,
provider_width: u32,
provider_height: u32,
}
fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
fn encode_editor_image_edit_png(
image: image::DynamicImage,
failure_status: StatusCode,
failure_message: &str,
) -> Result<Vec<u8>, AppError> {
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(failure_status).with_details(json!({
"provider": "editor-image-edit",
"message": format!("{failure_message}{error}"),
}))
})?;
Ok(bytes.into_inner())
}
fn prepare_editor_image_edit_references(
reference_images: &mut [OpenAiReferenceImage],
target_size: &str,
) -> Result<EditorImageEditDimensions, AppError> {
let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "图片改造目标尺寸无效",
}))
})?;
let target_width = target_width.parse::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "图片改造目标宽度无效",
}))
})?;
let target_height = target_height.parse::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "图片改造目标高度无效",
}))
})?;
let provider_width = align_editor_image_edit_dimension(target_width);
let provider_height = align_editor_image_edit_dimension(target_height);
if reference_images.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "图片改造缺少原图",
})),
);
}
for (index, reference) in reference_images.iter_mut().enumerate() {
let decoded = image::load_from_memory(reference.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": format!("图片改造参考图不是有效图片:{error}"),
"referenceIndex": index,
}))
})?;
let width = decoded.width();
let height = decoded.height();
let reference_provider_width = align_editor_image_edit_dimension(width);
let reference_provider_height = align_editor_image_edit_dimension(height);
if reference_provider_width == width && reference_provider_height == height {
continue;
}
// 中文注释:只在 provider 边界向右、向下复制边缘像素补齐到 16 倍数,
// 不缩放、不裁切用户原图;生成结果持久化前会恢复到业务目标精确尺寸。
let source = decoded.to_rgba8();
let mut aligned =
image::RgbaImage::new(reference_provider_width, reference_provider_height);
for y in 0..reference_provider_height {
let source_y = y.min(height.saturating_sub(1));
for x in 0..reference_provider_width {
let source_x = x.min(width.saturating_sub(1));
aligned.put_pixel(x, y, *source.get_pixel(source_x, source_y));
}
}
reference.bytes = encode_editor_image_edit_png(
image::DynamicImage::ImageRgba8(aligned),
StatusCode::BAD_REQUEST,
"图片改造参考图 16 对齐失败",
)?;
reference.mime_type = "image/png".to_string();
reference.file_name = format!("editor-image-edit-reference-{}.png", index + 1);
}
Ok(EditorImageEditDimensions {
target_width,
target_height,
provider_width,
provider_height,
})
}
fn restore_editor_image_edit_output_dimensions(
output: DownloadedOpenAiImage,
dimensions: &EditorImageEditDimensions,
) -> Result<DownloadedOpenAiImage, AppError> {
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() == dimensions.target_width && decoded.height() == dimensions.target_height {
return Ok(output);
}
let restored = if decoded.width() == dimensions.provider_width
&& decoded.height() == dimensions.provider_height
{
decoded.crop_imm(0, 0, dimensions.target_width, dimensions.target_height)
} else {
decoded.resize_to_fill(
dimensions.target_width,
dimensions.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<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1965,7 +2107,7 @@ pub(crate) async fn edit_editor_image_for_owner(
}
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 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(
@@ -1983,10 +2125,6 @@ pub(crate) async fn edit_editor_image_for_owner(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?,
);
}
let expected_price_mud_points =
resolve_editor_image_edit_price(state, generation_options.model, image_size.as_ref())
.await?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
@@ -1996,6 +2134,15 @@ pub(crate) async fn edit_editor_image_for_owner(
.or_else(|| payload.project_id.clone()),
);
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(
state,
caller.owner_user_id.as_str(),
@@ -2021,6 +2168,7 @@ pub(crate) async fn edit_editor_image_for_owner(
"message": "VectorEngine 未返回图片",
}))
})?;
let image = restore_editor_image_edit_output_dimensions(image, &edit_dimensions)?;
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
@@ -6435,6 +6583,59 @@ mod tests {
);
}
#[test]
fn editor_image_edit_aligns_provider_images_and_restores_source_dimensions() {
fn encode_png(width: u32, height: u32) -> Vec<u8> {
let image = image::DynamicImage::new_rgba8(width, height);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
bytes.into_inner()
}
let mut references = vec![
OpenAiReferenceImage {
bytes: encode_png(1537, 1025),
mime_type: "image/png".to_string(),
file_name: "source.png".to_string(),
},
OpenAiReferenceImage {
bytes: encode_png(501, 333),
mime_type: "image/png".to_string(),
file_name: "style.png".to_string(),
},
];
let dimensions = prepare_editor_image_edit_references(&mut references, "1537x1025")
.expect("references should align for provider");
assert_eq!(dimensions.target_width, 1537);
assert_eq!(dimensions.target_height, 1025);
assert_eq!(dimensions.provider_width, 1552);
assert_eq!(dimensions.provider_height, 1040);
let source = image::load_from_memory(references[0].bytes.as_slice()).unwrap();
assert_eq!((source.width(), source.height()), (1552, 1040));
let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap();
assert_eq!((extra.width(), extra.height()), (512, 336));
let restored = restore_editor_image_edit_output_dimensions(
DownloadedOpenAiImage {
bytes: encode_png(1552, 1040),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
},
&dimensions,
)
.expect("provider output should restore source dimensions");
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(1537, 1025)
);
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"));