From 5536fba95e2b793e38411af2593659e7f43ea10e Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 18:28:54 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=BB=E5=B8=83=E5=A4=9A?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E7=94=9F=E6=88=90=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将角色、图标图集和UI素材提取的原图登记为项目资源并随处理结果写入画布 保持后处理结果为生成器主锚点并将原图错开放置 新增原图标签、后端多产物布局测试和前端快照回归 同步更新图片画布方案与共享决策记录 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + ...】生成类面板Lovart统一改造方案-2026-06-17.md | 1 + .../crates/api-server/src/editor_project.rs | 449 ++++++++++++++++-- .../image-editor/ImageCanvasEditorModel.ts | 1 + .../image-editor/ImageCanvasEditorTypes.ts | 1 + .../ImageCanvasWorldView.test.tsx | 8 + .../image-editor/ImageCanvasWorldView.tsx | 5 + ...anvasGenerationSubmissionWorkflow.test.tsx | 44 +- src/index.css | 5 + 10 files changed, 483 insertions(+), 40 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c9d78e429..9d11b24a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-13 图片画布多产物生成任务必须回填全部产物 + +- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;此前原图只登记到 OSS,后端完成快照只把后处理结果放到画布,用户无法在画布继续使用同一任务的完整产物。 +- 决策:凡一次资产生成任务产生多个可复用产物,所有产物都必须登记为 `editor_project_resource` 并由同一次后端 `canvasCompletion` 写入权威画布快照,不能只返回最终产物或由前端临时补图。角色形象、图标 spritesheet 和 UI 素材提取至少同时保留纯色背景原图与透明后处理结果;UI 素材提取还继续保留拆分素材。生成器 `generatedLayerId` 始终锚定主后处理结果,附属产物在主结果右侧错开放置,避免重叠并保持后续改造入口语义不变。没有项目上下文时继续只持久化 OSS / asset object,不虚构画布资源。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布生成完成快照、角色形象 / 图标 spritesheet / UI 素材提取链路和图片画布文档。 +- 验证方式:覆盖多产物画布布局、主结果锚定和资源去重的定向 Rust 测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-12 泥点充值收敛为四档并统一资产入口 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ebff57a16..22911a21a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -22,6 +22,7 @@ - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:前端固定提交 `screenColor=auto`,后端视觉决策出具体 hex 并把源角色图合成到该背景色后再图生视频;抽帧后逐帧优先阿里云通用抠图,失败降级本地 `editor_green_screen`(按选定背景色,而非固定 `#00FF00`)。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index a9f89c1de..3c59dfd14 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -116,6 +116,7 @@ - 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 - 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 +- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9969b55aa..482d789e9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1554,14 +1554,21 @@ pub(crate) async fn generate_editor_image_for_owner( let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + screen_background_decision.as_ref(), + ); let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; - if is_character_generation { - persist_editor_green_screen_source_image( + let source_image = if is_character_generation { + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((1024, 1024)); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -1588,7 +1595,35 @@ pub(crate) async fn generate_editor_image_for_owner( &matting_audit, ) .await?; - } + Some((source_persisted, source_width, source_height)) + } else { + None + }; + let source_record = if let Some((source_persisted, source_width, source_height)) = source_image + { + Some( + persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + label: format!("{}(原图)", role_setting), + width: source_width, + height: source_height, + prompt: role_setting.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: payload.source_resource_id.clone(), + generation_inputs: generation_inputs.clone(), + }, + ) + .await?, + ) + } else { + None + }; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) @@ -1629,21 +1664,31 @@ pub(crate) async fn generate_editor_image_for_owner( task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, - generation_inputs: apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs, - screen_background_decision.as_ref(), - ), + generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(configured_price_mud_points), }, ) .await?; - let completed_project = complete_editor_canvas_generation( + let (canvas_items, primary_layer_id) = + if let Some(completion) = payload.canvas_completion.as_ref() { + build_multi_asset_canvas_layer_items( + completion, + generated_asset.resource.as_ref(), + source_record + .as_ref() + .and_then(|record| record.resource.as_ref()), + ) + } else { + (Vec::new(), None) + }; + let completed_project = complete_editor_canvas_generation_with_items( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - generated_asset.resource.as_ref(), + canvas_items, + primary_layer_id, ) .await?; @@ -3199,7 +3244,14 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "message": "VectorEngine 未返回图标 spritesheet", })) })?; - persist_editor_green_screen_source_image( + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((512, 512)); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + Some(&screen_background_decision), + ); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -3226,13 +3278,27 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( &matting_audit, ) .await?; + let source_record = persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + label: "图标素材图集(原图)".to_string(), + width: source_width, + height: source_height, + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: None, + generation_inputs: generation_inputs.clone(), + }, + ) + .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); - let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs.clone(), - Some(&screen_background_decision), - ); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -3281,6 +3347,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -3500,7 +3567,16 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": "VectorEngine 未返回 UI 设计图素材 spritesheet", })) })?; - persist_editor_green_screen_source_image( + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((512, 512)); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + Some(&screen_background_decision), + ); + let source_label = normalize_optional_string(payload.spritesheet_label.clone()) + .unwrap_or_else(|| "UI设计图素材图集".to_string()); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -3527,6 +3603,24 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( &matting_audit, ) .await?; + let source_record = persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + label: format!("{}(原图)", source_label), + width: source_width, + height: source_height, + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: None, + generation_inputs: generation_inputs.clone(), + }, + ) + .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); @@ -3542,10 +3636,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": error.to_string(), })) })?; - let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs.clone(), - Some(&screen_background_decision), - ); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -3655,6 +3745,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -4919,34 +5010,78 @@ fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { } } +fn build_multi_asset_canvas_layer_items( + completion: &EditorCanvasGenerationCompletionRequest, + primary_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, +) -> (Vec, Option) { + let Some(primary_resource) = primary_resource else { + return (Vec::new(), None); + }; + let placeholder = completion.placeholder.clone(); + let primary_layer_id = generated_canvas_layer_id(primary_resource.resource_id.as_str()); + let primary_item = build_generated_canvas_layer_item( + completion, + &placeholder, + primary_resource, + primary_layer_id.as_str(), + 0, + ); + let primary_right = primary_item + .get("x") + .and_then(Value::as_f64) + .unwrap_or(placeholder.x) + + primary_item + .get("width") + .and_then(Value::as_f64) + .unwrap_or(placeholder.width.max(1.0)); + let primary_y = primary_item + .get("y") + .and_then(Value::as_f64) + .unwrap_or(placeholder.y); + let mut items = vec![primary_item]; + + if let Some(source_resource) = source_resource { + let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str()); + let mut source_item = build_generated_canvas_layer_item( + completion, + &placeholder, + source_resource, + source_layer_id.as_str(), + 0, + ); + if let Some(object) = source_item.as_object_mut() { + object.insert("x".to_string(), json!(primary_right + 32.0)); + object.insert("y".to_string(), json!(primary_y)); + object.insert( + "title".to_string(), + json!(format!("{}(原图)", completion.title.trim())), + ); + } + items.push(source_item); + } + + (items, Some(primary_layer_id)) +} + fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, icons: &[EditorIconSpritesheetIconResponse], ) -> (Vec, Option) { - let mut items = Vec::new(); - let mut primary_layer_id = None; let placeholder = completion.placeholder.clone(); - let Some(spritesheet_resource) = spritesheet_resource else { + let (mut items, primary_layer_id) = + build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource); + if items.is_empty() { return (items, primary_layer_id); - }; + } - let spritesheet_layer_id = generated_canvas_layer_id(spritesheet_resource.resource_id.as_str()); - primary_layer_id = Some(spritesheet_layer_id.clone()); - let spritesheet_item = build_generated_canvas_layer_item( - completion, - &placeholder, - spritesheet_resource, - spritesheet_layer_id.as_str(), - 0, - ); - let sheet_width = spritesheet_item - .get("width") - .and_then(Value::as_f64) - .unwrap_or(placeholder.width.max(1.0)); - items.push(spritesheet_item); - - let mut cursor_x = placeholder.x + sheet_width + 32.0; + let mut cursor_x = items + .iter() + .filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?)) + .fold(placeholder.x, f64::max) + + 32.0; let mut cursor_y = placeholder.y; let mut row_height = 0.0_f64; let max_row_width = 560.0_f64; @@ -5345,6 +5480,20 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +struct PersistEditorGreenScreenSourceResourceInput { + project_id: Option, + owner_user_id: String, + label: String, + width: u32, + height: u32, + prompt: String, + actual_prompt: Option, + model: String, + task_id: String, + source_resource_id: Option, + generation_inputs: Option, +} + pub(crate) async fn persist_editor_generated_image( state: &AppState, owner_user_id: &str, @@ -5474,6 +5623,38 @@ async fn persist_editor_green_screen_source_image( .await } +async fn persist_editor_green_screen_source_resource( + state: &AppState, + persisted: PersistedEditorGeneratedImage, + input: PersistEditorGreenScreenSourceResourceInput, +) -> Result { + persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: input.project_id, + owner_user_id: input.owner_user_id, + folder_id: None, + label: input.label, + image_src: editor_media_src_from_object_key(persisted.object_key.as_str()), + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + width: input.width, + height: input.height, + prompt: input.prompt, + actual_prompt: input.actual_prompt, + model: input.model, + provider: "VectorEngine".to_string(), + task_id: input.task_id, + source_resource_id: input.source_resource_id, + asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + generation_inputs: input.generation_inputs, + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await +} + const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; @@ -5922,6 +6103,49 @@ mod tests { use super::*; use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; + fn editor_project_resource_for_canvas_test( + resource_id: &str, + asset_kind: &str, + width: u32, + height: u32, + ) -> EditorProjectResourcePayload { + EditorProjectResourcePayload { + resource_id: resource_id.to_string(), + showcase_id: None, + asset_id: None, + label: None, + project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), + author_display_name: None, + author_public_user_code: None, + image_src: format!("/generated-character-drafts/editor/{resource_id}.png"), + object_key: Some(format!( + "generated-character-drafts/editor/{resource_id}.png" + )), + asset_object_id: Some(format!("asset-object-{resource_id}")), + width, + height, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: Some("实际提示词".to_string()), + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some(asset_kind.to_string()), + showcase_category: None, + generation_inputs: None, + public_showcase_enabled: true, + review_status: None, + display_enabled: None, + like_count: None, + generation_cost_mud_points: 0, + refund_mud_points: None, + created_at: "2026-07-13T00:00:00.000Z".to_string(), + updated_at: "2026-07-13T00:00:00.000Z".to_string(), + } + } + #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure( @@ -7229,6 +7453,147 @@ mod tests { assert_eq!(result_layer["y"], json!(290.0)); } + #[test] + fn editor_canvas_multi_asset_items_keep_source_and_anchor_processed_result() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-character".to_string()), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 200.0, + height: 300.0, + original_width: 400.0, + original_height: 600.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-character-cutout", + "character", + 400, + 600, + ); + let source = editor_project_resource_for_canvas_test( + "resource-character-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 400, + 600, + ); + + let (items, primary_layer_id) = + build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source)); + + assert_eq!(items.len(), 2); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-character-cutout") + ); + assert_eq!(items[0]["resourceId"], json!("resource-character-cutout")); + assert_eq!(items[0]["x"], json!(0.0)); + assert_eq!(items[0]["y"], json!(-70.0)); + assert_eq!(items[1]["resourceId"], json!("resource-character-source")); + assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); + assert_eq!(items[1]["x"], json!(432.0)); + assert_eq!(items[1]["y"], json!(-70.0)); + + let layers = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "dialog": { + "id": "dialog-character", + "status": "generating", + "placeholder": completion.placeholder.clone() + } + }]); + let completed = apply_editor_canvas_generation_multi_items( + layers, + &completion, + items, + primary_layer_id, + ) + .expect("multi-asset completion should apply"); + let completed_items = completed + .layers + .as_array() + .expect("layers should stay array"); + assert_eq!( + completed_items + .iter() + .filter(|item| item + .get("resourceId") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("resource-character-"))) + .count(), + 2 + ); + let dialog = completed_items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("generation dialog should remain"); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-resource-character-cutout") + ); + } + + #[test] + fn editor_canvas_spritesheet_items_include_source_sheet_and_slices() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-ui-assets".to_string()), + title: "UI素材图集".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 256.0, + height: 256.0, + original_width: 512.0, + original_height: 512.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-ui-sheet", + "icon-spritesheet", + 512, + 512, + ); + let source = editor_project_resource_for_canvas_test( + "resource-ui-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 512, + 512, + ); + let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128); + let icons = vec![EditorIconSpritesheetIconResponse { + name: "开始按钮".to_string(), + image_src: slice.image_src.clone(), + width: 128, + height: 128, + resource: Some(slice), + asset: None, + }]; + + let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( + &completion, + Some(&processed), + Some(&source), + icons.as_slice(), + ); + + assert_eq!(items.len(), 3); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-ui-sheet") + ); + assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); + assert_eq!(items[1]["resourceId"], json!("resource-ui-source")); + assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); + let source_right = + items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); + assert!(items[2]["x"].as_f64().unwrap() > source_right); + } + #[test] fn editor_canvas_generation_completion_skips_when_dialog_was_removed() { let layers = json!([ @@ -7819,6 +8184,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", "if is_character_generation", ], ); @@ -7829,6 +8195,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", ], ); assert_function_contains( @@ -7838,6 +8205,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", ], ); assert_function_contains_in_order( @@ -7847,6 +8215,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", ], ); assert_function_contains( @@ -7856,6 +8225,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", "slice_generated_icon_spritesheet_all_by_connected_components", ], ); @@ -7866,6 +8236,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", ], ); assert_function_contains_in_order( diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index eb756a7b6..66b02fce5 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1145,6 +1145,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { value === 'icon' || value === 'icon-spritesheet' || value === 'icon-spec' || + value === 'editor_green_screen_source' || value === 'publication-material' || value === 'ui-design' || value === 'video' || diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index efc896315..e975483a5 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -20,6 +20,7 @@ export type CanvasAssetKind = | 'icon' | 'icon-spritesheet' | 'icon-spec' + | 'editor_green_screen_source' | 'publication-material' | 'ui-design' | 'video' diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index d395f2437..441d96468 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -281,6 +281,12 @@ describe('ImageCanvasWorldView', () => { title: '角色标签图层', assetKind: 'character', }), + createLayer({ + id: 'layer-source', + resourceId: 'resource-source', + title: '角色原图', + assetKind: 'editor_green_screen_source', + }), ], }); @@ -288,9 +294,11 @@ describe('ImageCanvasWorldView', () => { const characterButton = screen.getByRole('button', { name: '选择角色标签图层', }); + const sourceButton = screen.getByRole('button', { name: '选择角色原图' }); expect(within(layerButton).getByText('未知')).toBeTruthy(); expect(within(characterButton).getByText('角色')).toBeTruthy(); + expect(within(sourceButton).getByText('原图')).toBeTruthy(); }); it('renders snap guides, marquee and floating generation status', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a28d6401b..39c52b17c 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -205,6 +205,11 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ { value: 'icon', label: '图标', className: 'icon' }, { value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' }, { value: 'icon-spec', label: '图标规范', className: 'icon-spec' }, + { + value: 'editor_green_screen_source', + label: '原图', + className: 'source-image', + }, { value: 'publication-material', label: '宣发素材', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 2d548bc06..13fa81677 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1307,8 +1307,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { layerId: 'layer-editor-resource-character', resourceId: 'editor-resource-character', }, + { + layerId: 'layer-editor-resource-character-source', + resourceId: 'editor-resource-character-source', + }, + ], + resources: [ + { + resourceId: 'editor-resource-character', + projectId: 'editor-project-1', + imageSrc: '/generated-character-drafts/editor/character.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character', + }, + { + resourceId: 'editor-resource-character-source', + projectId: 'editor-project-1', + imageSrc: + '/generated-character-drafts/editor/character-green-screen-source.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'editor_green_screen_source', + }, ], - resources: [], updatedAt: '2026-06-21T12:16:22.000Z', }, }); @@ -1366,6 +1390,24 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect.objectContaining({ projectId: 'editor-project-1', title: '角色项目', + layers: expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'editor-resource-character', + }), + expect.objectContaining({ + resourceId: 'editor-resource-character-source', + }), + ]), + resources: expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'editor-resource-character', + assetKind: 'character', + }), + expect.objectContaining({ + resourceId: 'editor-resource-character-source', + assetKind: 'editor_green_screen_source', + }), + ]), }), ); }); diff --git a/src/index.css b/src/index.css index 8df8a0ccb..1082c3b1b 100644 --- a/src/index.css +++ b/src/index.css @@ -6335,6 +6335,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 8px 18px rgba(13, 148, 136, 0.24); } +.image-canvas-editor__kind-badge--source-image { + background: rgba(71, 85, 105, 0.9); + box-shadow: 0 8px 18px rgba(71, 85, 105, 0.22); +} + .image-canvas-editor__kind-badge--ui-design { background: rgba(245, 158, 11, 0.94); box-shadow: 0 8px 18px rgba(245, 158, 11, 0.24);