统一角色原图与透明图交付尺寸
Project CI / Repository checks (push) Successful in 51s
Project CI / Frontend tests (push) Successful in 1m44s
Project CI / Native shell tests (push) Successful in 2m7s
Project CI / Backend tests (push) Successful in 3m16s

角色原图持久化前归一到业务交付尺寸
拒绝错比例透明蒙版并降级保留原图
合并尺寸降级与后处理失败告警
补齐全模型全尺寸真实像素回归
同步角色图片尺寸边界文档
This commit is contained in:
2026-07-29 16:10:50 +08:00
parent 6ee33dc561
commit 3a64411e8c
3 changed files with 243 additions and 35 deletions
@@ -16,6 +16,16 @@
---
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
- 决策:角色分支必须在持久化带纯色背景原图和调用 BgFilter 之前,先按统一业务像素矩阵执行一次尺寸归一;该原图和透明派生图始终使用同一实际像素尺寸,1K 的长边为 `1024`。若 provider 回图任意一边小于业务目标或比例偏差过大,仍禁止放大或大幅裁切;此时两张图一同保留 provider 实际尺寸并返回通用 `warning`,不允许只改透明图。BgFilter 回图尺寸漂移时只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并回贴到该原图;蒙版比例超限、回贴失败或尺寸验证失败时必须改用原图单产物降级,不持久化尺寸或比例不一致的透明图。若尺寸降级和后处理降级同时发生,同一条 `warning.reason` 必须同时保留两个原因。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色生成、原图持久化、BgFilter 输入、项目资源尺寸与画布图层 Resolution;不改变前端请求 DTO、扣费、素材类型或多产物布局。
- 验证方式:后端定向测试覆盖角色全尺寸矩阵:`nanobanana2``0.5K / 1K / 2K``gpt-image-2``1K / 2K`,每档均覆盖 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`,30 个组合全部构造大于目标尺寸的真实 PNG provider 回图并执行像素恢复,不只校验字符串映射;另覆盖欠尺寸禁止放大、比例超限、BgFilter 错比例 alpha 蒙版拒绝和组合告警。同时从函数调用顺序上固定“尺寸归一 → 持久化带背景原图 → BgFilter”。运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-23 画布 Agent 工具生命周期统一经 object-safe trait 分派
- 背景:画布 Agent 八类工具的参数规范化、确认展示、计价与 worker payload、完成结果格式化和媒体投影分别在 `tool_args.rs``display_args.rs``api.rs``reconcile.rs` 重复按工具名分派;新增或调整工具时容易漏改其中一处。
File diff suppressed because one or more lines are too long
+232 -34
View File
@@ -123,6 +123,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-con
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed";
const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved";
const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback";
const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05;
const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;
const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet";
const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset";
@@ -631,6 +632,19 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration
}
}
fn editor_postprocess_fallback_warning_with_dimension(
reason: &'static str,
dimension_warning: Option<&EditorGenerationWarningResponse>,
) -> EditorGenerationWarningResponse {
let reason = dimension_warning
.map(|warning| format!("{reason} {}", warning.reason))
.unwrap_or_else(|| reason.to_string());
EditorGenerationWarningResponse {
code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE,
reason,
}
}
fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse {
let reason = error
.details()
@@ -1774,18 +1788,16 @@ pub(crate) async fn generate_editor_image_for_owner(
// 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回
// 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一,
// 保证不同模型的完成图与生成前占位标注一致。
let (restored_image, dimension_restore_error) = if is_character_generation {
// 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存;
// 透明主结果会在后处理完成后再归一到画布业务规格
(image, None)
} else {
// 角色带背景原图和透明结果是同一个业务产物的两个版本,必须在
// provider 原图持久化前就收口到同一交付尺寸。否则 1K 任务会把模型原生
// 2752x1536 原图和长边 1024 的透明图同时放入画布
let (restored_image, dimension_restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
image,
generation_options.model,
image_size.as_ref(),
)
};
let mut dimension_warning = dimension_restore_error
);
let dimension_warning = dimension_restore_error
.as_ref()
.map(editor_dimension_restore_warning);
if let Some(error) = dimension_restore_error {
@@ -1907,8 +1919,9 @@ pub(crate) async fn generate_editor_image_for_owner(
resource: source_record.resource,
asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
warning: Some(editor_postprocess_fallback_warning_with_dimension(
"生成任务成功,后处理失败。",
dimension_warning.as_ref(),
)),
},
));
@@ -1933,8 +1946,39 @@ pub(crate) async fn generate_editor_image_for_owner(
delivery_width,
delivery_height,
error = ?error,
"角色透明图尺寸恢复失败,保留去背景服务原始输出"
"角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src: source_image_src,
object_key: Some(source_object_key),
asset_object_id: Some(source_asset_object_id),
width: delivery_width,
height: delivery_height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: source_record.resource,
asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning_with_dimension(
"生成任务成功,后处理失败。",
dimension_warning.as_ref(),
)),
},
));
}
image = restored_removal_image;
output_prompt = "去除纯色背景".to_string();
@@ -1948,27 +1992,6 @@ pub(crate) async fn generate_editor_image_for_owner(
None
};
if is_character_generation {
let (restored_image, dimension_restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
image,
generation_options.model,
image_size.as_ref(),
);
if let Some(error) = dimension_restore_error {
dimension_warning = Some(editor_dimension_restore_warning(&error));
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_size = %image_size,
error = ?error,
"画板角色透明主图尺寸恢复失败,保留后处理原图"
);
}
image = restored_image;
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
@@ -2810,6 +2833,28 @@ fn apply_editor_postprocessed_alpha_to_provider_source(
return Ok(None);
}
let source_ratio = f64::from(provider_source.width()) / f64::from(provider_source.height());
let postprocessed_ratio = f64::from(postprocessed.width()) / f64::from(postprocessed.height());
let ratio_drift = if source_ratio >= postprocessed_ratio {
1.0 - postprocessed_ratio / source_ratio
} else {
1.0 - source_ratio / postprocessed_ratio
};
if ratio_drift > EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-image-postprocess",
"code": "postprocessed-alpha-aspect-ratio-mismatch",
"message": "透明后处理图比例与原图偏差过大,禁止拉伸 Alpha 蒙版",
"sourceWidth": provider_source.width(),
"sourceHeight": provider_source.height(),
"postprocessedWidth": postprocessed.width(),
"postprocessedHeight": postprocessed.height(),
"ratioDrift": ratio_drift,
})),
);
}
// 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。
// 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。
let postprocessed = postprocessed.to_rgba8();
@@ -8627,6 +8672,39 @@ mod tests {
assert_eq!(restored.extension, "png");
}
#[test]
fn static_image_postprocess_rejects_wrong_aspect_ratio_alpha_mask() {
let provider_source = image::DynamicImage::new_rgba8(1024, 576);
let mut provider_source_bytes = Cursor::new(Vec::new());
provider_source
.write_to(&mut provider_source_bytes, image::ImageFormat::Png)
.expect("provider source should encode");
let provider_source = DownloadedOpenAiImage {
bytes: provider_source_bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let postprocessed = image::DynamicImage::new_rgba8(512, 512);
let mut postprocessed_bytes = Cursor::new(Vec::new());
postprocessed
.write_to(&mut postprocessed_bytes, image::ImageFormat::Png)
.expect("postprocessed image should encode");
let postprocessed = DownloadedOpenAiImage {
bytes: postprocessed_bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let error =
apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed)
.expect_err("wrong-aspect alpha masks must use source-only fallback");
assert_eq!(
error.details().and_then(|details| details.get("code")),
Some(&json!("postprocessed-alpha-aspect-ratio-mismatch"))
);
}
#[test]
fn publication_material_generation_restores_provider_output_to_workflow_dimensions() {
let image = image::DynamicImage::new_rgba8(944, 704);
@@ -8932,6 +9010,85 @@ mod tests {
assert_eq!(fallback.provider_image_size, "1K");
}
#[test]
fn character_generation_covers_full_model_ratio_and_size_matrix() {
for (image_size, aspect_ratio, expected_delivery_size) in [
("0.5K", "1:1", "512x512"),
("0.5K", "4:3", "512x384"),
("0.5K", "3:2", "512x341"),
("0.5K", "2:3", "341x512"),
("0.5K", "9:16", "288x512"),
("0.5K", "16:9", "512x288"),
("1K", "1:1", "1024x1024"),
("1K", "4:3", "1024x768"),
("1K", "3:2", "1024x683"),
("1K", "2:3", "683x1024"),
("1K", "9:16", "576x1024"),
("1K", "16:9", "1024x576"),
("2K", "1:1", "2048x2048"),
("2K", "4:3", "2048x1536"),
("2K", "3:2", "2048x1365"),
("2K", "2:3", "1365x2048"),
("2K", "9:16", "1152x2048"),
("2K", "16:9", "2048x1152"),
] {
let mut models = vec![EDITOR_IMAGE_MODEL_NANOBANANA2];
if image_size != "0.5K" {
models.push(GPT_IMAGE_2_MODEL);
}
for model in models {
let (expected_width, expected_height) =
parse_editor_image_edit_pixel_size(expected_delivery_size)
.expect("delivery matrix should contain valid pixel dimensions");
let options = normalize_editor_generation_options(
Some(model),
Some(aspect_ratio),
Some(image_size),
);
assert_eq!(
options.size, expected_delivery_size,
"{model} {image_size} {aspect_ratio} should use the shared delivery matrix"
);
assert_eq!(
resolve_editor_image_request_size(Some("character"), None, true, &options,),
expected_delivery_size,
"character source and cutout should share {model} {image_size} {aspect_ratio} delivery dimensions"
);
let provider_width = (expected_width * 9).div_ceil(8);
let provider_height = (expected_height * 9).div_ceil(8);
let provider_image =
image::DynamicImage::new_luma8(provider_width, provider_height);
let mut provider_bytes = Cursor::new(Vec::new());
provider_image
.write_to(&mut provider_bytes, image::ImageFormat::Png)
.expect("synthetic provider image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: provider_bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let (restored, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
model,
expected_delivery_size,
);
assert!(
restore_error.is_none(),
"{model} {image_size} {aspect_ratio} should restore a larger provider image"
);
let restored = image::load_from_memory(restored.bytes.as_slice())
.expect("restored character source should remain a valid image");
assert_eq!(
(restored.width(), restored.height()),
(expected_width, expected_height),
"{model} {image_size} {aspect_ratio} should restore real pixels before background removal"
);
}
}
}
#[test]
fn publication_material_request_size_keeps_workflow_pixels() {
let options =
@@ -10122,7 +10279,11 @@ mod tests {
}
#[test]
fn editor_character_postprocess_fallback_response_keeps_source_and_warning() {
fn editor_character_postprocess_fallback_response_keeps_source_and_dimension_warning() {
let dimension_warning = EditorGenerationWarningResponse {
code: EDITOR_GENERATION_DIMENSION_WARNING_CODE,
reason: "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位".to_string(),
};
let response = EditorImageGenerationResponse {
image_src: "/api/assets/source-character.png".to_string(),
object_key: Some("generated/editor/character-source.png".to_string()),
@@ -10138,8 +10299,9 @@ mod tests {
resource: None,
asset: None,
project: None,
warning: Some(editor_postprocess_fallback_warning(
warning: Some(editor_postprocess_fallback_warning_with_dimension(
"生成任务成功,后处理失败。",
Some(&dimension_warning),
)),
};
@@ -10159,7 +10321,9 @@ mod tests {
);
assert_eq!(
payload["warning"]["reason"],
json!("生成任务成功,后处理失败。")
json!(
"生成任务成功,后处理失败。 provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位"
)
);
}
@@ -11255,6 +11419,12 @@ mod tests {
.expect("character generation function end marker should exist");
let body = &function_tail[..end];
let dimension_restore = body
.find("restore_editor_generated_image_output_dimensions_or_original")
.expect("character provider image should be restored to delivery dimensions");
let source_persist = body
.find("persist_editor_provider_source_image")
.expect("character provider source should be persisted");
let phase = body
.find("caller.report_processing_phase(state).await?")
.expect("phase report should remain fallible");
@@ -11274,6 +11444,10 @@ mod tests {
phase < removal,
"phase errors must fail before fallback is considered"
);
assert!(
dimension_restore < source_persist && source_persist < removal,
"character delivery dimensions must be restored before source persistence and background removal"
);
for snippet in [
"Err(error)",
"complete_editor_canvas_generation",
@@ -11302,6 +11476,30 @@ mod tests {
assert!(success_tail.contains("persist_editor_generated_image("));
assert!(success_tail.contains("persist_editor_generated_asset("));
assert!(success_tail.contains(".await?;"));
let alpha_restore = body
.find("apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original")
.expect("character postprocess should restore alpha to the source dimensions");
let dimension_fallback = body[alpha_restore..]
.find("if let Some(error) = postprocess_dimension_error")
.map(|offset| alpha_restore + offset)
.expect("character postprocess dimension errors should be handled");
let processed_image = body[dimension_fallback..]
.find("image = restored_removal_image;")
.map(|offset| dimension_fallback + offset)
.expect("processed image should only continue after dimension validation");
let dimension_fallback_body = &body[dimension_fallback..processed_image];
for snippet in [
"complete_editor_canvas_generation",
"source_record.resource.as_ref()",
"return Ok(json_success_body",
"warning: Some(editor_postprocess_fallback_warning",
] {
assert!(
dimension_fallback_body.contains(snippet),
"character dimension fallback should contain {snippet}"
);
}
}
#[test]