Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library
This commit is contained in:
@@ -24,6 +24,14 @@
|
||||
- 验证方式:运行宣发素材提交模型 / 面板测试、`api-server` 宣发素材模型锁定测试、前端类型检查、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-22 创作主页精选只展示项目内画布生成资源
|
||||
|
||||
- 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。
|
||||
- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。
|
||||
- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、最近项目列表资源读取、创作主页改版计划和精选素材相关测试。
|
||||
- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts` 与 `CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-22 编辑器生成模型默认定价调整
|
||||
|
||||
- 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。
|
||||
@@ -2632,3 +2640,11 @@
|
||||
- 2026-06-22 补充:所有明确设置绿幕用于后续抠图的 prompt 都必须固定写明 `#00FF00 / RGB(0,255,0)`,不能只写“纯绿色绿幕”或“接近 #00FF00”;编辑器角色图通用抠图额外开启暗绿 / 灰绿绿幕背景识别,只作为生成模型偏离标准亮绿时的兜底。该宽松识别只参与从画布边缘连通扩散出的背景清理,不参与全图断开绿色区域删除,避免误伤角色衣物或纹理。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet_muted_green_alpha_requires_explicit_option`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-22 图片画布外部生成统一改走 worker 队列
|
||||
|
||||
- 背景:图片画布的图片、改图、图标素材、UI 素材提取、角色动作、视频和音频生成都可能长时间等待外部 provider;如果继续由 HTTP handler 同步执行,生产只能扩 API 进程,不能独立扩生成吞吐。
|
||||
- 决策:`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画板所有外部 provider 生成入口统一入 `external_generation_job`,job kind 使用 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。worker 成功后由后端写 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`;前端只轮询 BFF job 状态并重新读取项目快照,不从队列 payload 或本地临时状态重建完成图层。
|
||||
- 补充:带 `dialogId` 的 `canvasCompletion` 必须读取后端当前 layout 中的最新 generation dialog placeholder;等待期间用户移动占位时,结果层要跟随最新占位。无 dialog 的重绘 / UI 素材提取等入口使用明确的右侧完成占位;生成器已删除时不把结果重新塞回画布。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`。
|
||||
- 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -87,6 +87,14 @@
|
||||
- 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。
|
||||
|
||||
## 图片画布素材多时拖拽卡顿先查等距吸附候选规模
|
||||
|
||||
- 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。
|
||||
- 原因:边缘 / 中心线吸附是线性扫描,但等距吸附如果对所有可吸附素材做两两配对,会在素材数量上来后进入 O(n²) 热路径。
|
||||
- 处理:保留边缘 / 中心线全量线性扫描;等距吸附先过滤跨轴相交素材,再只检查轴向邻近候选,不要为远处或不相交素材生成配对候选。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器宣发素材生成器刷新后不要丢快照
|
||||
|
||||
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
|
||||
@@ -135,6 +143,14 @@
|
||||
- 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片画布发布入口 429 先查自动保存 PATCH 并发
|
||||
|
||||
- 现象:发布域名访问画板时出现短时间密集 `429`,Nginx access log 中 `PATCH /api/editor/projects/<projectId>`、生成接口和资料接口混杂,429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting connections by zone "genarrative_api_conn"`。
|
||||
- 原因:这类 429 是入口 Nginx `limit_conn` 在转发前拒绝,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。画布自动保存如果只有防抖、没有 in-flight 串行保护,慢 `PATCH /api/editor/projects/{projectId}` 未完成时,拖拽生成器、资源回填和后续状态变化会继续发起新的保存请求,同一客户端连接数被长请求撑满后触发入口连接限流。
|
||||
- 处理:不要先放大 Nginx 限流或把错误归给生成 provider;先看 access log 的 `upstream_status` / `request_time` 和 error log 的 `limit_conn` zone,再查前端保存路径。`useImageCanvasProjectPersistence` 中自动保存和资源创建后的布局保存必须共用串行队列:同一时刻只允许一个 `saveEditorProjectLayout` in-flight,期间新快照覆盖旧待保存快照,当前保存结束后只发送最新一次。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose` 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 `upstream_status=-` / Nginx `limit_conn` 收敛。
|
||||
- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
|
||||
|
||||
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销重做、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。
|
||||
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
|
||||
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
|
||||
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
|
||||
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
|
||||
- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
|
||||
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。
|
||||
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
|
||||
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
|
||||
|
||||
@@ -43,11 +45,11 @@
|
||||
- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。
|
||||
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框),BFF 在生成成功后必须直接读取当前项目布局,只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `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`;宣发素材生成器还必须保存并恢复 `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` 时完成。带 `canvasCompletion` 的图片生成以 BFF 返回的 `project` 快照刷新画布;无项目上下文或没有返回快照时,前端才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
@@ -112,7 +114,7 @@
|
||||
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。
|
||||
- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。
|
||||
- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。
|
||||
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
||||
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 外部生成 Worker 化方案
|
||||
|
||||
更新时间:`2026-06-12`
|
||||
更新时间:`2026-06-22`
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。
|
||||
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
|
||||
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。
|
||||
|
||||
@@ -48,7 +48,7 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍
|
||||
| --- | --- |
|
||||
| `job_id` | 主键,`extgen-` 前缀 UUID |
|
||||
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
|
||||
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background` |
|
||||
| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` |
|
||||
| `owner_user_id` | 触发用户 |
|
||||
| `source_module` | 玩法或能力名,例如 `puzzle` |
|
||||
| `source_entity_id` | session/profile/work 等作用域 |
|
||||
@@ -161,6 +161,20 @@ controller 配置:
|
||||
|
||||
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。
|
||||
|
||||
### 图片画布编辑器
|
||||
|
||||
图片画布 `/editor/canvas` 下所有会调用外部生成 provider 的入口在 `queue` 模式下入 `external_generation_job`,HTTP handler 只返回 `queueState`:
|
||||
|
||||
- `editor_image_generation`:普通图片、生成规范、角色形象、UI 设计图、宣发素材和图片快速编辑。
|
||||
- `editor_image_edit`:图片编辑 / 修改结果。
|
||||
- `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。
|
||||
- `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。
|
||||
- `editor_character_animation_generation`:角色动作视频和帧素材生成。
|
||||
- `editor_video_generation`:画布视频生成和视频素材快速编辑。
|
||||
- `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。
|
||||
|
||||
画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset` 和 `editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。
|
||||
|
||||
## 验收
|
||||
|
||||
基础检查:
|
||||
|
||||
@@ -245,7 +245,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
|
||||
### `ai_text_chunk`
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
||||
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。
|
||||
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。
|
||||
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。
|
||||
- `陶泥儿精选` 只展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
|
||||
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
|
||||
|
||||
## 页面结构
|
||||
@@ -61,9 +61,9 @@
|
||||
|
||||
### 陶泥儿精选
|
||||
|
||||
`陶泥儿精选` 是页面底部的用户素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
|
||||
精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 mock。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。
|
||||
精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 的项目资源,代表用户通过图片画布生成并已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
Tab:
|
||||
|
||||
@@ -100,9 +100,10 @@ Tab:
|
||||
|
||||
数据来源:
|
||||
|
||||
- 图片类素材优先读取账号级素材库和已落库生成结果。
|
||||
- 未登录或账号素材为空时,读取已公开作品中的真实用户生成图片作为精选素材补充,但卡片仍按素材瀑布流呈现,不展示玩法入口卡。
|
||||
- 音乐、音效、视频类素材只展示已存在的真实记录。
|
||||
- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源。
|
||||
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
|
||||
- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
|
||||
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
|
||||
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
|
||||
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
|
||||
|
||||
@@ -113,7 +114,7 @@ Tab:
|
||||
- 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。
|
||||
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。
|
||||
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
|
||||
- `陶泥儿精选` 读取账号级素材库;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。
|
||||
- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。
|
||||
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
|
||||
|
||||
## 路由与导航
|
||||
@@ -128,6 +129,7 @@ Tab:
|
||||
- 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。
|
||||
- `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。
|
||||
- 移动端导航列表不展示“创作”和“项目”。
|
||||
- 移动端首次进入或刷新 `/` 平台首页时显示欢迎弹窗,文案提示移动端仅支持作品展示,体验创作工具请使用电脑端访问;点击 `好` 关闭后,本次页面生命周期内切换下方页签再回到首页不再弹出。
|
||||
- 移动端直接访问 `/creation` 时显示桌面端打开引导,不加载创作工具主页;`/creation/<play>` 继续直达对应玩法工作台。`/project` 暂不新增移动端拦截。
|
||||
|
||||
## 实施拆分
|
||||
@@ -194,11 +196,12 @@ git diff --check
|
||||
- 桌面导航显示“项目”,不显示“草稿”。
|
||||
- `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容。
|
||||
- 移动视口底部不显示“创作”和“项目”。
|
||||
- 移动端进入 `/` 首页时显示 `欢迎` 弹窗,正文包含移动端仅支持作品展示和电脑端访问提示,按钮为 `好`。
|
||||
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。
|
||||
- “我的”页没有项目快捷入口。
|
||||
- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
|
||||
- 陶泥儿精选不出现玩法入口卡;即使使用公开作品图片补充展示,也必须呈现为用户素材瀑布流。
|
||||
- 陶泥儿精选不出现玩法入口卡或公开作品图片补充展示。
|
||||
|
||||
## 非目标
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@
|
||||
|
||||
- 底部 AI 画布工具栏新增 `生成图标素材` 按钮。
|
||||
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
|
||||
- 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。
|
||||
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
|
||||
- 生成完成后删除占位态,先把后端返回的 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,再把拆分出的每个独立图标素材作为画布图片图层铺开,图集与图标之间、图标彼此之间不重叠,并保留少量间距。
|
||||
- 图标素材图层写入 `assetKind: "icon"`;图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -283,7 +283,10 @@ mod tests {
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{config::AppConfig, state::AppState};
|
||||
use crate::{
|
||||
config::{AppConfig, ExternalGenerationMode},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::{build_router, build_spacetime_unavailable_router};
|
||||
|
||||
@@ -1472,7 +1475,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_accepts_character_image_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body");
|
||||
let app = build_router(state);
|
||||
@@ -1554,7 +1561,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_ui_design_asset_extraction_accepts_image_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138188", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_body");
|
||||
let app = build_router(state);
|
||||
@@ -1599,7 +1610,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_image_generation_accepts_reference_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Inline,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body");
|
||||
let app = build_router(state);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
@@ -55,6 +55,15 @@ use crate::{
|
||||
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
|
||||
},
|
||||
editor_generation_config::EditorGenerationPricingConfig,
|
||||
editor_generation_queue::{
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
EditorGenerationQueuedResponse, editor_generation_queue_state,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
},
|
||||
editor_project::{
|
||||
EditorCanvasGeneratedLayerInput, build_editor_canvas_generated_layer_item,
|
||||
complete_editor_canvas_generation_with_items,
|
||||
},
|
||||
http_error::AppError,
|
||||
platform_errors::map_oss_error,
|
||||
prompt::role_asset_studio::{
|
||||
@@ -525,11 +534,44 @@ pub async fn generate_editor_character_animation(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
payload.source_layer_id.as_str(),
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成角色动作",
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
queue_state: editor_generation_queue_state(queue_job),
|
||||
},
|
||||
));
|
||||
}
|
||||
generate_editor_character_animation_for_owner(
|
||||
state,
|
||||
request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload,
|
||||
owner_user_id,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -549,6 +591,10 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let source_resource_id = payload.source_resource_id.clone();
|
||||
|
||||
let pricing = state.editor_generation_pricing().map_err(|error| {
|
||||
character_animation_error_response(
|
||||
@@ -613,6 +659,68 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
|
||||
let (generated, frames) =
|
||||
result.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
|
||||
let frame_payload_json = serde_json::to_value(&frames).map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": format!("角色动作序列帧无法写入画布:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let preview_frame = frames.first();
|
||||
let layer_id = format!("layer-editor-character-animation-{task_id}");
|
||||
let item = build_editor_canvas_generated_layer_item(
|
||||
completion,
|
||||
EditorCanvasGeneratedLayerInput {
|
||||
layer_id: layer_id.clone(),
|
||||
resource_id: format!("local-resource-editor-character-animation-{task_id}"),
|
||||
title: completion.title.trim().to_string(),
|
||||
src: preview_frame
|
||||
.map(|frame| frame.image_src.clone())
|
||||
.unwrap_or_else(|| generated.preview_video_path.clone()),
|
||||
media_type: Some("image-sequence".to_string()),
|
||||
thumbnail_src: preview_frame.map(|frame| frame.image_src.clone()),
|
||||
original_width: preview_frame
|
||||
.map(|frame| frame.width)
|
||||
.unwrap_or(normalized.frame_width),
|
||||
original_height: preview_frame
|
||||
.map(|frame| frame.height)
|
||||
.unwrap_or(normalized.frame_height),
|
||||
display_width: completion.placeholder.width,
|
||||
display_height: completion.placeholder.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(generated.submitted_prompt.clone()),
|
||||
actual_prompt: Some(generated.submitted_prompt.clone()),
|
||||
model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()),
|
||||
provider: Some("ark".to_string()),
|
||||
task_id: Some(task_id.clone()),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
duration_seconds: Some(normalized.duration_seconds),
|
||||
source_resource_id,
|
||||
asset_kind: Some("character-animation".to_string()),
|
||||
generation_inputs,
|
||||
image_sequence_frames: Some(frame_payload_json),
|
||||
preview_video_path: Some(generated.preview_video_path.clone()),
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_deref(),
|
||||
canvas_completion.as_ref(),
|
||||
vec![item],
|
||||
Some(layer_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let completed_project =
|
||||
completed_project.and_then(|project| serde_json::to_value(project).ok());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -629,6 +737,8 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
fps: normalized.fps,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
frames,
|
||||
project: completed_project,
|
||||
queue_state: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -639,13 +749,38 @@ pub async fn generate_editor_video(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
generate_editor_video_for_owner(
|
||||
state,
|
||||
request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-video",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let source_entity_id =
|
||||
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成视频",
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
queue_state: editor_generation_queue_state(queue_job),
|
||||
},
|
||||
));
|
||||
}
|
||||
generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_video_for_owner(
|
||||
@@ -663,6 +798,11 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let source_resource_id = payload.source_resource_id.clone();
|
||||
let asset_kind = payload.asset_kind.clone();
|
||||
|
||||
let pricing = state.editor_generation_pricing().map_err(|error| {
|
||||
editor_video_error_response(
|
||||
@@ -698,6 +838,52 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
|
||||
let layer_id = format!("layer-editor-video-{task_id}");
|
||||
let item = build_editor_canvas_generated_layer_item(
|
||||
completion,
|
||||
EditorCanvasGeneratedLayerInput {
|
||||
layer_id: layer_id.clone(),
|
||||
resource_id: format!("local-resource-editor-video-{task_id}"),
|
||||
title: completion.title.trim().to_string(),
|
||||
src: generated.preview_video_path.clone(),
|
||||
media_type: Some("video".to_string()),
|
||||
thumbnail_src: None,
|
||||
original_width: normalized.width,
|
||||
original_height: normalized.height,
|
||||
display_width: completion.placeholder.width,
|
||||
display_height: completion.placeholder.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(normalized.prompt.clone()),
|
||||
actual_prompt: Some(generated.submitted_prompt.clone()),
|
||||
model: Some(normalized.model.clone()),
|
||||
provider: Some("VectorEngine".to_string()),
|
||||
task_id: Some(task_id.clone()),
|
||||
object_key: generated.object_key.clone(),
|
||||
asset_object_id: generated.asset_object_id.clone(),
|
||||
duration_seconds: Some(normalized.duration_seconds),
|
||||
source_resource_id,
|
||||
asset_kind: Some(asset_kind.unwrap_or_else(|| "video".to_string())),
|
||||
generation_inputs,
|
||||
image_sequence_frames: None,
|
||||
preview_video_path: None,
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_deref(),
|
||||
canvas_completion.as_ref(),
|
||||
vec![item],
|
||||
Some(layer_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let completed_project =
|
||||
completed_project.and_then(|project| serde_json::to_value(project).ok());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -717,6 +903,8 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
duration_seconds: normalized.duration_seconds,
|
||||
resolution: normalized.resolution,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
project: completed_project,
|
||||
queue_state: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -4909,6 +5097,10 @@ mod tests {
|
||||
frame_count: 48,
|
||||
duration_seconds: 6,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
})
|
||||
.expect("editor request should normalize");
|
||||
|
||||
@@ -4943,6 +5135,10 @@ mod tests {
|
||||
frame_count: 32,
|
||||
duration_seconds: 4,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
})
|
||||
.expect("editor request should normalize");
|
||||
|
||||
@@ -4966,6 +5162,10 @@ mod tests {
|
||||
frame_count: 48,
|
||||
duration_seconds: 4,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
})
|
||||
.expect_err("invalid frame/duration pair should fail");
|
||||
|
||||
@@ -5121,6 +5321,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("editor video request should normalize");
|
||||
|
||||
@@ -5146,6 +5351,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance fast request should normalize");
|
||||
|
||||
@@ -5168,6 +5378,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance standard request should normalize");
|
||||
|
||||
@@ -5190,6 +5405,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance standard 1080p request should normalize");
|
||||
|
||||
@@ -5213,6 +5433,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect_err("seedance fast should reject 1080p");
|
||||
|
||||
@@ -5235,6 +5460,11 @@ mod tests {
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance multimodal request should normalize");
|
||||
|
||||
@@ -5266,6 +5496,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: vec!["data:video/mp4;base64,video".to_string()],
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect_err("Seedance reference videos must not accept Base64 data URLs");
|
||||
|
||||
@@ -5287,6 +5522,11 @@ mod tests {
|
||||
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
|
||||
reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance multimodal request should normalize");
|
||||
|
||||
@@ -5328,6 +5568,11 @@ mod tests {
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("seedance objectKey request should normalize");
|
||||
|
||||
@@ -5363,6 +5608,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: vec!["data:audio/wav;base64,audio".to_string()],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect_err("audio-only references should fail");
|
||||
|
||||
@@ -5404,7 +5654,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
|
||||
let start_index = source
|
||||
.find(start)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use axum::http::StatusCode;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros};
|
||||
use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord};
|
||||
|
||||
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
|
||||
|
||||
pub(crate) const EDITOR_IMAGE_GENERATION_JOB_KIND: &str = "editor_image_generation";
|
||||
pub(crate) const EDITOR_IMAGE_EDIT_JOB_KIND: &str = "editor_image_edit";
|
||||
pub(crate) const EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND: &str =
|
||||
"editor_icon_spritesheet_generation";
|
||||
pub(crate) const EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND: &str =
|
||||
"editor_ui_design_asset_extraction";
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND: &str =
|
||||
"editor_character_animation_generation";
|
||||
pub(crate) const EDITOR_VIDEO_GENERATION_JOB_KIND: &str = "editor_video_generation";
|
||||
pub(crate) const EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND: &str = "editor_sound_effect_generation";
|
||||
pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str =
|
||||
"editor_background_music_generation";
|
||||
|
||||
pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
|
||||
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EditorGenerationQueuedResponse {
|
||||
pub(crate) queue_state: ExternalGenerationJobStatusRecord,
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_editor_generation_job<T>(
|
||||
state: &AppState,
|
||||
_request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
source_entity_id: impl Into<String>,
|
||||
request_label: impl Into<String>,
|
||||
payload: &T,
|
||||
) -> Result<ExternalGenerationJobRecord, AppError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let job_id = build_prefixed_uuid_id("extgen-");
|
||||
let request_payload_json = serde_json::to_string(payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
|
||||
"message": format!("编辑器 worker 任务参数序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
|
||||
dedupe_key: format!("editor-canvas:{job_kind}:{job_id}"),
|
||||
job_id,
|
||||
job_kind: job_kind.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(),
|
||||
source_entity_id: source_entity_id.into(),
|
||||
request_label: request_label.into(),
|
||||
request_payload_json,
|
||||
max_attempts: 1,
|
||||
available_at_micros: now_micros,
|
||||
created_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn editor_generation_queue_state(
|
||||
job: ExternalGenerationJobRecord,
|
||||
) -> ExternalGenerationJobStatusRecord {
|
||||
ExternalGenerationJobStatusRecord {
|
||||
operation_id: job.job_id,
|
||||
status: ExternalGenerationJobStatus::Queued,
|
||||
phase_label: job.request_label,
|
||||
phase_detail: "排队中。".to_string(),
|
||||
progress: 8,
|
||||
error: job.last_error_message,
|
||||
updated_at_micros: job.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editor_generation_source_entity_id(
|
||||
project_id: Option<&str>,
|
||||
fallback: &str,
|
||||
) -> String {
|
||||
project_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use std::{future::Future, io, pin::Pin, time::Duration};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use axum::{Json, extract::FromRef};
|
||||
use serde_json::json;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use spacetime_client::{
|
||||
@@ -15,6 +15,22 @@ use tokio::{
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
character_animation_assets::{
|
||||
generate_editor_character_animation_for_owner, generate_editor_video_for_owner,
|
||||
},
|
||||
editor_generation_queue::{
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND,
|
||||
EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
},
|
||||
editor_project::{
|
||||
EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
|
||||
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
|
||||
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
|
||||
},
|
||||
jump_hop::{
|
||||
JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload,
|
||||
execute_jump_hop_compile_draft_worker_job,
|
||||
@@ -31,6 +47,9 @@ use crate::{
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
vector_engine_audio_generation::{
|
||||
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
|
||||
},
|
||||
wooden_fish::{
|
||||
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload,
|
||||
execute_wooden_fish_generate_image_assets_worker_job,
|
||||
@@ -536,6 +555,234 @@ async fn process_external_generation_job_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_IMAGE_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<EditorImageGenerationRequest>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布生图任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_image_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
editor_generation_worker_caller(&job),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_IMAGE_EDIT_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<EditorImageEditRequest>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布改图任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match edit_editor_image_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
editor_generation_worker_caller(&job),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<EditorIconSpritesheetGenerationRequest>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布图标素材任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_icon_spritesheet_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
editor_generation_worker_caller(&job),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<EditorUiDesignAssetExtractionRequest>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布 UI 素材提取任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match extract_editor_ui_design_assets_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
editor_generation_worker_caller(&job),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorCharacterAnimationGenerateRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布角色动作任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_character_animation_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_VIDEO_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorVideoGenerateRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布视频生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_video_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorSoundEffectGenerateRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布音效生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_sound_effect_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<
|
||||
shared_contracts::assets::EditorBackgroundMusicGenerateRequest,
|
||||
>(job.request_payload_json.as_str())
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("图片画布背景音乐生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = worker_request_context(&job);
|
||||
match generate_editor_background_music_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
unknown => {
|
||||
warn!(
|
||||
job_id = job.job_id,
|
||||
@@ -598,6 +845,43 @@ async fn fail_queue_job_after_worker_error(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_request_context(job: &ExternalGenerationJobRecord) -> RequestContext {
|
||||
RequestContext::new(
|
||||
format!("external-generation-worker-{}", job.job_id),
|
||||
format!("external-generation-worker {}", job.job_kind),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn editor_generation_worker_caller(job: &ExternalGenerationJobRecord) -> EditorGenerationCaller {
|
||||
EditorGenerationCaller {
|
||||
owner_user_id: job.owner_user_id.clone(),
|
||||
audit_subject_user_id: Some(job.owner_user_id.clone()),
|
||||
audit_project_id: Some(job.source_entity_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete_editor_generation_job(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
) -> Result<(), String> {
|
||||
complete_job(
|
||||
state,
|
||||
worker_id,
|
||||
job,
|
||||
Some(
|
||||
json!({
|
||||
"sourceModule": job.source_module.clone(),
|
||||
"sourceEntityId": job.source_entity_id.clone(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn complete_job(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
|
||||
@@ -37,6 +37,7 @@ mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod editor_generation_config;
|
||||
mod editor_generation_queue;
|
||||
mod editor_project;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
|
||||
@@ -11,9 +11,21 @@ use serde_json::json;
|
||||
use shared_contracts::{assets, creation_audio};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||
editor_generation_config::EditorGenerationPricingConfig, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
editor_generation_config::EditorGenerationPricingConfig,
|
||||
editor_generation_queue::{
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
EditorGenerationQueuedResponse, editor_generation_queue_state,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
},
|
||||
editor_project::{
|
||||
EditorCanvasGeneratedLayerInput, build_editor_canvas_generated_layer_item,
|
||||
complete_editor_canvas_generation_with_items,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
|
||||
@@ -128,13 +140,33 @@ pub async fn generate_editor_sound_effect(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
generate_editor_sound_effect_for_owner(
|
||||
state,
|
||||
request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
"editor-sound-effect",
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成音效",
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
queue_state: editor_generation_queue_state(queue_job),
|
||||
},
|
||||
));
|
||||
}
|
||||
generate_editor_sound_effect_for_owner(state, request_context, owner_user_id, Ok(Json(payload)))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
@@ -144,6 +176,9 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let pricing = state
|
||||
.editor_generation_pricing()
|
||||
.map_err(|error| {
|
||||
@@ -192,6 +227,52 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
|
||||
let layer_id = format!("layer-editor-sound-effect-{}", generated.task_id);
|
||||
let item = build_editor_canvas_generated_layer_item(
|
||||
completion,
|
||||
EditorCanvasGeneratedLayerInput {
|
||||
layer_id: layer_id.clone(),
|
||||
resource_id: format!("local-resource-editor-sound-effect-{}", generated.task_id),
|
||||
title: completion.title.trim().to_string(),
|
||||
src: audio_src.clone(),
|
||||
media_type: Some("audio".to_string()),
|
||||
thumbnail_src: None,
|
||||
original_width: EDITOR_AUDIO_WIDTH,
|
||||
original_height: EDITOR_AUDIO_HEIGHT,
|
||||
display_width: completion.placeholder.width,
|
||||
display_height: completion.placeholder.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(normalized.prompt.clone()),
|
||||
actual_prompt: Some(normalized.prompt.clone()),
|
||||
model: Some(normalized.model.clone()),
|
||||
provider: Some(generated.provider.clone()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
object_key: generated.object_key.clone(),
|
||||
asset_object_id: generated.asset_object_id.clone(),
|
||||
duration_seconds: Some(u32::from(normalized.duration)),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("sound-effect".to_string()),
|
||||
generation_inputs,
|
||||
image_sequence_frames: None,
|
||||
preview_video_path: None,
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_deref(),
|
||||
canvas_completion.as_ref(),
|
||||
vec![item],
|
||||
Some(layer_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let completed_project =
|
||||
completed_project.and_then(|project| serde_json::to_value(project).ok());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -210,6 +291,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
audio_kind: "sound-effect".to_string(),
|
||||
project: completed_project,
|
||||
queue_state: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -220,11 +303,36 @@ pub async fn generate_editor_background_music(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
"editor-background-music",
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成背景音乐",
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
queue_state: editor_generation_queue_state(queue_job),
|
||||
},
|
||||
));
|
||||
}
|
||||
generate_editor_background_music_for_owner(
|
||||
state,
|
||||
request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload,
|
||||
owner_user_id,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -236,6 +344,9 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let pricing = state
|
||||
.editor_generation_pricing()
|
||||
.map_err(|error| {
|
||||
@@ -282,6 +393,55 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
|
||||
let layer_id = format!("layer-editor-background-music-{}", generated.task_id);
|
||||
let item = build_editor_canvas_generated_layer_item(
|
||||
completion,
|
||||
EditorCanvasGeneratedLayerInput {
|
||||
layer_id: layer_id.clone(),
|
||||
resource_id: format!(
|
||||
"local-resource-editor-background-music-{}",
|
||||
generated.task_id
|
||||
),
|
||||
title: completion.title.trim().to_string(),
|
||||
src: audio_src.clone(),
|
||||
media_type: Some("audio".to_string()),
|
||||
thumbnail_src: None,
|
||||
original_width: EDITOR_AUDIO_WIDTH,
|
||||
original_height: EDITOR_AUDIO_HEIGHT,
|
||||
display_width: completion.placeholder.width,
|
||||
display_height: completion.placeholder.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(normalized.gpt_description_prompt.clone()),
|
||||
actual_prompt: Some(normalized.gpt_description_prompt.clone()),
|
||||
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
|
||||
provider: Some(generated.provider.clone()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
object_key: generated.object_key.clone(),
|
||||
asset_object_id: generated.asset_object_id.clone(),
|
||||
duration_seconds: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("background-music".to_string()),
|
||||
generation_inputs,
|
||||
image_sequence_frames: None,
|
||||
preview_video_path: None,
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_deref(),
|
||||
canvas_completion.as_ref(),
|
||||
vec![item],
|
||||
Some(layer_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let completed_project =
|
||||
completed_project.and_then(|project| serde_json::to_value(project).ok());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -300,6 +460,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
audio_kind: "background-music".to_string(),
|
||||
project: completed_project,
|
||||
queue_state: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() {
|
||||
prompt: " 金币掉落叮当声 ".to_string(),
|
||||
model: None,
|
||||
duration: 7,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("editor sound effect request should normalize");
|
||||
|
||||
@@ -125,6 +128,9 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
|
||||
prompt: "金币掉落叮当声".to_string(),
|
||||
model: Some(" audio1.0 ".to_string()),
|
||||
duration: 5,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("Vidu audio model should be accepted");
|
||||
|
||||
@@ -134,6 +140,9 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
|
||||
prompt: "金币掉落叮当声".to_string(),
|
||||
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
|
||||
duration: 5,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
|
||||
|
||||
@@ -148,6 +157,9 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
|
||||
prompt: "按钮点击".to_string(),
|
||||
model: None,
|
||||
duration,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect_err("duration outside 2-10 seconds should fail");
|
||||
|
||||
@@ -183,6 +195,9 @@ fn editor_background_music_request_forces_instrumental_and_resolves_price() {
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: " 森林冒险背景音乐 ".to_string(),
|
||||
make_instrumental: false,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("editor background music request should normalize");
|
||||
|
||||
@@ -197,6 +212,9 @@ fn editor_background_music_request_rejects_prompt_over_documented_limit() {
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: "乐".repeat(201),
|
||||
make_instrumental: true,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit");
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::external_generation::ExternalGenerationJobStatusRecord;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketRequest {
|
||||
@@ -304,7 +306,7 @@ pub struct CharacterAnimationGenerateResponse {
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub source_layer_id: String,
|
||||
@@ -317,6 +319,14 @@ pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub frame_count: u32,
|
||||
pub duration_seconds: u32,
|
||||
pub model: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_resource_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -328,7 +338,7 @@ pub struct EditorCharacterAnimationFramePayload {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
@@ -343,9 +353,13 @@ pub struct EditorCharacterAnimationGenerateResponse {
|
||||
pub frame_height: u32,
|
||||
pub fps: u32,
|
||||
pub price_mud_points: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorVideoGenerateRequest {
|
||||
pub prompt: String,
|
||||
@@ -363,13 +377,23 @@ pub struct EditorVideoGenerateRequest {
|
||||
pub reference_video_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_audio_srcs: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_resource_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_kind: Option<String>,
|
||||
}
|
||||
|
||||
fn default_editor_video_web_search_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorVideoGenerateResponse {
|
||||
pub ok: bool,
|
||||
@@ -390,6 +414,30 @@ pub struct EditorVideoGenerateResponse {
|
||||
pub duration_seconds: u32,
|
||||
pub resolution: String,
|
||||
pub price_mud_points: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCanvasGenerationPlaceholderPayload {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub original_width: f64,
|
||||
pub original_height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCanvasGenerationCompletionPayload {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dialog_id: Option<String>,
|
||||
pub title: String,
|
||||
pub placeholder: EditorCanvasGenerationPlaceholderPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -430,23 +478,35 @@ pub struct EditorIconSpritesheetGenerateResponse {
|
||||
pub price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorSoundEffectGenerateRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
pub duration: u8,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorBackgroundMusicGenerateRequest {
|
||||
pub gpt_description_prompt: String,
|
||||
pub make_instrumental: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAudioGenerateResponse {
|
||||
pub ok: bool,
|
||||
@@ -466,6 +526,10 @@ pub struct EditorAudioGenerateResponse {
|
||||
pub task_id: String,
|
||||
pub price_mud_points: u32,
|
||||
pub audio_kind: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -992,6 +1056,21 @@ mod tests {
|
||||
frame_count: 48,
|
||||
duration_seconds: 6,
|
||||
model: "seedance2.0-fast".to_string(),
|
||||
project_id: Some("editor-project-1".to_string()),
|
||||
canvas_completion: Some(EditorCanvasGenerationCompletionPayload {
|
||||
dialog_id: Some("dialog-1".to_string()),
|
||||
title: "角色动作".to_string(),
|
||||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 420.0,
|
||||
height: 420.0,
|
||||
original_width: 1024.0,
|
||||
original_height: 1024.0,
|
||||
},
|
||||
}),
|
||||
generation_inputs: None,
|
||||
source_resource_id: Some("resource-source".to_string()),
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
@@ -1002,6 +1081,9 @@ mod tests {
|
||||
);
|
||||
assert!(payload.get("priceMudPoints").is_none());
|
||||
assert_eq!(payload["model"], json!("seedance2.0-fast"));
|
||||
assert_eq!(payload["projectId"], json!("editor-project-1"));
|
||||
assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1"));
|
||||
assert_eq!(payload["sourceResourceId"], json!("resource-source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1024,6 +1106,8 @@ mod tests {
|
||||
frame_height: 1024,
|
||||
fps: 8,
|
||||
price_mud_points: 40,
|
||||
project: None,
|
||||
queue_state: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
@@ -1052,6 +1136,11 @@ mod tests {
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_video_srcs: Vec::new(),
|
||||
reference_audio_srcs: Vec::new(),
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
@@ -1078,6 +1167,11 @@ mod tests {
|
||||
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
|
||||
],
|
||||
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: None,
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
@@ -1100,7 +1194,9 @@ mod tests {
|
||||
let payload = serde_json::to_value(EditorVideoGenerateResponse {
|
||||
ok: true,
|
||||
video_src: "/generated-editor-videos/task-1/preview.mp4".to_string(),
|
||||
object_key: Some("generated-character-drafts/editor-videos/task-1/preview.mp4".to_string()),
|
||||
object_key: Some(
|
||||
"generated-character-drafts/editor-videos/task-1/preview.mp4".to_string(),
|
||||
),
|
||||
asset_object_id: Some("assetobj_video_1".to_string()),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
@@ -1113,6 +1209,8 @@ mod tests {
|
||||
duration_seconds: 5,
|
||||
resolution: "480p".to_string(),
|
||||
price_mud_points: 100,
|
||||
project: None,
|
||||
queue_state: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
@@ -1178,6 +1276,9 @@ mod tests {
|
||||
prompt: "金币掉落叮当声".to_string(),
|
||||
model: Some("audio1.0".to_string()),
|
||||
duration: 7,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("sound request should serialize");
|
||||
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
|
||||
@@ -1198,6 +1299,9 @@ mod tests {
|
||||
prompt: "按钮确认短促音".to_string(),
|
||||
model: None,
|
||||
duration: 5,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("sound request with unset model should serialize");
|
||||
assert!(unset_model_payload.get("model").is_none());
|
||||
@@ -1206,6 +1310,9 @@ mod tests {
|
||||
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: "森林冒险背景音乐".to_string(),
|
||||
make_instrumental: true,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
})
|
||||
.expect("background music request should serialize");
|
||||
assert_eq!(
|
||||
@@ -1229,6 +1336,8 @@ mod tests {
|
||||
task_id: "sound-task-1".to_string(),
|
||||
price_mud_points: 10,
|
||||
audio_kind: "sound-effect".to_string(),
|
||||
project: None,
|
||||
queue_state: None,
|
||||
})
|
||||
.expect("audio response should serialize");
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,12 +6,10 @@ import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
@@ -48,7 +46,6 @@ function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
@@ -72,12 +69,10 @@ const projectItems = [
|
||||
|
||||
type RenderCreationLandingOptions = {
|
||||
authValue?: AuthValue | null;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
function renderCreationLanding({
|
||||
authValue = createAuthValue(),
|
||||
publicGalleryEntries = [],
|
||||
}: RenderCreationLandingOptions = {}) {
|
||||
const result = render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
@@ -85,7 +80,6 @@ function renderCreationLanding({
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={vi.fn()}
|
||||
publicGalleryEntries={publicGalleryEntries}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
@@ -97,12 +91,10 @@ describe('CreationLandingView', () => {
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
loadEditorAssetLibraryMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders the Taonier creation landing modules without external branding', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -129,7 +121,6 @@ describe('CreationLandingView', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
@@ -173,7 +164,6 @@ describe('CreationLandingView', () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
createEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'created-project',
|
||||
title: '未命名画布',
|
||||
@@ -203,7 +193,6 @@ describe('CreationLandingView', () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCommunity = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
@@ -221,30 +210,32 @@ describe('CreationLandingView', () => {
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders real account assets in showcase tabs without mock rows', async () => {
|
||||
it('renders generated project resources in showcase tabs without mock rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -260,56 +251,57 @@ describe('CreationLandingView', () => {
|
||||
|
||||
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-animation',
|
||||
folderId: 'folder-1',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'asset',
|
||||
refId: 'asset-character',
|
||||
},
|
||||
],
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
resourceId: 'resource-animation',
|
||||
projectId: 'project-newest',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-character',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
@@ -332,46 +324,67 @@ describe('CreationLandingView', () => {
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-pack-a',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-pack-b',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-a',
|
||||
projectId: 'project-newest',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-uploaded',
|
||||
projectId: 'project-newest',
|
||||
label: '上传素材',
|
||||
imageSrc: 'data:image/png;base64,uploaded',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'uploaded',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...projectItems[1]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-b',
|
||||
projectId: 'project-old',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = renderCreationLanding();
|
||||
|
||||
expect(await screen.findByText('素材包 A')).toBeTruthy();
|
||||
expect(screen.getByText('素材包 B')).toBeTruthy();
|
||||
expect(screen.queryByText('上传素材')).toBeNull();
|
||||
expect(
|
||||
screen.getByLabelText('用户素材瀑布流').className,
|
||||
).toContain('creation-landing__asset-waterfall');
|
||||
@@ -383,52 +396,16 @@ describe('CreationLandingView', () => {
|
||||
expect(screen.queryByText('拼图关卡创作')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to real public user assets for the featured waterfall', async () => {
|
||||
const publicAsset = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'work-public-asset',
|
||||
profileId: 'profile-public-asset',
|
||||
publicWorkCode: 'PZ-PUBLIC-ASSET',
|
||||
ownerUserId: 'owner-public-asset',
|
||||
authorDisplayName: '公开作者',
|
||||
worldName: '公开素材组合',
|
||||
subtitle: '公开素材副标题',
|
||||
summaryText: '公开用户生成素材提示词',
|
||||
coverImageSrc: 'https://assets.example.test/public-asset.png',
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'slide-1',
|
||||
imageSrc: 'https://assets.example.test/public-asset-slide.png',
|
||||
label: '公开素材图',
|
||||
},
|
||||
],
|
||||
themeTags: ['素材包'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-18T09:00:00.000Z',
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { container } = renderCreationLanding({
|
||||
it('keeps the featured waterfall empty when project resources are empty', async () => {
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
}),
|
||||
publicGalleryEntries: [publicAsset],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('公开素材组合')).toBeTruthy();
|
||||
const card = screen
|
||||
.getByText('公开素材组合')
|
||||
.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText('公开用户生成素材提示词'),
|
||||
).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('公开作者')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.creation-landing__asset-waterfall'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('暂无素材')).toBeNull();
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createEditorProject,
|
||||
listEditorProjects,
|
||||
loadEditorAssetLibrary,
|
||||
type EditorAssetLibrarySnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -20,7 +19,6 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ProjectCanvasCover } from '../project/ProjectCanvasCover';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { ResolvedAssetVideo } from '../ResolvedAssetVideo';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildCreationShowcaseItems,
|
||||
SHOWCASE_TABS,
|
||||
@@ -33,7 +31,6 @@ type CreationLandingViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
onOpenProjects: () => void;
|
||||
onOpenCommunity?: () => void;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
type CreationFeatureItem = {
|
||||
@@ -360,7 +357,6 @@ export function CreationLandingView({
|
||||
onOpenProject,
|
||||
onOpenProjects,
|
||||
onOpenCommunity,
|
||||
publicGalleryEntries = [],
|
||||
}: CreationLandingViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
@@ -370,9 +366,9 @@ export function CreationLandingView({
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [projectError, setProjectError] = useState<string | null>(null);
|
||||
const [assetLibrary, setAssetLibrary] =
|
||||
useState<EditorAssetLibrarySnapshot | null>(null);
|
||||
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
EditorProjectResourceSnapshot[]
|
||||
>([]);
|
||||
const [assetError, setAssetError] = useState<string | null>(null);
|
||||
const [activeShowcaseTab, setActiveShowcaseTab] =
|
||||
useState<ShowcaseTabId>('packs');
|
||||
@@ -383,21 +379,24 @@ export function CreationLandingView({
|
||||
const refreshRecentProjects = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setRecentProjects([]);
|
||||
setProjectShowcaseResources([]);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoadingProjects(true);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
listEditorProjects()
|
||||
.then((projects) => {
|
||||
setRecentProjects(
|
||||
[...projects]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() -
|
||||
new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 4),
|
||||
const sortedProjects = [...projects].sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() -
|
||||
new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
setRecentProjects(sortedProjects.slice(0, 4));
|
||||
setProjectShowcaseResources(
|
||||
sortedProjects.flatMap((project) => project.resources ?? []),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -405,42 +404,20 @@ export function CreationLandingView({
|
||||
authUi?.openLoginModal(refreshRecentProjects);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取最近项目失败';
|
||||
setProjectError(
|
||||
error instanceof Error ? error.message : '读取最近项目失败',
|
||||
message,
|
||||
);
|
||||
setAssetError(message);
|
||||
})
|
||||
.finally(() => setIsLoadingProjects(false));
|
||||
}, [authUi, isAuthenticated]);
|
||||
|
||||
const refreshAssetLibrary = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setAssetLibrary(null);
|
||||
setAssetError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoadingAssets(true);
|
||||
setAssetError(null);
|
||||
loadEditorAssetLibrary()
|
||||
.then(setAssetLibrary)
|
||||
.catch((error: unknown) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
return;
|
||||
}
|
||||
setAssetError(
|
||||
error instanceof Error ? error.message : '读取素材展示失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingAssets(false));
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRecentProjects();
|
||||
}, [refreshRecentProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAssetLibrary();
|
||||
}, [refreshAssetLibrary]);
|
||||
|
||||
const createProject = useCallback(() => {
|
||||
if (authUi && !authUi.user) {
|
||||
authUi.openLoginModal(createProject);
|
||||
@@ -466,16 +443,14 @@ export function CreationLandingView({
|
||||
() =>
|
||||
buildCreationShowcaseItems({
|
||||
activeTab: activeShowcaseTab,
|
||||
assetLibrary,
|
||||
publicGalleryEntries,
|
||||
projectResources: projectShowcaseResources,
|
||||
fallbackAuthorName: authUi?.user?.displayName,
|
||||
}),
|
||||
[activeShowcaseTab, assetLibrary, authUi?.user?.displayName, publicGalleryEntries],
|
||||
[activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName],
|
||||
);
|
||||
|
||||
const isShowcaseLoading = isAuthenticated && isLoadingAssets;
|
||||
const showcaseEmptyText =
|
||||
publicGalleryEntries.length > 0 ? '暂无匹配素材' : '暂无素材';
|
||||
const isShowcaseLoading = isAuthenticated && isLoadingProjects;
|
||||
const showcaseEmptyText = '暂无素材';
|
||||
|
||||
const renderAssetShowcase = () => {
|
||||
if (assetError) {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import { buildCreationShowcaseItems } from './creationShowcaseModel';
|
||||
|
||||
function createAsset(
|
||||
overrides: Partial<EditorAssetSnapshot> & Record<string, unknown>,
|
||||
): EditorAssetSnapshot {
|
||||
function createResource(
|
||||
overrides: Partial<EditorProjectResourceSnapshot> & Record<string, unknown>,
|
||||
): EditorProjectResourceSnapshot {
|
||||
return {
|
||||
assetId: 'asset-1',
|
||||
folderId: 'folder-1',
|
||||
resourceId: 'resource-1',
|
||||
projectId: 'project-1',
|
||||
label: '素材',
|
||||
imageSrc: 'data:image/png;base64,asset',
|
||||
width: 512,
|
||||
@@ -23,31 +20,53 @@ function createAsset(
|
||||
provider: null,
|
||||
taskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot {
|
||||
return {
|
||||
folders: [],
|
||||
assets,
|
||||
};
|
||||
} as EditorProjectResourceSnapshot;
|
||||
}
|
||||
|
||||
describe('creationShowcaseModel', () => {
|
||||
it('only builds showcase items from generated project resources', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'generated-1',
|
||||
label: '画布生成素材',
|
||||
sourceType: 'generated',
|
||||
prompt: '真实画布生成',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'uploaded-1',
|
||||
label: '上传素材',
|
||||
sourceType: 'uploaded',
|
||||
prompt: '上传不应展示',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'mock-1',
|
||||
label: 'Mock 素材',
|
||||
sourceType: 'mock_generated',
|
||||
prompt: 'Mock 不应展示',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('画布生成素材');
|
||||
});
|
||||
|
||||
it('groups pack items by the same referenced spec image and sums known cost', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'spec-1',
|
||||
label: '角色规范图',
|
||||
assetKind: 'spec',
|
||||
prompt: '规范提示词',
|
||||
priceMudPoints: 5,
|
||||
authorName: '创作者A',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '角色主图',
|
||||
assetKind: 'character',
|
||||
priceMudPoints: 12,
|
||||
@@ -57,14 +76,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'spec-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -81,15 +99,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups assets when references point to the source project resource', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'spec-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'spec-1',
|
||||
label: '项目规范图',
|
||||
assetKind: 'spec',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '项目角色图',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
@@ -104,8 +122,7 @@ describe('creationShowcaseModel', () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -118,15 +135,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups character animations under the referenced character image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
assetKind: 'character',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
@@ -136,14 +153,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -157,15 +173,15 @@ describe('creationShowcaseModel', () => {
|
||||
it('groups UI design extraction assets under the referenced UI image', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
prompt: 'UI提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
createResource({
|
||||
resourceId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs: {
|
||||
@@ -174,14 +190,14 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
createResource({
|
||||
resourceId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
@@ -190,14 +206,13 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -212,16 +227,16 @@ describe('creationShowcaseModel', () => {
|
||||
it('counts a spritesheet task only once when UI children share the same task', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'ui',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'ui-design-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'ui-design-1',
|
||||
label: '游戏UI图',
|
||||
assetKind: 'ui-design',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
taskId: 'task-ui-design',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-sheet-1',
|
||||
createResource({
|
||||
resourceId: 'icon-sheet-1',
|
||||
label: 'UI子素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
model: 'gpt-image-2',
|
||||
@@ -232,14 +247,14 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'icon-1',
|
||||
createResource({
|
||||
resourceId: 'icon-1',
|
||||
label: '开始按钮',
|
||||
assetKind: 'icon',
|
||||
model: 'gpt-image-2',
|
||||
@@ -250,34 +265,32 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: 'UI设计图',
|
||||
label: '游戏UI图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'ui-design-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('32泥点');
|
||||
expect(items[0]?.cost).toBe('15泥点');
|
||||
});
|
||||
|
||||
it('keeps music assets as audio preview items', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'music',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'music-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'music-1',
|
||||
label: '背景音乐',
|
||||
imageSrc: '/generated-editor-audios/music.mp3',
|
||||
assetKind: 'background-music',
|
||||
prompt: '舒缓背景音乐',
|
||||
priceMudPoints: '5',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
fallbackAuthorName: '当前用户',
|
||||
});
|
||||
|
||||
@@ -290,21 +303,20 @@ describe('creationShowcaseModel', () => {
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '角色动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'poster-1',
|
||||
createResource({
|
||||
resourceId: 'poster-1',
|
||||
label: '上线美宣视频',
|
||||
assetKind: 'publication-material',
|
||||
imageSrc: '/generated-editor-videos/poster.mp4',
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -315,9 +327,9 @@ describe('creationShowcaseModel', () => {
|
||||
it('infers costs from generated asset kinds when stored price is absent', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
assetLibrary: createLibrary([
|
||||
createAsset({
|
||||
assetId: 'character-1',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
@@ -325,8 +337,8 @@ describe('creationShowcaseModel', () => {
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createAsset({
|
||||
assetId: 'animation-1',
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
@@ -339,18 +351,17 @@ describe('creationShowcaseModel', () => {
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'asset',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
publicGalleryEntries: [],
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('140泥点');
|
||||
expect(items[0]?.cost).toBe('123泥点');
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
EditorAssetGenerationInputReference,
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
calculateCharacterAnimationPrice,
|
||||
@@ -15,12 +15,6 @@ import {
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
} from '../image-editor/ImageCanvasGenerationModel';
|
||||
import {
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export type ShowcaseTabId =
|
||||
| 'packs'
|
||||
@@ -91,6 +85,10 @@ function assetRecord(asset: EditorAssetSnapshot) {
|
||||
return asset as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function projectResourceRecord(resource: EditorProjectResourceSnapshot) {
|
||||
return resource as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
@@ -162,6 +160,9 @@ function getSelfAssetKey(asset: EditorAssetSnapshot) {
|
||||
|
||||
function getAssetReferenceKeys(asset: EditorAssetSnapshot) {
|
||||
const keys = [getSelfAssetKey(asset)];
|
||||
if (asset.assetId?.trim()) {
|
||||
keys.push(`project-resource:${asset.assetId.trim()}`);
|
||||
}
|
||||
if (asset.sourceResourceId?.trim()) {
|
||||
keys.push(`project-resource:${asset.sourceResourceId.trim()}`);
|
||||
}
|
||||
@@ -174,6 +175,61 @@ function getAssetReferenceKeys(asset: EditorAssetSnapshot) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function projectResourceToShowcaseAsset(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
): EditorAssetSnapshot {
|
||||
const record = projectResourceRecord(resource);
|
||||
const fallbackLabel =
|
||||
normalizeText(record.label) ||
|
||||
normalizeText(record.title) ||
|
||||
resource.assetKind?.trim() ||
|
||||
'项目素材';
|
||||
|
||||
return {
|
||||
...record,
|
||||
assetId: resource.resourceId,
|
||||
folderId: `project:${resource.projectId}`,
|
||||
label: fallbackLabel,
|
||||
imageSrc: resource.imageSrc,
|
||||
objectKey: resource.objectKey,
|
||||
assetObjectId: resource.assetObjectId,
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
sourceType: resource.sourceType,
|
||||
prompt: resource.prompt ?? null,
|
||||
actualPrompt: resource.actualPrompt ?? null,
|
||||
model: resource.model ?? null,
|
||||
provider: resource.provider ?? null,
|
||||
taskId: resource.taskId ?? null,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
sourceResourceId: resource.sourceResourceId,
|
||||
assetKind: resource.assetKind,
|
||||
generationInputs: resource.generationInputs,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
} as EditorAssetSnapshot;
|
||||
}
|
||||
|
||||
function buildGeneratedProjectResourceAssets(
|
||||
resources: EditorProjectResourceSnapshot[],
|
||||
) {
|
||||
const seenResourceIds = new Set<string>();
|
||||
|
||||
return resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.sourceType === 'generated' && resource.imageSrc.trim(),
|
||||
)
|
||||
.filter((resource) => {
|
||||
if (seenResourceIds.has(resource.resourceId)) {
|
||||
return false;
|
||||
}
|
||||
seenResourceIds.add(resource.resourceId);
|
||||
return true;
|
||||
})
|
||||
.map(projectResourceToShowcaseAsset);
|
||||
}
|
||||
|
||||
function findReference(
|
||||
asset: EditorAssetSnapshot,
|
||||
matcher: (reference: EditorAssetGenerationInputReference) => boolean,
|
||||
@@ -641,114 +697,21 @@ function toLibraryShowcaseItem(
|
||||
};
|
||||
}
|
||||
|
||||
function matchesPublicGalleryTab(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
tab: ShowcaseTabId,
|
||||
) {
|
||||
if (tab === 'packs') {
|
||||
return true;
|
||||
}
|
||||
if (tab === 'games') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const themeTags = 'themeTags' in entry ? entry.themeTags : [];
|
||||
const haystack = [
|
||||
entry.worldName,
|
||||
entry.subtitle,
|
||||
entry.summaryText,
|
||||
entry.sourceType,
|
||||
...themeTags,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (tab === 'characters') {
|
||||
return /角色|character|rpg|visual/u.test(haystack);
|
||||
}
|
||||
if (tab === 'ui') {
|
||||
return /\bui\b|界面|图标|icon|puzzle/u.test(haystack);
|
||||
}
|
||||
if (tab === 'music') {
|
||||
return /音乐|音效|audio|music|sound/u.test(haystack);
|
||||
}
|
||||
if (tab === 'marketing') {
|
||||
return /美宣|宣传|poster|video|视觉|visual/u.test(haystack);
|
||||
}
|
||||
if (tab === 'effects') {
|
||||
return /特效|effect|vfx/u.test(haystack);
|
||||
}
|
||||
if (tab === 'scenes') {
|
||||
return /场景|scene|background|world|rpg/u.test(haystack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toPublicGalleryShowcaseItems(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
tab: ShowcaseTabId,
|
||||
): ShowcaseAssetItem[] {
|
||||
const usedImageSrcs = new Set<string>();
|
||||
const items: ShowcaseAssetItem[] = [];
|
||||
|
||||
entries
|
||||
.filter((entry) => matchesPublicGalleryTab(entry, tab))
|
||||
.forEach((entry) => {
|
||||
const coverImageSrc = resolvePlatformWorldCoverImage(entry).trim();
|
||||
const imageCandidates = [
|
||||
...resolvePlatformWorldCoverSlides(entry).map((slide) => ({
|
||||
src: slide.imageSrc.trim(),
|
||||
label: slide.label?.trim() || entry.worldName,
|
||||
})),
|
||||
{ src: coverImageSrc, label: entry.worldName },
|
||||
].filter((candidate) => candidate.src);
|
||||
|
||||
imageCandidates.forEach((candidate, index) => {
|
||||
if (usedImageSrcs.has(candidate.src)) {
|
||||
return;
|
||||
}
|
||||
usedImageSrcs.add(candidate.src);
|
||||
items.push({
|
||||
id: `public:${entry.sourceType}:${entry.profileId}:${index}`,
|
||||
label: index === 0 ? entry.worldName : `${entry.worldName} ${index + 1}`,
|
||||
previews: [
|
||||
{
|
||||
id: `public:${entry.profileId}:${index}`,
|
||||
label: candidate.label,
|
||||
src: candidate.src,
|
||||
mediaType: 'image',
|
||||
width: index === 0 ? 720 : 640,
|
||||
height: index === 0 ? 480 : 720,
|
||||
},
|
||||
],
|
||||
prompt: entry.summaryText || entry.subtitle || UNKNOWN_META_VALUE,
|
||||
author: resolvePlatformWorkAuthorDisplayName(entry),
|
||||
cost: UNKNOWN_META_VALUE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items.slice(0, 36);
|
||||
}
|
||||
|
||||
export function buildCreationShowcaseItems({
|
||||
activeTab,
|
||||
assetLibrary,
|
||||
publicGalleryEntries,
|
||||
projectResources,
|
||||
fallbackAuthorName,
|
||||
}: {
|
||||
activeTab: ShowcaseTabId;
|
||||
assetLibrary: EditorAssetLibrarySnapshot | null;
|
||||
publicGalleryEntries: PlatformPublicGalleryCard[];
|
||||
projectResources: EditorProjectResourceSnapshot[];
|
||||
fallbackAuthorName?: string | null;
|
||||
}) {
|
||||
const libraryItems = getGroupsForTab(assetLibrary?.assets ?? [], activeTab)
|
||||
const projectItems = getGroupsForTab(
|
||||
buildGeneratedProjectResourceAssets(projectResources),
|
||||
activeTab,
|
||||
)
|
||||
.map((group) => toLibraryShowcaseItem(group, fallbackAuthorName))
|
||||
.filter((item): item is ShowcaseAssetItem => item !== null);
|
||||
|
||||
if (libraryItems.length > 0) {
|
||||
return libraryItems.slice(0, 36);
|
||||
}
|
||||
|
||||
return toPublicGalleryShowcaseItems(publicGalleryEntries, activeTab);
|
||||
return projectItems.slice(0, 36);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
|
||||
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
|
||||
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
|
||||
expect(canvasDisplayScaleToViewportScale(0.05)).toBe(0.025);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
|
||||
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
|
||||
expect(formatCanvasDisplayScalePercent(0.025)).toBe('5%');
|
||||
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
|
||||
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
|
||||
expect(
|
||||
@@ -705,4 +707,59 @@ describe('ImageCanvasEditorModel', () => {
|
||||
horizontal: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps equal-spacing snap focused on nearby overlapping layers', () => {
|
||||
const movingLayer: CanvasLayer = {
|
||||
id: 'moving',
|
||||
resourceId: 'resource-moving',
|
||||
title: '移动图',
|
||||
src: 'data:image/png;base64,moving',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 80,
|
||||
originalWidth: 80,
|
||||
originalHeight: 80,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
const firstLayer: CanvasLayer = {
|
||||
...movingLayer,
|
||||
id: 'first',
|
||||
resourceId: 'resource-first',
|
||||
x: 100,
|
||||
y: 160,
|
||||
zIndex: 2,
|
||||
};
|
||||
const secondLayer: CanvasLayer = {
|
||||
...movingLayer,
|
||||
id: 'second',
|
||||
resourceId: 'resource-second',
|
||||
x: 260,
|
||||
y: 160,
|
||||
zIndex: 3,
|
||||
};
|
||||
const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({
|
||||
...movingLayer,
|
||||
id: `offscreen-${index}`,
|
||||
resourceId: `resource-offscreen-${index}`,
|
||||
x: 2400 + index * 96,
|
||||
y: 1600,
|
||||
zIndex: 10 + index,
|
||||
}));
|
||||
|
||||
const snapped = resolveSnappedLayerPosition(
|
||||
movingLayer,
|
||||
417,
|
||||
160,
|
||||
[movingLayer, ...offscreenLayers, firstLayer, secondLayer],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(snapped.x).toBe(420);
|
||||
expect(snapped.guide).toEqual({
|
||||
vertical: 460,
|
||||
horizontal: 160,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,13 +30,14 @@ export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
|
||||
export const CANVAS_WORLD_SIZE = 12000;
|
||||
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||||
export const MIN_SCALE = 0.24;
|
||||
export const MIN_SCALE = 0.025;
|
||||
export const MAX_SCALE = 3.2;
|
||||
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
||||
export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
||||
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
||||
export const FIT_VIEW_PADDING = 10;
|
||||
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
||||
export const MINIMAP_PADDING = 8;
|
||||
@@ -1291,10 +1292,19 @@ function findNearestEqualSpacingSnap({
|
||||
items: CanvasSnapItem[];
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const orderedItems = [...items].sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
const orderedItems = items
|
||||
.filter((item) =>
|
||||
snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
item,
|
||||
axis,
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
let nearest: SnapCandidate | null = null;
|
||||
|
||||
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
||||
@@ -1304,29 +1314,14 @@ function findNearestEqualSpacingSnap({
|
||||
}
|
||||
for (
|
||||
let secondIndex = firstIndex + 1;
|
||||
secondIndex < orderedItems.length;
|
||||
secondIndex < orderedItems.length &&
|
||||
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
||||
secondIndex += 1
|
||||
) {
|
||||
const secondItem = orderedItems[secondIndex];
|
||||
if (!secondItem) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
firstItem,
|
||||
axis,
|
||||
) ||
|
||||
!snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
secondItem,
|
||||
axis,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstStart = getSnapItemStart(firstItem, axis);
|
||||
const firstEnd = getSnapItemEnd(firstItem, axis);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user