修复图片改造尺寸保持
保持图片改造提交与画布回填的原图尺寸 在 VectorEngine 边界对齐非 16 倍数参考图并恢复目标尺寸 补充前后端回归测试与编辑器尺寸契约文档
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user