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
@@ -2737,7 +2737,27 @@
"type": "string"
},
"model": {
"type": "string"
"type": "string",
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。"
},
"aspectRatio": {
"type": "string",
"enum": [
"1:1",
"4:3",
"3:2",
"2:3",
"9:16",
"16:9"
]
},
"imageSize": {
"type": "string",
"enum": [
"0.5K",
"1K",
"2K"
]
},
"referenceImageSrcs": {
"type": "array",
+11 -3
View File
@@ -380,9 +380,9 @@
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
## 图片画布快速编辑完成必须按目标图层回写
@@ -400,6 +400,14 @@
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/ImageCanvasGenerationLayerModel.ts``server-rs/crates/api-server/src/editor_project.rs`
## 图片画布快速编辑模型必须在后端选择正确的 provider 协议
- 现象:快速编辑继承或选择 `nanobanana2` 后,上游返回 `not supported model for image generation`;图集开放快速编辑后尤其容易触发。
- 原因:前端把 `gemini-3.1-flash-image-preview` 正常提交到 `/api/editor/images/edits`,但后端无条件使用只支持 `gpt-image-2` 的 VectorEngine `/v1/images/edits` multipart 协议。
- 处理:快速编辑请求同时提交 `model / aspectRatio / imageSize`。api-server 归一模型后分流:`nanobanana2` 使用 `/v1beta/models/{model}:generateContent`,把原图和参考图放入 `inline_data`,并传递 `generationConfig.imageConfig``gpt-image-2` 继续使用 `/v1/images/edits` multipart 和 16 像素 provider 边界对齐。nanobanana2 保留 provider 输出几何尺寸,不套用 GPT edits 的像素恢复。
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖前端参数提交;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖模型分流、尺寸档位计费与 GPT 对齐恢复。
- 关联:`src/services/image-editor/editorProjectClient.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/openai_image_generation.rs`
## 图片画布纯尺寸变换必须先在内存决策再单次上传
- 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。
@@ -91,7 +91,7 @@
- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId``targetLayerId``assetFolderId``assetLabel``sourceResourceId``canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheetapi-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model``screenColor``segModel``aspectRatio``imageSize``priceMudPoints``projectId``assetFolderId``generationInputs``priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2``0.5K / 1K / 2K``gpt-image-2``1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize``0.5K``"512"``gpt-image-2``/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheetapi-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor``segModel``aspectRatio: "1:1"``imageSize: "1K" | "2K"``priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId``assetFolderId``generationInputs``spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸成功时只上传恢复结果,失败时只上传 provider 原图无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig``gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸nanobanana2 路径保留 provider 按比例和清晰度返回的几何尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast``seedance2.0``kling3.0``kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p``sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`
- `POST /api/editor/audios/sound-effects/generations``POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`
@@ -68,7 +68,7 @@
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 宣发素材的 `游戏首图``详情五图``运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data``gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。
- 不再在底部常驻展开全部可选项。
## 泥点显示
+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!(
@@ -834,6 +834,53 @@ describe('ImageCanvasEditorView', () => {
expect(resumedPrompt.value).not.toContain('2号红色圈选框');
});
it('locks quick edit selection tools after the edit task starts', async () => {
editEditorImageMock.mockReturnValueOnce(new Promise(() => {}));
render(<ImageCanvasEditorView />);
const sourceLayer = (
await screen.findByAltText('画布图片:拼图素材')
).closest('button')!;
dispatchPointerEvent(sourceLayer, 'pointerdown', {
button: 0,
pointerId: 74,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickEditDialog = screen.getByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEditDialog).getByLabelText('快速编辑提示词'), {
target: { value: '快速修图' },
});
fireEvent.click(
within(
screen.getByRole('toolbar', { name: '快速编辑框选工具' }),
).getByRole('button', { name: '矩形框选' }),
);
fireEvent.click(
within(quickEditDialog).getByRole('button', { name: '修改' }),
);
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByRole('toolbar', { name: '快速编辑框选工具' }),
).toBeNull();
expect(
screen.queryByRole('application', { name: 'UI素材框选画布' }),
).toBeNull();
const submitButton = within(quickEditDialog).getByRole('button', {
name: '修改',
});
expect((submitButton as HTMLButtonElement).disabled).toBe(true);
expect(submitButton.textContent).toBe('生成中');
});
it('opens the login modal immediately when entering the editor while logged out', async () => {
const openLoginModal = vi.fn();
@@ -539,12 +539,20 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
createGenerated({
prompt: '快速修图',
model: 'gemini-3.1-flash-image-preview',
}),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ originalWidth: 1537, originalHeight: 1025 }),
createLayer({
originalWidth: 1537,
originalHeight: 1025,
assetKind: 'icon-spritesheet',
model: 'gemini-3.1-flash-image-preview',
}),
]}
/>,
);
@@ -564,6 +572,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
sourceImageSrc: 'resource-source',
size: '2048x1365',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '3:2',
imageSize: '2K',
}),
);
});
@@ -574,7 +584,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图 快速编辑:resource-source:-:-:data:image/png;base64,generated',
'layer-source:源图 快速编辑:resource-source:icon-spritesheet:-:data:image/png;base64,generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
@@ -1570,6 +1570,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
sourceImageSrc: sourceReferenceImageSrc,
size: quickEditSize,
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
: {}),
@@ -823,11 +823,12 @@ export function useImageCanvasGenerationWorkflow({
(layer) => layer.id === uiAssetExtractionState.sourceLayerId,
) ?? null)
: null;
const quickEditSelectionSourceLayer = quickEditSelectionState
? (layers.find(
(layer) => layer.id === quickEditSelectionState.sourceLayerId,
) ?? null)
: null;
const quickEditSelectionSourceLayer =
quickEditPanel?.status !== 'generating' && quickEditSelectionState
? (layers.find(
(layer) => layer.id === quickEditSelectionState.sourceLayerId,
) ?? null)
: null;
const characterAnimationSourceLayer = effectiveCharacterAnimationPanel
? (layers.find(
(layer) => layer.id === effectiveCharacterAnimationPanel.sourceLayerId,
@@ -1581,6 +1581,8 @@ describe('editorProjectClient', () => {
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,style'],
projectId: 'editor-project-1',
assetKind: 'character',
@@ -1605,6 +1607,8 @@ describe('editorProjectClient', () => {
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,style'],
projectId: 'editor-project-1',
assetKind: 'character',
@@ -234,6 +234,8 @@ export type EditorImageEditInput = {
sourceImageSrc: string;
size?: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
referenceImageSrcs?: string[];
projectId?: string | null;
assetKind?: string | null;
@@ -1000,6 +1002,8 @@ export async function editEditorImage(input: EditorImageEditInput) {
sourceImageSrc: input.sourceImageSrc,
...(input.size ? { size: input.size } : {}),
...(input.model ? { model: input.model } : {}),
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
...(input.imageSize ? { imageSize: input.imageSize } : {}),
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),