diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 694bf03f4..2bff16c14 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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", diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5fe3dd109..4dc46c268 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 已成功回图后仍可能没有素材进入素材库和画布。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ffb125345..9ace874c3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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`:按图标规范图和素材描述数组生成 spritesheet;api-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` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-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`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 1f8eeebb0..38297a241 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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 端点。 - 不再在底部常驻展开全部可选项。 ## 泥点显示 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ad8143449..322e235b1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -267,6 +267,8 @@ pub struct EditorImageEditRequest { pub(crate) source_image_src: String, pub(crate) size: Option, pub(crate) model: Option, + pub(crate) aspect_ratio: Option, + pub(crate) image_size: Option, pub(crate) reference_image_srcs: Option>, pub(crate) project_id: Option, pub(crate) asset_kind: Option, @@ -2073,7 +2075,15 @@ async fn resolve_editor_image_edit_price( state: &AppState, model: &str, size: &str, + image_size: Option<&str>, ) -> Result { + 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::().ok()?; + let height = height.parse::().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()]) diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 31c4af815..dc9935d49 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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!( diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index e3a708552..75d5d6ac0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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(); + + 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(); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index cd86d0bd5..bfcb403dd 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -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( , ); @@ -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('-'); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index fb8b16ec2..f571fefdf 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1570,6 +1570,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ sourceImageSrc: sourceReferenceImageSrc, size: quickEditSize, model: normalizedQuickEditModel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, ...(extraReferenceImageSrcs.length ? { referenceImageSrcs: extraReferenceImageSrcs } : {}), diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 8f88ab06c..0a3f638c8 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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, diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 023859535..ca07f33e6 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -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', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 90b1abf28..58120cd19 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -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 } : {}),