From ed8be382870e5019df656418b83ae89fbf6c941d Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 10:22:59 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=B5=84=E6=BA=90=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增所有生成面板的资源名称输入并统一80字符边界 贯通画布标题、项目资源、素材库与生成请求的名称传播 为图标图集和角色动作补齐命名契约及中间产物后缀 补充前后端回归测试、OpenAPI与编辑器文档 --- .../genarrative-external-v1.openapi.json | 16 +++ .../shared-memory/decision-log.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + ...】生成类面板Lovart统一改造方案-2026-06-17.md | 1 + .../src/character_animation_assets.rs | 57 ++++++++- .../crates/api-server/src/editor_agent.rs | 1 + .../crates/api-server/src/editor_project.rs | 115 ++++++++++++++---- .../crates/shared-contracts/src/assets.rs | 6 + ...CanvasBasicGenerationComposerView.test.tsx | 13 +- ...ImageCanvasBasicGenerationComposerView.tsx | 15 +++ ...ImageCanvasCharacterAnimationPanelView.tsx | 6 + ...eCanvasCharacterGenerationComposerView.tsx | 15 +++ .../image-editor/ImageCanvasEditorTypes.ts | 3 + ...ageCanvasGenerationAssetNameField.test.tsx | 63 ++++++++++ .../ImageCanvasGenerationAssetNameField.tsx | 46 +++++++ .../ImageCanvasGenerationComposerView.tsx | 11 ++ .../ImageCanvasGenerationDialogModel.test.ts | 15 +++ .../ImageCanvasGenerationDialogModel.ts | 3 + .../ImageCanvasGenerationLayerModel.ts | 6 +- ...ageCanvasGenerationSubmissionModel.test.ts | 24 ++++ .../ImageCanvasGenerationSubmissionModel.ts | 74 +++++++++-- ...ImageCanvasIconSpritesheetComposerView.tsx | 23 ++++ ...anvasPublicationMaterialsDemoPanelView.tsx | 16 +++ .../ImageCanvasQuickEditPanelView.tsx | 2 + .../ImageCanvasSpecGenerationPanelView.tsx | 25 ++++ ...anvasGenerationSubmissionWorkflow.test.tsx | 63 ++++++++-- ...ImageCanvasGenerationSubmissionWorkflow.ts | 54 +++++--- .../useImageCanvasGenerationWorkflow.test.tsx | 28 ++++- .../useImageCanvasGenerationWorkflow.ts | 2 + src/index.css | 12 ++ .../image-editor/editorProjectClient.test.ts | 4 + .../image-editor/editorProjectClient.ts | 3 + 32 files changed, 671 insertions(+), 60 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasGenerationAssetNameField.test.tsx create mode 100644 src/components/image-editor/ImageCanvasGenerationAssetNameField.tsx diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index aedeea1b1..acc09d755 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2925,6 +2925,14 @@ "null" ] }, + "assetLabel": { + "type": [ + "string", + "null" + ], + "maxLength": 80, + "description": "写入素材库时使用的图标图集名称;空白或省略时使用自动名称。" + }, "canvasCompletion": { "anyOf": [ { @@ -3292,6 +3300,14 @@ "null" ], "description": "角色动作绿幕预览视频写入账号素材库的文件夹;省略时写入默认项目素材文件夹。" + }, + "assetLabel": { + "type": [ + "string", + "null" + ], + "maxLength": 80, + "description": "角色动作素材名称;空白或省略时使用自动名称。原始预览视频以该名称追加(原始视频)保存。" } }, "additionalProperties": false diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3c3ae8904..f439f7856 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 图片画布生成资源支持提交前统一命名 + +- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。 +- 决策:主生成状态统一使用可选 `assetLabel`,所有生成面板提供同一个资源名称输入。名称最多 80 个字符,提交时去除首尾空格;留空时继续使用现有自动编号名称。一次提交解析出的名称必须同时用于 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。 +- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。 +- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。 +- 验证方式:覆盖自定义名、空白回退、长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物 - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 22911a21a..7f4bc6fef 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -56,6 +56,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 +- 生成面板的资源命名统一写入可选 `assetLabel`,最大 80 字符并在提交时 trim。留空时提交模型保留原有自动编号;非空时同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样支持该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index d71764999..00d6c6e7d 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -119,6 +119,7 @@ - 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 - 图标和 UI 图集自动拆分属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 warning toast 提示用户可手动重试。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成图集标记为失败。 +- 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板统一提供“资源名称”输入,状态字段沿用请求契约 `assetLabel`。输入最多 80 个字符,提交时 trim;留空继续使用现有“类型 + 编号”名称。最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b1e50a023..391e33d95 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -41,9 +41,9 @@ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload, CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse, - EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest, - EditorCharacterAnimationGenerateResponse, EditorVideoGenerateRequest, - EditorVideoGenerateResponse, + EDITOR_ASSET_LABEL_MAX_CHARS, EditorCharacterAnimationFramePayload, + EditorCharacterAnimationGenerateRequest, EditorCharacterAnimationGenerateResponse, + EditorVideoGenerateRequest, EditorVideoGenerateResponse, }; use spacetime_client::SpacetimeClientError; @@ -634,6 +634,8 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let generation_inputs = payload.generation_inputs.clone(); let source_resource_id = payload.source_resource_id.clone(); let asset_folder_id = payload.asset_folder_id.clone(); + let asset_label = + resolve_editor_character_animation_asset_label(payload.asset_label.as_deref()); let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -742,7 +744,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( folder_id: asset_folder_id .clone() .or_else(|| Some("project".to_string())), - label: "角色动作原始视频".to_string(), + label: editor_character_animation_source_asset_label(asset_label.as_str()), image_src: generated.preview_video_path.clone(), object_key: generated.object_key.clone(), asset_object_id: generated.asset_object_id.clone(), @@ -4683,6 +4685,25 @@ fn trim_optional_text(value: Option<&str>) -> Option { .map(ToOwned::to_owned) } +fn resolve_editor_character_animation_asset_label(value: Option<&str>) -> String { + trim_optional_text(value) + .unwrap_or_else(|| "角色动作".to_string()) + .chars() + .take(EDITOR_ASSET_LABEL_MAX_CHARS) + .collect() +} + +fn editor_character_animation_source_asset_label(asset_label: &str) -> String { + let suffix = "(原始视频)"; + let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); + let base = asset_label + .trim() + .chars() + .take(base_char_limit) + .collect::(); + format!("{base}{suffix}") +} + fn clamp_prompt_seed_text(value: Option<&str>) -> String { trim_optional_text(value) .unwrap_or_default() @@ -5704,6 +5725,7 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_folder_id: None, + asset_label: Some(" 勇者待机 ".to_string()), }) .expect("editor request should normalize"); @@ -5715,6 +5737,31 @@ mod tests { assert_eq!(normalized.frame_width, 540); assert_eq!(normalized.frame_height, 720); assert_eq!(normalized.ratio, "3:4"); + assert_eq!( + resolve_editor_character_animation_asset_label(Some(" 勇者待机 ")), + "勇者待机" + ); + assert_eq!( + resolve_editor_character_animation_asset_label(Some(" ")), + "角色动作" + ); + assert_eq!( + resolve_editor_character_animation_asset_label(Some("动".repeat(81).as_str())) + .chars() + .count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert_eq!( + editor_character_animation_source_asset_label("勇者待机"), + "勇者待机(原始视频)" + ); + let long_source_label = + editor_character_animation_source_asset_label("动".repeat(80).as_str()); + assert_eq!( + long_source_label.chars().count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert!(long_source_label.ends_with("(原始视频)")); } #[test] @@ -5744,6 +5791,7 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_folder_id: None, + asset_label: None, }) .expect("editor request should normalize"); @@ -5773,6 +5821,7 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_folder_id: None, + asset_label: None, }) .expect_err("invalid frame/duration pair should fail"); diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs index 6ac84f022..a1d63d10a 100644 --- a/server-rs/crates/api-server/src/editor_agent.rs +++ b/server-rs/crates/api-server/src/editor_agent.rs @@ -1799,6 +1799,7 @@ async fn execute_editor_agent_tool_call( project_id: Some(conversation.project_id.clone()), generation_inputs, asset_folder_id: Some(editor_agent_default_asset_folder_id()), + asset_label: None, canvas_completion: Some(completion), }, ) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7425aa167..39ec8a3c4 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -30,6 +30,7 @@ use platform_oss::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::{ + EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; @@ -298,6 +299,7 @@ pub struct EditorIconSpritesheetGenerationRequest { pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, pub(crate) canvas_completion: Option, } @@ -1594,6 +1596,8 @@ pub(crate) async fn generate_editor_image_for_owner( payload.generation_inputs.clone(), screen_background_decision.as_ref(), ); + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str()); let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -1625,7 +1629,7 @@ pub(crate) async fn generate_editor_image_for_owner( .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), - label: format!("{}(原图)", role_setting), + label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"), width: source_width, height: source_height, prompt: role_setting.clone(), @@ -1683,8 +1687,7 @@ pub(crate) async fn generate_editor_image_for_owner( project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| role_setting.clone()), + label: asset_label, image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -2357,9 +2360,10 @@ pub(crate) async fn edit_editor_image_for_owner( "vector-engine", ) .await?; - let source_label = normalize_optional_string(payload.asset_label.clone()) - .map(|label| format!("{label}(原始输出)")) - .unwrap_or_else(|| "图片修改原始输出".to_string()); + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果"); + let source_label = + editor_generated_asset_variant_label(asset_label.as_str(), "原始输出"); let _source_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -2414,8 +2418,7 @@ pub(crate) async fn edit_editor_image_for_owner( project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| "图片修改结果".to_string()), + label: asset_label.clone(), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -2450,6 +2453,7 @@ pub(crate) async fn edit_editor_image_for_owner( payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), + Some(asset_label.as_str()), true, ) .await? @@ -2537,14 +2541,15 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果"); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| "去背景结果".to_string()), + label: asset_label.clone(), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -2579,6 +2584,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), + Some(asset_label.as_str()), false, ) .await? @@ -3518,6 +3524,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( payload.generation_inputs.clone(), Some(&screen_background_decision), ); + let spritesheet_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集"); let asset_folder_id = payload .asset_folder_id .clone() @@ -3540,7 +3548,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), - label: "图标素材图集(原图)".to_string(), + label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), width: source_width, height: source_height, prompt: prompt.clone(), @@ -3594,7 +3602,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), - label: "图标素材图集".to_string(), + label: spritesheet_label, image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), @@ -4109,8 +4117,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( 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_label = + resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集"); let asset_folder_id = payload .asset_folder_id .clone() @@ -4133,7 +4141,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), - label: format!("{}(原图)", source_label), + label: editor_generated_asset_variant_label(source_label.as_str(), "原图"), width: source_width, height: source_height, prompt: prompt.clone(), @@ -4187,8 +4195,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), - label: normalize_optional_string(payload.spritesheet_label) - .unwrap_or_else(|| "UI设计图素材图集".to_string()), + label: source_label, image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), @@ -5141,6 +5148,7 @@ pub(crate) async fn complete_editor_canvas_background_removal( project_id: Option<&str>, target_layer_id: Option<&str>, resource: Option<&EditorProjectResourcePayload>, + replacement_title: Option<&str>, recenter_replacement: bool, ) -> Result, AppError> { let (Some(project_id), Some(target_layer_id), Some(resource)) = @@ -5169,6 +5177,7 @@ pub(crate) async fn complete_editor_canvas_background_removal( project_payload.layers, target_layer_id, resource, + replacement_title, recenter_replacement, ) else { return Ok(None); @@ -5193,6 +5202,7 @@ fn replace_editor_canvas_layer_with_resource( layers: Value, target_layer_id: &str, resource: &EditorProjectResourcePayload, + replacement_title: Option<&str>, recenter_replacement: bool, ) -> Option { let mut items = match layers { @@ -5235,6 +5245,12 @@ fn replace_editor_canvas_layer_with_resource( "resourceId".to_string(), Value::String(resource.resource_id.clone()), ); + if let Some(title) = replacement_title + .map(str::trim) + .filter(|title| !title.is_empty()) + { + object.insert("title".to_string(), Value::String(title.to_string())); + } object.insert("src".to_string(), Value::String(resource.image_src.clone())); if let Some((x, y)) = centered_position { object.insert("x".to_string(), json!(x)); @@ -5915,6 +5931,25 @@ pub(crate) fn normalize_optional_string(value: Option) -> Option .filter(|item| !item.is_empty()) } +fn resolve_editor_generated_asset_label(value: Option, fallback: &str) -> String { + normalize_optional_string(value) + .unwrap_or_else(|| fallback.to_string()) + .chars() + .take(EDITOR_ASSET_LABEL_MAX_CHARS) + .collect() +} + +fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String { + let suffix = format!("({variant})"); + let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); + let base = label + .trim() + .chars() + .take(base_char_limit) + .collect::(); + format!("{base}{suffix}") +} + pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() @@ -7793,6 +7828,7 @@ mod tests { layers.clone(), "layer-source", &resource, + None, false, ) .expect("target layer should be replaced"); @@ -7817,18 +7853,25 @@ mod tests { assert_eq!(layer["taskId"], json!("extgen-cutout")); assert_eq!(layer["sourceResourceId"], json!("resource-source")); assert_eq!(layer["assetKind"], json!("character")); + assert_eq!(layer["title"], json!("源图")); assert!(layer.get("sourceAssetId").is_none()); assert!(layer.get("thumbnailSrc").is_none()); - let recentered = - replace_editor_canvas_layer_with_resource(layers, "layer-source", &resource, true) - .expect("quick edit replacement should recenter the layer"); + let recentered = replace_editor_canvas_layer_with_resource( + layers, + "layer-source", + &resource, + Some("勇者快速编辑"), + true, + ) + .expect("quick edit replacement should recenter the layer"); let recentered_layer = recentered .as_array() .and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source")) .expect("quick edit source layer should remain"); assert_eq!(recentered_layer["x"], json!(24.0)); assert_eq!(recentered_layer["y"], json!(-124.0)); + assert_eq!(recentered_layer["title"], json!("勇者快速编辑")); assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0); assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0); } @@ -8830,7 +8873,8 @@ mod tests { "referenceImageSrcs": ["data:image/png;base64,style"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", - "segModel": "anime-seg" + "segModel": "anime-seg", + "assetLabel": " 冒险界面图标 " })) .expect("icon spritesheet request should deserialize references"); @@ -8840,6 +8884,35 @@ mod tests { ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); + assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 ")); + } + + #[test] + fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() { + assert_eq!( + resolve_editor_generated_asset_label( + Some(" 冒险界面图标 ".to_string()), + "图标素材图集" + ), + "冒险界面图标" + ); + assert_eq!( + resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"), + "图标素材图集" + ); + assert_eq!( + resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集") + .chars() + .count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert_eq!( + editor_generated_asset_variant_label("冒险界面图标", "原图"), + "冒险界面图标(原图)" + ); + let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图"); + assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS); + assert!(long_variant.ends_with("(原图)")); } #[test] diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index eb772a9e9..58e0b3958 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -5,6 +5,8 @@ use serde_json::Value; use crate::external_generation::ExternalGenerationJobStatusRecord; +pub const EDITOR_ASSET_LABEL_MAX_CHARS: usize = 80; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateDirectUploadTicketRequest { @@ -332,6 +334,8 @@ pub struct EditorCharacterAnimationGenerateRequest { pub source_resource_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub asset_folder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_label: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1111,6 +1115,7 @@ mod tests { generation_inputs: None, source_resource_id: Some("resource-source".to_string()), asset_folder_id: Some("project".to_string()), + asset_label: Some("勇者奔跑".to_string()), }) .expect("request should serialize"); @@ -1125,6 +1130,7 @@ mod tests { assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1")); assert_eq!(payload["sourceResourceId"], json!("resource-source")); assert_eq!(payload["assetFolderId"], json!("project")); + assert_eq!(payload["assetLabel"], json!("勇者奔跑")); } #[test] diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index a5bf9c096..596ea1135 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -58,6 +58,7 @@ function BasicGenerationHarness({ onSubmit={onSubmit} /> {dialog.prompt} + {dialog.assetLabel ?? '-'} {dialog.aspectRatio} {dialog.imageSize} {dialog.imageModel} @@ -86,17 +87,27 @@ describe('ImageCanvasBasicGenerationComposerView', () => { fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的提示' }, }); + fireEvent.change(screen.getByLabelText('资源名称'), { + target: { value: '自定义主视觉' }, + }); const panel = screen.getByRole('dialog', { name: '生成图片' }); fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); + expect(screen.getByLabelText('当前资源名称').textContent).toBe( + '自定义主视觉', + ); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); expect(requestUpload).toHaveBeenCalledWith('generation-reference'); expect(submitGeneration).toHaveBeenCalledWith( - expect.objectContaining({ prompt: '新的提示', status: 'idle' }), + expect.objectContaining({ + prompt: '新的提示', + assetLabel: '自定义主视觉', + status: 'idle', + }), ); }); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index ce63f14c6..e21d649cc 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -18,6 +18,7 @@ import type { GenerateDialogState, UploadTarget, } from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; @@ -267,6 +268,20 @@ export function ImageCanvasBasicGenerationComposerView({ ) } /> + + setGenerateDialog((currentDialog) => + currentDialog + ? { + ...resetFailedDialogStatus(currentDialog), + assetLabel, + } + : currentDialog, + ) + } + />
+ updatePanel({ assetLabel })} + />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( +