统一角色原图与透明图交付尺寸
角色原图持久化前归一到业务交付尺寸 拒绝错比例透明蒙版并降级保留原图 合并尺寸降级与后处理失败告警 补齐全模型全尺寸真实像素回归 同步角色图片尺寸边界文档
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user