diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7eed4c0c5..71cf20114 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -40,12 +40,12 @@ - 验证方式:运行品牌标识、绑定手机号页和平台首页相关前端测试,确认品牌图 `src` 为 `/branding/taonier-product-ip.png`;执行 `npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`。 -## 2026-06-22 创作主页精选只展示项目内画布生成资源 +## 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。 +- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间倒序展示。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。 +- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。 +- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts`、`CreationLandingView.test.tsx`、`ImageCanvasAssetRowView.test.tsx`、`useImageCanvasAssetLibrary.test.tsx` 与 `src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-06-22 编辑器生成模型默认定价调整 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6879bf4c6..33f5743bb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 陶泥儿精选重复先查同源同媒体画布副本 + +- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。 +- 原因:素材拖入画布会为图层实例准备 `editor_project_resource`;如果该素材本来带 `sourceResourceId` 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。 +- 处理:创建项目资源时保留 `source_resource_id`,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 `public_showcase_enabled = false`。公开精选读取和前端精选模型都跳过 `sourceResourceId` 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。 +- 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId`;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/creation-home/creationShowcaseModel.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 + ## 画板外部生成排队超时不是失败 - 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 @@ -183,6 +191,14 @@ - 验证:`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`。 +## 图片画布发布入口 429 也要查 read-url 换签爆发 + +- 现象:发布域名刚上线或刷新画板后出现短时间 `429`,Nginx access log 中集中为同一 IP / 同一 `editor/canvas?projectid=...` referrer 的 `GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png` 到几十上百个 UI 设计切片;429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting requests ... zone "genarrative_api_rps"`。 +- 原因:这类 429 是入口 Nginx `limit_req` 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 `ResolvedAssetImage` 同时挂载;如果 `/api/assets/read-url` 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 `genarrative_api_rps` burst。 +- 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 `read-url` 数量、状态和 referrer,确认是否同一画板页面触发。`assetReadUrlService` 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 `/api/assets/read-url`。 +- 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。 +- 关联:`src/services/assetReadUrlService.ts`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9d3af0f74..87ae87f6f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -46,7 +46,7 @@ - 新增 `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`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 -- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。 +- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `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` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index cef069bba..cb5e2c70e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -468,7 +468,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind` 和 `generation_inputs_json`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` @@ -482,7 +482,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind` 和 `generation_inputs_json`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,前端只用该快照更新素材栏。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `inventory_slot` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index fa88b6f8c..f968603d5 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -15,7 +15,7 @@ - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 - 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 -- `陶泥儿精选` 只展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 +- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 ## 页面结构 @@ -61,9 +61,9 @@ ### 陶泥儿精选 -`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 +`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 的项目资源,代表用户通过图片画布生成并已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 Tab: @@ -100,10 +100,11 @@ Tab: 数据来源: -- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 - 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。 -- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 +- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 +- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。菜单内 `公开展示该素材` 默认勾选,取消勾选后调用 `PATCH /api/editor/project-resources/{resourceId}/showcase` 写回 `public_showcase_enabled = false`,该资源从 `陶泥儿精选` 移除。 - 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。 - 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。 @@ -114,7 +115,7 @@ Tab: - 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。 - 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。 - 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。 -- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。 +- `陶泥儿精选` 读取后端公开精选接口返回的全站公开画布生成资源;公开开关以后端 `editor_project_resource.public_showcase_enabled` 为准,前端只做展示过滤、同源同媒体副本兜底过滤和乐观交互回滚。 - `/creation/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 ## 路由与导航 @@ -127,7 +128,7 @@ Tab: - 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。 - 桌面端平台一级页统一复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;推荐、发现、我的、`/creation` 与 `/project` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入任一页后无法返回或切换页签。 - 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。 -- `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。 +- `/project` 项目页头部不展示 `返回主站` 按钮;用户通过左侧导航在主站页签之间切换,项目页右侧只保留项目管理动作。 - 移动端导航列表不展示“创作”和“项目”。 - 移动端首次进入或刷新 `/` 平台首页时显示欢迎弹窗,文案提示移动端仅支持作品展示,体验创作工具请使用电脑端访问;点击 `好` 关闭后,本次页面生命周期内切换下方页签再回到首页不再弹出。 - 移动端直接访问 `/creation` 时显示桌面端打开引导,不加载创作工具主页;`/creation/` 继续直达对应玩法工作台。`/project` 暂不新增移动端拦截。 @@ -172,8 +173,8 @@ Tab: - `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/` 不回归。 - `RpgEntryHomeView.recharge.test.tsx`:桌面显示“创作”“项目”,不显示“草稿”;移动端不显示“创作”和“项目”;我的页不再出现项目快捷入口。 - `CreationLandingView.test.tsx`:覆盖首屏主视觉、九大功能、最近项目、新建入口、查看全部、未登录隐藏最近项目、素材 Tab。 -- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,项目页头部“返回主站”回到 `/`,新建项目进入 `/editor/canvas?projectid=xxx`。 -- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、头部“返回主站”、重命名、删除和选择模式保持通过。 +- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,新建项目进入 `/editor/canvas?projectid=xxx`。 +- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、重命名、删除和选择模式保持通过。 验证命令: @@ -194,7 +195,7 @@ git diff --check - 登录后显示最近项目、新建项目、最多 4 个项目和“查看全部”。 - 点击新建项目进入 `/editor/canvas?projectid=xxx`。 - 桌面导航显示“项目”,不显示“草稿”。 -- `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容。 +- `/project` 头部不展示“返回主站”,右侧只保留选择和新建等项目管理动作。 - 移动视口底部不显示“创作”和“项目”。 - 移动端进入 `/` 首页时显示 `欢迎` 弹窗,正文包含移动端仅支持作品展示和电脑端访问提示,按钮为 `好`。 - 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b15c30b55..25b3c27ec 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -35,7 +35,8 @@ use spacetime_client::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, SpacetimeClientError, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, SpacetimeClientError, }; use crate::{ @@ -142,6 +143,12 @@ pub struct EditorProjectResourceCreateRequest { generation_inputs: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectResourceShowcaseUpdateRequest { + public_showcase_enabled: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderCreateRequest { @@ -298,6 +305,12 @@ pub struct EditorProjectResourceResponse { resource: EditorProjectResourcePayload, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectResourceListResponse { + resources: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetLibraryResponse { @@ -479,6 +492,7 @@ pub struct EditorProjectResourcePayload { source_resource_id: Option, asset_kind: Option, generation_inputs: Option, + public_showcase_enabled: bool, created_at: String, updated_at: String, } @@ -521,6 +535,8 @@ pub struct EditorAssetPayload { task_id: Option, asset_kind: Option, generation_inputs: Option, + source_resource_id: Option, + public_showcase_enabled: Option, created_at: String, updated_at: String, } @@ -587,6 +603,28 @@ pub async fn list_editor_projects( )) } +pub async fn list_public_editor_project_resources( + State(state): State, + Extension(request_context): Extension, +) -> Result, AppError> { + let resources = state + .spacetime_client() + .list_public_editor_project_resources(EditorProjectResourcePublicShowcaseListRecordInput { + limit: 36, + }) + .await + .map_err(map_editor_project_error)?; + let resources = resources + .into_iter() + .map(editor_project_resource_payload_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorProjectResourceListResponse { resources }, + )) +} + pub async fn create_editor_project( State(state): State, Extension(request_context): Extension, @@ -763,6 +801,32 @@ pub async fn create_editor_project_resource( )) } +pub async fn update_editor_project_resource_showcase( + State(state): State, + Path(resource_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let resource = state + .spacetime_client() + .update_editor_project_resource_showcase(EditorProjectResourceShowcaseUpdateRecordInput { + resource_id, + owner_user_id: current_owner_user_id(&authenticated), + public_showcase_enabled: payload.public_showcase_enabled, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorProjectResourceResponse { + resource: editor_project_resource_payload_from_record(resource), + }, + )) +} + pub async fn get_editor_asset_library( State(state): State, Extension(request_context): Extension, @@ -892,6 +956,7 @@ pub async fn create_editor_asset( task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, + source_resource_id: None, now_micros: current_utc_micros(), }) .await @@ -2431,6 +2496,7 @@ pub(crate) fn editor_project_resource_payload_from_record( generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), + public_showcase_enabled: record.public_showcase_enabled, created_at: record.created_at, updated_at: record.updated_at, } @@ -2489,6 +2555,8 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), + source_resource_id: record.source_resource_id, + public_showcase_enabled: record.public_showcase_enabled, created_at: record.created_at, updated_at: record.updated_at, } @@ -2652,6 +2720,10 @@ async fn persist_editor_generated_asset( let folder_id = normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str()); + let created_resource_id = resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .or_else(|| normalize_optional_string(input.source_resource_id.clone())); let asset = if let Some(folder_id) = folder_id { let record = state .spacetime_client() @@ -2673,6 +2745,7 @@ async fn persist_editor_generated_asset( task_id: Some(input.task_id), asset_kind: normalize_optional_string(input.asset_kind), generation_inputs_json, + source_resource_id: created_resource_id, now_micros, }) .await @@ -3798,6 +3871,7 @@ mod tests { source_resource_id: None, asset_kind: Some("spec".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; @@ -3868,6 +3942,7 @@ mod tests { source_resource_id: None, asset_kind: Some("spec".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; @@ -3934,6 +4009,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }; @@ -4393,6 +4469,7 @@ mod tests { "fields": [{"title": "角色设定", "value": "银发游侠"}], "references": [] })), + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4489,6 +4566,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4569,6 +4647,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4690,6 +4769,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0b313d690..84c8f97f2 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -490,6 +490,7 @@ pub async fn create_external_editor_asset( task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, + source_resource_id: None, now_micros: current_utc_micros(), }) .await diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index a8650abd2..2969c40b3 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -13,8 +13,9 @@ use crate::{ delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image, get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, - load_recent_editor_project, rename_editor_project, save_editor_project_layout, - update_editor_asset, update_editor_asset_folder, + list_public_editor_project_resources, load_recent_editor_project, rename_editor_project, + save_editor_project_layout, update_editor_asset, update_editor_asset_folder, + update_editor_project_resource_showcase, }, state::AppState, }; @@ -27,6 +28,10 @@ pub fn router(state: AppState) -> Router { "/api/editor/generation-pricing", get(get_editor_generation_pricing), ) + .route( + "/api/editor/showcase/resources", + get(list_public_editor_project_resources), + ) .route( "/api/editor/projects/recent", get(load_recent_editor_project).route_layer(middleware::from_fn_with_state( @@ -67,6 +72,12 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/project-resources/{resource_id}/showcase", + patch(update_editor_project_resource_showcase).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/editor/assets/library", get(get_editor_asset_library).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index fba5f9c20..a8a877600 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -235,6 +235,56 @@ impl SpacetimeClient { .await } + pub async fn update_editor_project_resource_showcase( + &self, + input: EditorProjectResourceShowcaseUpdateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "update_editor_project_resource_showcase_and_return", + move |connection, sender| { + connection + .procedures() + .update_editor_project_resource_showcase_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_resource_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_public_editor_project_resources( + &self, + input: EditorProjectResourcePublicShowcaseListRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "list_public_editor_project_resources_and_return", + move |connection, sender| { + connection + .procedures() + .list_public_editor_project_resources_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_resource_list_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_editor_asset_library( &self, owner_user_id: String, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index a4fbd17e6..34110353d 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -38,7 +38,8 @@ pub use mapper::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index da294c8d3..e284107b2 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -81,7 +81,8 @@ pub use self::editor_project::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, }; pub use self::external_api_key::{ ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, @@ -210,7 +211,9 @@ pub(crate) use self::editor_project::{ map_editor_asset_library_procedure_result, map_editor_asset_procedure_result, map_editor_project_delete_procedure_result, map_editor_project_layout_save_procedure_result, map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, - map_editor_project_required_procedure_result, map_editor_project_resource_procedure_result, + map_editor_project_required_procedure_result, + map_editor_project_resource_list_procedure_result, + map_editor_project_resource_procedure_result, }; pub(crate) use self::external_api_key::{ map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 85c6fb64e..c17b47969 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -57,6 +57,7 @@ pub struct EditorProjectResourceRecord { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs: Option, + pub public_showcase_enabled: bool, pub created_at: String, pub updated_at: String, } @@ -90,6 +91,8 @@ pub struct EditorAssetRecord { pub task_id: Option, pub asset_kind: Option, pub generation_inputs: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at: String, pub updated_at: String, } @@ -169,6 +172,19 @@ pub struct EditorProjectResourceMediaRepairRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorProjectResourceShowcaseUpdateRecordInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorProjectResourcePublicShowcaseListRecordInput { + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetFolderCreateRecordInput { pub folder_id: String, @@ -213,6 +229,7 @@ pub struct EditorAssetCreateRecordInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } @@ -341,6 +358,27 @@ impl From } } +impl From + for crate::module_bindings::EditorProjectResourceShowcaseUpdateInput +{ + fn from(input: EditorProjectResourceShowcaseUpdateRecordInput) -> Self { + Self { + resource_id: input.resource_id, + owner_user_id: input.owner_user_id, + public_showcase_enabled: input.public_showcase_enabled, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorProjectResourcePublicShowcaseListInput +{ + fn from(input: EditorProjectResourcePublicShowcaseListRecordInput) -> Self { + Self { limit: input.limit } + } +} + impl From for crate::module_bindings::EditorAssetFolderCreateInput { @@ -401,6 +439,7 @@ impl From for crate::module_bindings::EditorAssetC task_id: input.task_id, asset_kind: input.asset_kind, generation_inputs_json: input.generation_inputs_json, + source_resource_id: input.source_resource_id, now_micros: input.now_micros, } } @@ -518,6 +557,20 @@ pub(crate) fn map_editor_project_resource_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照")) } +pub(crate) fn map_editor_project_resource_list_procedure_result( + result: EditorProjectResourceListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .resources + .into_iter() + .map(map_editor_project_resource_snapshot) + .collect() +} + pub(crate) fn map_editor_asset_library_procedure_result( result: EditorAssetLibraryProcedureResult, ) -> Result { @@ -638,6 +691,7 @@ fn map_editor_project_resource_snapshot( source_resource_id: snapshot.source_resource_id, asset_kind: snapshot.asset_kind, generation_inputs, + public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) @@ -698,6 +752,8 @@ fn map_editor_asset_snapshot( task_id: snapshot.task_id, asset_kind: snapshot.asset_kind, generation_inputs, + source_resource_id: snapshot.source_resource_id, + public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b0c6686ec..4a647bd63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). +// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -391,8 +391,11 @@ pub mod editor_project_list_procedure_result_type; pub mod editor_project_procedure_result_type; pub mod editor_project_rename_input_type; pub mod editor_project_resource_create_input_type; +pub mod editor_project_resource_list_procedure_result_type; pub mod editor_project_resource_media_repair_input_type; pub mod editor_project_resource_procedure_result_type; +pub mod editor_project_resource_public_showcase_list_input_type; +pub mod editor_project_resource_showcase_update_input_type; pub mod editor_project_resource_snapshot_type; pub mod editor_project_resource_table; pub mod editor_project_resource_type; @@ -578,6 +581,7 @@ pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; +pub mod list_public_editor_project_resources_and_return_procedure; pub mod list_puzzle_clear_works_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; @@ -1120,6 +1124,7 @@ pub mod unpublish_custom_world_profile_reducer; pub mod update_bark_battle_draft_config_procedure; pub mod update_editor_asset_and_return_procedure; pub mod update_editor_asset_folder_and_return_procedure; +pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_jump_hop_work_procedure; pub mod update_match_3_d_work_procedure; pub mod update_puzzle_clear_work_procedure; @@ -1614,8 +1619,11 @@ pub use editor_project_list_procedure_result_type::EditorProjectListProcedureRes pub use editor_project_procedure_result_type::EditorProjectProcedureResult; pub use editor_project_rename_input_type::EditorProjectRenameInput; pub use editor_project_resource_create_input_type::EditorProjectResourceCreateInput; +pub use editor_project_resource_list_procedure_result_type::EditorProjectResourceListProcedureResult; pub use editor_project_resource_media_repair_input_type::EditorProjectResourceMediaRepairInput; pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult; +pub use editor_project_resource_public_showcase_list_input_type::EditorProjectResourcePublicShowcaseListInput; +pub use editor_project_resource_showcase_update_input_type::EditorProjectResourceShowcaseUpdateInput; pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; pub use editor_project_resource_table::*; pub use editor_project_resource_type::EditorProjectResource; @@ -1801,6 +1809,7 @@ pub use list_match_3_d_works_procedure::list_match_3_d_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use list_public_editor_project_resources_and_return_procedure::list_public_editor_project_resources_and_return; pub use list_puzzle_clear_works_procedure::list_puzzle_clear_works; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; @@ -2343,6 +2352,7 @@ pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; pub use update_bark_battle_draft_config_procedure::update_bark_battle_draft_config; pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return; pub use update_editor_asset_folder_and_return_procedure::update_editor_asset_folder_and_return; +pub use update_editor_project_resource_showcase_and_return_procedure::update_editor_project_resource_showcase_and_return; pub use update_jump_hop_work_procedure::update_jump_hop_work; pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_clear_work_procedure::update_puzzle_clear_work; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs index f37faf5d1..5d39f1fcc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs @@ -24,6 +24,7 @@ pub struct EditorAssetCreateInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs index 85cb1de4a..419250158 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs @@ -23,6 +23,8 @@ pub struct EditorAssetSnapshot { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index 04e959907..ad126e534 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -26,6 +26,7 @@ pub struct EditorAsset { pub updated_at: __sdk::Timestamp, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, } impl __sdk::InModule for EditorAsset { @@ -55,6 +56,7 @@ pub struct EditorAssetCols { pub updated_at: __sdk::__query_builder::Col, pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, + pub source_resource_id: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorAsset { @@ -83,6 +85,7 @@ impl __sdk::__query_builder::HasCols for EditorAsset { table_name, "generation_inputs_json", ), + source_resource_id: __sdk::__query_builder::Col::new(table_name, "source_resource_id"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs new file mode 100644 index 000000000..10e3d136c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourceListProcedureResult { + pub ok: bool, + pub resources: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectResourceListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs new file mode 100644 index 000000000..764c4eed9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourcePublicShowcaseListInput { + pub limit: u32, +} + +impl __sdk::InModule for EditorProjectResourcePublicShowcaseListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs new file mode 100644 index 000000000..b2d655893 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourceShowcaseUpdateInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectResourceShowcaseUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index 085ff2b4c..b1af43048 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -23,6 +23,7 @@ pub struct EditorProjectResourceSnapshot { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs index a47526ce4..4ef4a0a43 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -26,6 +26,7 @@ pub struct EditorProjectResource { pub updated_at: __sdk::Timestamp, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, } impl __sdk::InModule for EditorProjectResource { @@ -55,6 +56,7 @@ pub struct EditorProjectResourceCols { pub updated_at: __sdk::__query_builder::Col, pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, + pub public_showcase_enabled: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for EditorProjectResource { @@ -83,6 +85,10 @@ impl __sdk::__query_builder::HasCols for EditorProjectResource { table_name, "generation_inputs_json", ), + public_showcase_enabled: __sdk::__query_builder::Col::new( + table_name, + "public_showcase_enabled", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs new file mode 100644 index 000000000..70b2895cf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_resource_list_procedure_result_type::EditorProjectResourceListProcedureResult; +use super::editor_project_resource_public_showcase_list_input_type::EditorProjectResourcePublicShowcaseListInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPublicEditorProjectResourcesAndReturnArgs { + pub input: EditorProjectResourcePublicShowcaseListInput, +} + +impl __sdk::InModule for ListPublicEditorProjectResourcesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_public_editor_project_resources_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_public_editor_project_resources_and_return { + fn list_public_editor_project_resources_and_return( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + ) { + self.list_public_editor_project_resources_and_return_then(input, |_, _| {}); + } + + fn list_public_editor_project_resources_and_return_then( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_public_editor_project_resources_and_return for super::RemoteProcedures { + fn list_public_editor_project_resources_and_return_then( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceListProcedureResult>( + "list_public_editor_project_resources_and_return", + ListPublicEditorProjectResourcesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs new file mode 100644 index 000000000..200432a78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult; +use super::editor_project_resource_showcase_update_input_type::EditorProjectResourceShowcaseUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateEditorProjectResourceShowcaseAndReturnArgs { + pub input: EditorProjectResourceShowcaseUpdateInput, +} + +impl __sdk::InModule for UpdateEditorProjectResourceShowcaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_editor_project_resource_showcase_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_editor_project_resource_showcase_and_return { + fn update_editor_project_resource_showcase_and_return( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + ) { + self.update_editor_project_resource_showcase_and_return_then(input, |_, _| {}); + } + + fn update_editor_project_resource_showcase_and_return_then( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_editor_project_resource_showcase_and_return for super::RemoteProcedures { + fn update_editor_project_resource_showcase_and_return_then( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( + "update_editor_project_resource_showcase_and_return", + UpdateEditorProjectResourceShowcaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 28379ddd3..3d8585bab 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -74,6 +74,8 @@ pub struct EditorProjectResource { asset_kind: Option, #[default(None::)] generation_inputs_json: Option, + #[default(true)] + public_showcase_enabled: bool, } #[spacetimedb::table( @@ -120,6 +122,8 @@ pub struct EditorAsset { asset_kind: Option, #[default(None::)] generation_inputs_json: Option, + #[default(None::)] + source_resource_id: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -208,6 +212,19 @@ pub struct EditorProjectResourceMediaRepairInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectResourceShowcaseUpdateInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectResourcePublicShowcaseListInput { + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceSnapshot { pub resource_id: String, @@ -226,6 +243,7 @@ pub struct EditorProjectResourceSnapshot { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -259,6 +277,8 @@ pub struct EditorAssetSnapshot { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -319,6 +339,7 @@ pub struct EditorAssetCreateInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } @@ -411,6 +432,13 @@ pub struct EditorProjectResourceProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectResourceListProcedureResult { + pub ok: bool, + pub resources: Vec, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetLibraryProcedureResult { pub ok: bool, @@ -575,6 +603,44 @@ pub fn repair_editor_project_resource_media_and_return( } } +#[spacetimedb::procedure] +pub fn update_editor_project_resource_showcase_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectResourceShowcaseUpdateInput, +) -> EditorProjectResourceProcedureResult { + match ctx.try_with_tx(|tx| update_editor_project_resource_showcase(tx, input.clone())) { + Ok(resource) => EditorProjectResourceProcedureResult { + ok: true, + resource: Some(resource), + error_message: None, + }, + Err(message) => EditorProjectResourceProcedureResult { + ok: false, + resource: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_public_editor_project_resources_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectResourcePublicShowcaseListInput, +) -> EditorProjectResourceListProcedureResult { + match ctx.try_with_tx(|tx| list_public_editor_project_resources(tx, input.clone())) { + Ok(resources) => EditorProjectResourceListProcedureResult { + ok: true, + resources, + error_message: None, + }, + Err(message) => EditorProjectResourceListProcedureResult { + ok: false, + resources: Vec::new(), + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_editor_asset_library_and_return( ctx: &mut ProcedureContext, @@ -918,6 +984,27 @@ fn create_editor_project_resource( if input.width == 0 || input.height == 0 { return Err("画布资源尺寸必须大于 0".to_string()); } + let asset_object_id = normalize_optional(input.asset_object_id); + let object_key = normalize_optional(input.object_key); + let source_resource_id = normalize_optional(input.source_resource_id); + let prompt = normalize_optional(input.prompt); + let actual_prompt = normalize_optional(input.actual_prompt); + let model = normalize_optional(input.model); + let provider = normalize_optional(input.provider); + let task_id = normalize_optional(input.task_id); + let asset_kind = normalize_optional(input.asset_kind); + let generation_inputs_json = normalize_optional(input.generation_inputs_json); + if let Some(existing_resource) = find_reusable_project_resource_for_input( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + source_resource_id.as_ref(), + asset_object_id.as_ref(), + object_key.as_ref(), + image_src.as_str(), + ) { + return Ok(resource_snapshot_from_row(existing_resource)); + } if ctx .db .editor_project_resource() @@ -928,6 +1015,15 @@ fn create_editor_project_resource( return Err("画布资源已存在".to_string()); } + let public_showcase_enabled = source_type != "generated" + || !project_resource_input_copies_source_media( + ctx, + owner_user_id.as_str(), + source_resource_id.as_ref(), + asset_object_id.as_ref(), + object_key.as_ref(), + image_src.as_str(), + ); let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .editor_project_resource() @@ -935,22 +1031,23 @@ fn create_editor_project_resource( resource_id: resource_id.clone(), project_id, owner_user_id, - asset_object_id: normalize_optional(input.asset_object_id), + asset_object_id, image_src, - object_key: normalize_optional(input.object_key), + object_key, width: input.width, height: input.height, source_type, - prompt: normalize_optional(input.prompt), - actual_prompt: normalize_optional(input.actual_prompt), - model: normalize_optional(input.model), - provider: normalize_optional(input.provider), - task_id: normalize_optional(input.task_id), - source_resource_id: normalize_optional(input.source_resource_id), + prompt, + actual_prompt, + model, + provider, + task_id, + source_resource_id, created_at: now, updated_at: now, - asset_kind: normalize_optional(input.asset_kind), - generation_inputs_json: normalize_optional(input.generation_inputs_json), + asset_kind, + generation_inputs_json, + public_showcase_enabled, }); ctx.db @@ -1007,6 +1104,7 @@ fn repair_editor_project_resource_media( updated_at: now, asset_kind: resource.asset_kind, generation_inputs_json: resource.generation_inputs_json, + public_showcase_enabled: resource.public_showcase_enabled, }); ctx.db @@ -1167,12 +1265,13 @@ fn create_editor_asset( updated_at: now, asset_kind: normalize_optional(input.asset_kind), generation_inputs_json: normalize_optional(input.generation_inputs_json), + source_resource_id: normalize_optional(input.source_resource_id), }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材创建失败".to_string()) } @@ -1214,12 +1313,13 @@ fn update_editor_asset( updated_at: now, asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, + source_resource_id: asset.source_resource_id, }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材更新失败".to_string()) } @@ -1257,13 +1357,14 @@ fn repair_editor_asset_media( updated_at: now, asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, + source_resource_id: asset.source_resource_id, }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材媒体修复失败".to_string()) } @@ -1275,7 +1376,7 @@ fn delete_editor_asset( let owner_user_id = normalize_required(&input.owner_user_id, "editor_asset.owner_user_id")?; let asset = require_owned_asset(ctx, asset_id.as_str(), owner_user_id.as_str())?; ctx.db.editor_asset().asset_id().delete(&asset_id); - Ok(asset_snapshot_from_row(asset)) + Ok(asset_snapshot_from_row(ctx, asset)) } fn build_project_snapshot( @@ -1320,6 +1421,69 @@ fn build_project_snapshot( }) } +fn update_editor_project_resource_showcase( + ctx: &ReducerContext, + input: EditorProjectResourceShowcaseUpdateInput, +) -> Result { + let resource_id = + normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "editor_project_resource.owner_user_id", + )?; + let resource = + require_owned_project_resource(ctx, resource_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .editor_project_resource() + .resource_id() + .delete(&resource_id); + ctx.db + .editor_project_resource() + .insert(EditorProjectResource { + public_showcase_enabled: input.public_showcase_enabled, + updated_at: now, + ..resource + }); + + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "画布资源公开展示状态更新失败".to_string()) +} + +fn list_public_editor_project_resources( + ctx: &ReducerContext, + input: EditorProjectResourcePublicShowcaseListInput, +) -> Result, String> { + let limit = input.limit.clamp(1, 120) as usize; + let mut resources = ctx + .db + .editor_project_resource() + .iter() + .filter(|resource| { + resource.public_showcase_enabled + && resource.source_type == "generated" + && !resource.image_src.trim().is_empty() + && !project_resource_copies_source_media(ctx, resource) + }) + .map(resource_snapshot_from_row) + .collect::>(); + + resources.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| right.resource_id.cmp(&left.resource_id)) + }); + resources.truncate(limit); + + Ok(resources) +} + fn ensure_default_canvas( ctx: &ReducerContext, project_id: &str, @@ -1509,7 +1673,7 @@ fn build_asset_library_snapshot( .editor_asset() .by_editor_asset_owner_user_id() .filter(&owner_key) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .collect::>(); assets.sort_by(|left, right| { left.created_at_micros @@ -1531,7 +1695,15 @@ fn asset_folder_snapshot_from_row(row: EditorAssetFolder) -> EditorAssetFolderSn } } -fn asset_snapshot_from_row(row: EditorAsset) -> EditorAssetSnapshot { +fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAssetSnapshot { + let linked_resource = find_asset_source_project_resource(ctx, &row); + let inferred_source_resource_id = linked_resource + .as_ref() + .map(|resource| resource.resource_id.clone()); + let public_showcase_enabled = linked_resource + .as_ref() + .map(|resource| resource.public_showcase_enabled); + EditorAssetSnapshot { asset_id: row.asset_id, folder_id: row.folder_id, @@ -1549,11 +1721,237 @@ fn asset_snapshot_from_row(row: EditorAsset) -> EditorAssetSnapshot { task_id: row.task_id, asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, + source_resource_id: row.source_resource_id.or(inferred_source_resource_id), + public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } +fn find_asset_source_project_resource( + ctx: &ReducerContext, + row: &EditorAsset, +) -> Option { + if let Some(resource_id) = row + .source_resource_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + if let Some(resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id.to_string()) + .filter(|resource| resource.owner_user_id == row.owner_user_id) + { + return Some(resource); + } + } + + if row.source_type != "generated" { + return None; + } + + let owner_key = row.owner_user_id.clone(); + let mut matches = ctx + .db + .editor_project_resource() + .by_editor_project_resource_owner_user_id() + .filter(&owner_key) + .filter(|resource| { + resource.source_type == "generated" + && project_resource_matches_asset_media(resource, row) + }) + .collect::>(); + matches.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| right.resource_id.cmp(&left.resource_id)) + }); + matches.into_iter().next() +} + +fn project_resource_matches_asset_media( + resource: &EditorProjectResource, + asset: &EditorAsset, +) -> bool { + project_resource_matches_media( + resource, + asset.asset_object_id.as_ref(), + asset.object_key.as_ref(), + asset.image_src.as_str(), + ) +} + +fn project_resource_matches_media( + resource: &EditorProjectResource, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + if matching_optional_text_ref(resource.asset_object_id.as_ref(), asset_object_id) { + return true; + } + if matching_optional_media_ref_ref(resource.object_key.as_ref(), object_key) { + return true; + } + if let Some(asset_object_key) = object_key.map(String::as_str) { + if matching_media_ref(resource.image_src.as_str(), asset_object_key) { + return true; + } + } + if let Some(resource_object_key) = resource.object_key.as_deref() { + if matching_media_ref(resource_object_key, image_src) { + return true; + } + } + matching_media_ref(resource.image_src.as_str(), image_src) +} + +fn project_resource_copies_source_media( + ctx: &ReducerContext, + resource: &EditorProjectResource, +) -> bool { + project_resource_input_copies_source_media( + ctx, + resource.owner_user_id.as_str(), + resource.source_resource_id.as_ref(), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + ) +} + +fn project_resource_input_copies_source_media( + ctx: &ReducerContext, + owner_user_id: &str, + source_resource_id: Option<&String>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + let Some(source_resource_id) = source_resource_id + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + ctx.db + .editor_project_resource() + .resource_id() + .find(&source_resource_id.to_string()) + .filter(|source_resource| source_resource.owner_user_id == owner_user_id) + .is_some_and(|source_resource| { + project_resource_matches_media( + &source_resource, + asset_object_id, + object_key, + image_src, + ) + }) +} + +fn find_reusable_project_resource_for_input( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + source_resource_id: Option<&String>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> Option { + let source_resource_id = source_resource_id + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let project_key = project_id.to_string(); + let mut matches = ctx + .db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_key) + .filter(|resource| { + resource.owner_user_id == owner_user_id + && (resource.resource_id == source_resource_id + || resource + .source_resource_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value == source_resource_id)) + && project_resource_matches_media( + resource, + asset_object_id, + object_key, + image_src, + ) + }) + .collect::>(); + matches.sort_by(|left, right| { + let left_is_source = left.resource_id == source_resource_id; + let right_is_source = right.resource_id == source_resource_id; + right_is_source + .cmp(&left_is_source) + .then_with(|| { + left.created_at + .to_micros_since_unix_epoch() + .cmp(&right.created_at.to_micros_since_unix_epoch()) + }) + .then_with(|| left.resource_id.cmp(&right.resource_id)) + }); + matches.into_iter().next() +} + +fn matching_optional_text_ref(left: Option<&String>, right: Option<&String>) -> bool { + match (normalize_optional_ref(left), normalize_optional_ref(right)) { + (Some(left_value), Some(right_value)) => left_value == right_value, + _ => false, + } +} + +fn matching_optional_media_ref_ref(left: Option<&String>, right: Option<&String>) -> bool { + match (normalize_optional_ref(left), normalize_optional_ref(right)) { + (Some(left_value), Some(right_value)) => matching_media_ref(left_value, right_value), + _ => false, + } +} + +fn normalize_optional_ref(value: Option<&String>) -> Option<&str> { + value + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn matching_media_ref(left: &str, right: &str) -> bool { + let left_value = normalize_media_ref(left); + let right_value = normalize_media_ref(right); + !left_value.is_empty() && left_value == right_value +} + +fn normalize_media_ref(value: &str) -> String { + let trimmed_value = value + .trim() + .split('?') + .next() + .unwrap_or("") + .trim(); + let path_value = if let Some(protocol_index) = trimmed_value.find("://") { + let after_protocol = &trimmed_value[protocol_index + 3..]; + after_protocol + .find('/') + .map(|path_index| &after_protocol[path_index..]) + .unwrap_or("") + } else { + trimmed_value + }; + + path_value.trim_start_matches('/').to_string() +} + fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot { EditorProjectResourceSnapshot { resource_id: row.resource_id, @@ -1572,6 +1970,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour source_resource_id: row.source_resource_id, asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, + public_showcase_enabled: row.public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f1babe02a..b76f1ab31 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1394,6 +1394,22 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "editor_project_resource" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:精选公开开关晚于画布资源表加入,旧生成资源按默认公开兼容。 + object + .entry("public_showcase_enabled".to_string()) + .or_insert_with(|| serde_json::Value::Bool(true)); + } + } + if table_name == "editor_asset" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:账号素材与项目资源绑定晚于素材库表加入,旧素材按未绑定兼容。 + object + .entry("source_resource_id".to_string()) + .or_insert(serde_json::Value::Null); + } + } next_value } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 31997e2d1..40eda8514 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -3,12 +3,13 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ContextType } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthUiContext } from '../auth/AuthUiContext'; import { CreationLandingView } from './CreationLandingView'; const listEditorProjectsMock = vi.hoisted(() => vi.fn()); +const listPublicEditorProjectResourcesMock = vi.hoisted(() => vi.fn()); const createEditorProjectMock = vi.hoisted(() => vi.fn()); type AuthValue = NonNullable>; @@ -45,6 +46,7 @@ function createAuthValue(overrides: Partial = {}): AuthValue { vi.mock('../../services/image-editor/editorProjectClient', () => ({ listEditorProjects: listEditorProjectsMock, + listPublicEditorProjectResources: listPublicEditorProjectResourcesMock, createEditorProject: createEditorProjectMock, })); @@ -88,8 +90,13 @@ function renderCreationLanding({ } describe('CreationLandingView', () => { + beforeEach(() => { + listPublicEditorProjectResourcesMock.mockResolvedValue([]); + }); + afterEach(() => { listEditorProjectsMock.mockReset(); + listPublicEditorProjectResourcesMock.mockReset(); createEditorProjectMock.mockReset(); }); @@ -212,28 +219,24 @@ describe('CreationLandingView', () => { it('renders generated project resources in showcase tabs without mock rows', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.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-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, }, ]); @@ -251,55 +254,51 @@ describe('CreationLandingView', () => { it('opens a fullscreen asset preview modal with thumbnail navigation', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.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', - }, - ], + 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', }, - }, - ], + ], + }, }, ]); @@ -325,58 +324,49 @@ describe('CreationLandingView', () => { }); it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => { - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.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, - }, - ], + 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, }, { - ...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, - }, - ], + 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, + }, + { + 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, }, ]); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 981a31f7b..096b07439 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { createEditorProject, + listPublicEditorProjectResources, listEditorProjects, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, @@ -364,6 +365,7 @@ export function CreationLandingView({ [], ); const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [isLoadingShowcase, setIsLoadingShowcase] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); const [projectError, setProjectError] = useState(null); const [projectShowcaseResources, setProjectShowcaseResources] = useState< @@ -379,14 +381,11 @@ export function CreationLandingView({ const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { setRecentProjects([]); - setProjectShowcaseResources([]); setProjectError(null); - setAssetError(null); return; } setIsLoadingProjects(true); setProjectError(null); - setAssetError(null); listEditorProjects() .then((projects) => { const sortedProjects = [...projects].sort( @@ -395,9 +394,6 @@ export function CreationLandingView({ new Date(left.updatedAt).getTime(), ); setRecentProjects(sortedProjects.slice(0, 4)); - setProjectShowcaseResources( - sortedProjects.flatMap((project) => project.resources ?? []), - ); }) .catch((error: unknown) => { if (isUnauthorizedError(error)) { @@ -409,7 +405,6 @@ export function CreationLandingView({ setProjectError( message, ); - setAssetError(message); }) .finally(() => setIsLoadingProjects(false)); }, [authUi, isAuthenticated]); @@ -418,6 +413,24 @@ export function CreationLandingView({ refreshRecentProjects(); }, [refreshRecentProjects]); + const refreshShowcaseResources = useCallback(() => { + setIsLoadingShowcase(true); + setAssetError(null); + listPublicEditorProjectResources() + .then((resources) => setProjectShowcaseResources(resources)) + .catch((error: unknown) => { + setProjectShowcaseResources([]); + setAssetError( + error instanceof Error ? error.message : '读取陶泥儿精选失败', + ); + }) + .finally(() => setIsLoadingShowcase(false)); + }, []); + + useEffect(() => { + refreshShowcaseResources(); + }, [refreshShowcaseResources]); + const createProject = useCallback(() => { if (authUi && !authUi.user) { authUi.openLoginModal(createProject); @@ -444,12 +457,12 @@ export function CreationLandingView({ buildCreationShowcaseItems({ activeTab: activeShowcaseTab, projectResources: projectShowcaseResources, - fallbackAuthorName: authUi?.user?.displayName, + fallbackAuthorName: null, }), - [activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName], + [activeShowcaseTab, projectShowcaseResources], ); - const isShowcaseLoading = isAuthenticated && isLoadingProjects; + const isShowcaseLoading = isLoadingShowcase; const showcaseEmptyText = '暂无素材'; const renderAssetShowcase = () => { diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 30f6d8c96..0bfe74cc1 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -53,6 +53,63 @@ describe('creationShowcaseModel', () => { expect(items[0]?.label).toBe('画布生成素材'); }); + it('filters out generated resources that are not public showcase enabled', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'packs', + projectResources: [ + createResource({ + resourceId: 'public-generated', + label: '公开素材', + sourceType: 'generated', + publicShowcaseEnabled: true, + }), + createResource({ + resourceId: 'private-generated', + label: '不公开素材', + sourceType: 'generated', + publicShowcaseEnabled: false, + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('公开素材'); + }); + + it('deduplicates canvas copies that point back to the same generated resource', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + projectResources: [ + createResource({ + resourceId: 'resource-original', + label: '原始角色图', + imageSrc: '/generated/character.png', + objectKey: 'generated/character.png', + assetObjectId: 'asset-object-character', + assetKind: 'character', + sourceType: 'generated', + publicShowcaseEnabled: true, + }), + createResource({ + resourceId: 'resource-copy', + label: '拖拽副本角色图', + imageSrc: + 'https://oss.example.test/generated/character.png?x-oss-signature=1', + objectKey: '/generated/character.png', + assetObjectId: 'asset-object-character', + assetKind: 'character', + sourceType: 'generated', + sourceResourceId: 'resource-original', + publicShowcaseEnabled: true, + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.previews).toHaveLength(1); + expect(items[0]?.previews[0]?.id).toBe('resource-original'); + }); + it('groups pack items by the same referenced spec image and sums known cost', () => { const items = buildCreationShowcaseItems({ activeTab: 'packs', diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index bd5fa4a69..22fc6f608 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -210,15 +210,70 @@ function projectResourceToShowcaseAsset( } as EditorAssetSnapshot; } +function normalizeShowcaseMediaRef(value: string | null | undefined) { + const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? ''; + if (!trimmedValue) { + return ''; + } + const pathValue = /^https?:\/\//iu.test(trimmedValue) + ? (() => { + try { + return new URL(trimmedValue).pathname; + } catch { + return trimmedValue; + } + })() + : trimmedValue; + return pathValue.replace(/^\/+/u, ''); +} + +function getProjectResourceMediaKey(resource: EditorProjectResourceSnapshot) { + const assetObjectId = resource.assetObjectId?.trim(); + if (assetObjectId) { + return `asset-object:${assetObjectId}`; + } + const objectKey = normalizeShowcaseMediaRef(resource.objectKey); + if (objectKey) { + return `object:${objectKey}`; + } + const imageSrc = normalizeShowcaseMediaRef(resource.imageSrc); + return imageSrc ? `src:${imageSrc}` : ''; +} + +function isCopiedProjectResource( + resource: EditorProjectResourceSnapshot, + resourceById: Map, +) { + const sourceResourceId = resource.sourceResourceId?.trim(); + if (!sourceResourceId) { + return false; + } + const sourceResource = resourceById.get(sourceResourceId); + if (!sourceResource) { + return false; + } + return ( + getProjectResourceMediaKey(sourceResource) === + getProjectResourceMediaKey(resource) + ); +} + function buildGeneratedProjectResourceAssets( resources: EditorProjectResourceSnapshot[], ) { const seenResourceIds = new Set(); + const resourceById = new Map( + resources + .filter((resource) => resource.resourceId.trim()) + .map((resource) => [resource.resourceId, resource]), + ); return resources .filter( (resource) => - resource.sourceType === 'generated' && resource.imageSrc.trim(), + resource.publicShowcaseEnabled !== false && + resource.sourceType === 'generated' && + resource.imageSrc.trim(), ) .filter((resource) => { if (seenResourceIds.has(resource.resourceId)) { @@ -227,6 +282,7 @@ function buildGeneratedProjectResourceAssets( seenResourceIds.add(resource.resourceId); return true; }) + .filter((resource) => !isCopiedProjectResource(resource, resourceById)) .map(projectResourceToShowcaseAsset); } diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index cff04617e..fe371467c 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -60,6 +60,10 @@ type ImageCanvasAssetFolderSectionViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; }; @@ -90,6 +94,7 @@ export function ImageCanvasAssetFolderSectionView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }: ImageCanvasAssetFolderSectionViewProps) { @@ -248,6 +253,7 @@ export function ImageCanvasAssetFolderSectionView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} /> diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index c8e02577c..ac4320a0c 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -90,6 +90,10 @@ export type ImageCanvasAssetLibraryPanelViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; @@ -136,6 +140,7 @@ export function ImageCanvasAssetLibraryPanelView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, @@ -226,6 +231,7 @@ export function ImageCanvasAssetLibraryPanelView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} /> diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index 3df07e5b5..976c88abb 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -56,6 +56,8 @@ function renderAssetRow({ addAssetLayer = vi.fn(), toggleAssetSelected = vi.fn(), commitAssetRename = vi.fn(), + deleteUploadedAsset = vi.fn(), + setAssetPublicShowcaseEnabled = vi.fn(), }: { asset?: EditorAsset; isAssetSelectionMode?: boolean; @@ -65,6 +67,11 @@ function renderAssetRow({ addAssetLayer?: (asset: EditorAsset) => void; toggleAssetSelected?: (assetId: string) => void; commitAssetRename?: (asset: EditorAsset) => void; + deleteUploadedAsset?: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled?: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; } = {}) { const props = { asset, @@ -82,7 +89,8 @@ function renderAssetRow({ moveAssetToFolder: vi.fn(), startRenamingAsset: vi.fn(), commitAssetRename, - deleteUploadedAsset: vi.fn(), + deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }; @@ -207,4 +215,84 @@ describe('ImageCanvasAssetRowView', () => { 'generated-character-drafts/editor/spec.png', ); }); + + it('opens asset actions from the right click menu', () => { + const asset = createAsset({ + sourceResourceId: 'resource-asset-1', + publicShowcaseEnabled: true, + }); + const deleteUploadedAsset = vi.fn(); + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ + asset, + deleteUploadedAsset, + setAssetPublicShowcaseEnabled, + }); + + expect( + screen.queryByRole('button', { name: '删除素材账号素材A' }), + ).toBeNull(); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect(publicMenuItem.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + expect(deleteUploadedAsset).toHaveBeenCalledWith(asset); + }); + + it('disables public showcase toggle when the asset is not linked to a project resource', () => { + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ setAssetPublicShowcaseEnabled }); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled(); + }); + + it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => { + const asset = createAsset({ + sourceType: 'generated', + sourceResourceId: 'resource-inferred-from-media', + publicShowcaseEnabled: null, + }); + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ asset, setAssetPublicShowcaseEnabled }); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false); + expect(publicMenuItem.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false); + }); }); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 2217afa40..de5516afc 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,4 +1,5 @@ -import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react'; +import { Check, Music2, Pencil, Video, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -41,6 +42,10 @@ export type ImageCanvasAssetRowViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; }; @@ -62,12 +67,51 @@ export function ImageCanvasAssetRowView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }: ImageCanvasAssetRowViewProps) { + const rowRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ + x: number; + y: number; + } | null>(null); const isRenaming = renamingAsset?.assetId === asset.id; const isUploadingAsset = asset.uploadStatus === 'uploading'; const isFailedUpload = asset.uploadStatus === 'failed'; + const canShowcaseAsset = Boolean(asset.sourceResourceId); + const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true; + + const closeMenu = useCallback(() => { + setMenuPosition(null); + }, []); + + useEffect(() => { + if (!menuPosition) { + return undefined; + } + const handlePointerDown = (event: PointerEvent) => { + if (rowRef.current?.contains(event.target as Node)) { + return; + } + closeMenu(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeMenu(); + } + }; + window.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('scroll', closeMenu, true); + window.addEventListener('resize', closeMenu); + return () => { + window.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('scroll', closeMenu, true); + window.removeEventListener('resize', closeMenu); + }; + }, [closeMenu, menuPosition]); const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100); const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset); const mediaPreviewClassName = @@ -146,19 +190,22 @@ export function ImageCanvasAssetRowView({ icon={Pencil} onClick={() => startRenamingAsset(asset)} /> - {asset.sourceKind === 'uploaded' ? ( - deleteUploadedAsset(asset)} - /> - ) : null} ); return ( -
+
{ + if (isUploadingAsset || isFailedUpload || isRenaming) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} + > + {menuPosition ? ( +
+ + {asset.sourceKind === 'uploaded' ? ( + + ) : null} +
+ ) : null}
); } diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 30f37fa60..8aae3250f 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -327,18 +327,22 @@ describe('ImageCanvasEditorView asset library integration', () => { expect( within(customFolder).getByRole('button', { name: '添加角色草图.png' }), ).toBeTruthy(); - expect( - within(customFolder).getByRole('button', { - name: '删除素材角色草图.png', - }), - ).toBeTruthy(); }); - await user.click( - within(customFolder).getByRole('button', { - name: '删除素材角色草图.png', - }), + const uploadedAssetButton = within(customFolder).getByRole('button', { + name: '添加角色草图.png', + }); + const uploadedAssetRow = uploadedAssetButton.closest( + '[data-asset-id="persisted-角色草图.png"]', ); + if (!uploadedAssetRow) { + throw new Error('uploaded asset row should exist'); + } + fireEvent.contextMenu(uploadedAssetRow, { + clientX: 120, + clientY: 180, + }); + await user.click(screen.getByRole('menuitem', { name: '删除' })); expect( screen.queryByRole('button', { name: '添加角色草图.png' }), @@ -867,7 +871,20 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '删除素材生成资源A' })); + const generatedAssetButton = screen.getByRole('button', { + name: '添加生成资源A', + }); + const generatedAssetRow = generatedAssetButton.closest( + '[data-asset-id="asset-generated-a"]', + ); + if (!generatedAssetRow) { + throw new Error('generated asset row should exist'); + } + fireEvent.contextMenu(generatedAssetRow, { + clientX: 120, + clientY: 180, + }); + await user.click(screen.getByRole('menuitem', { name: '删除' })); await waitFor(() => { expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull(); @@ -1144,4 +1161,62 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('keeps the source resource id when dragging a generated asset onto the viewport', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createTestImageAsset({ + assetId: 'asset-generated-character', + label: '生成角色素材', + imageSrc: '/generated/character.png', + objectKey: 'generated/character.png', + assetObjectId: 'asset-object-character', + sourceType: 'generated', + sourceResourceId: 'resource-original-character', + }), + ], + }); + render(); + + const sourceAsset = await screen.findByRole('button', { + name: '添加生成角色素材', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); + const viewport = screen.getByLabelText('画布工作区'); + const dataTransfer = createDataTransferStub(); + + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.drop(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy(); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + imageSrc: '/generated/character.png', + sourceType: 'generated', + sourceResourceId: 'resource-original-character', + }), + ); + }); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 4bcdf4f18..3f34f6f21 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -711,6 +711,7 @@ export function mapAssetLibrarySnapshot( objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, sourceResourceId: asset.sourceResourceId ?? undefined, + publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), durationSeconds: asset.durationSeconds ?? undefined, diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 91ce99ff7..96e038a0b 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -77,6 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { startRenamingAsset: vi.fn(), commitAssetRename: vi.fn(), deleteUploadedAsset: vi.fn(), + setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), toggleAllAssetsSelected: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 441761985..303719a0a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -48,6 +48,7 @@ export type EditorAsset = { objectKey?: string; assetObjectId?: string; sourceResourceId?: string | null; + publicShowcaseEnabled?: boolean | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 24152109f..e9be6c4f6 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -382,6 +382,7 @@ export function ImageCanvasEditorView() { toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, startRenamingFolder, commitFolderRename, deleteAssetFolder, @@ -1317,6 +1318,7 @@ export function ImageCanvasEditorView() { startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index d7573c6ed..c0753550c 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -779,8 +779,8 @@ function ImageCanvasAudioGenerationComposerView({ disabled={isGenerating} placeholder={ isSoundEffect - ? '描述需要生成的游戏音效' - : '描述需要生成的游戏背景音乐' + ? '你希望生成什么音效?' + : '你希望生成什么音乐?' } size="sm" density="compact" diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index e40487f25..6eb956973 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -74,6 +74,28 @@ describe('ImageCanvasGenerationDialogModel', () => { originalHeight: 1024, }, }); + expect( + createSpecDialogDraft({ canvasSize, viewport, specType: 'character' }), + ).toMatchObject({ + mode: 'spec', + specType: 'character', + specValues: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + }, + }); + expect( + createSpecDialogDraft({ canvasSize, viewport, specType: 'ui' }), + ).toMatchObject({ + mode: 'spec', + specType: 'ui', + specValues: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + }, + }); expect( createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }), ).toMatchObject({ @@ -153,16 +175,9 @@ describe('ImageCanvasGenerationDialogModel', () => { imageModel: 'unknown-model', aspectRatio: '1:1', imageSize: '1K', - prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮', + prompt: '', iconSpecReference: null, - iconDescriptions: [ - '返回按钮', - '设置按钮', - '下一关按钮', - '提示按钮', - '原图按钮', - '冻结按钮', - ], + iconDescriptions: [], placeholder: { x: 300, y: 180, @@ -228,8 +243,7 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport })) .toMatchObject({ mode: 'audio-sound-effect', - prompt: - '短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。', + prompt: '', status: 'idle', composerOpen: true, soundModel: 'audio1.0', @@ -246,8 +260,7 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport })) .toMatchObject({ mode: 'audio-background-music', - prompt: - '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。', + prompt: '', status: 'idle', composerOpen: true, makeInstrumental: true, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index c54fc5dd5..e08aad069 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -50,11 +50,6 @@ const VIDEO_REFERENCE_LIMITS = { video: 3, audio: 3, } as const; -const DEFAULT_SOUND_EFFECT_PROMPT = - '短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。'; -const DEFAULT_BACKGROUND_MUSIC_PROMPT = - '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。'; - function getViewportWorldCenter({ canvasSize, viewport, @@ -194,6 +189,10 @@ export function createGenerateDialogDraft({ }; } +function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) { + return specType === 'character' || specType === 'ui'; +} + export function createSpecDialogDraft({ canvasSize, viewport, @@ -210,7 +209,12 @@ export function createSpecDialogDraft({ status: 'idle', composerOpen: true, specType, - specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, + specValues: { + ...DEFAULT_SPEC_FORM_VALUES[specType], + ...(shouldUseSpecPlaceholderValues(specType) + ? { playSetting: '', artStyle: '' } + : {}), + }, placeholder: { x: worldCenter.x - SPEC_FRAME_ORIGINAL_SIZE.width / 2, y: worldCenter.y - SPEC_FRAME_ORIGINAL_SIZE.height / 2, @@ -274,11 +278,11 @@ export function createIconGenerationDialogDraft({ const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel); return { mode: 'icon', - prompt: formatIconDescriptionsText(DEFAULT_ICON_DESCRIPTIONS), + prompt: '', status: 'idle', composerOpen: true, iconSpecReference: null, - iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], + iconDescriptions: [], imageModel: normalizedImageModel, aspectRatio: dimensionDefaults.aspectRatio, imageSize: dimensionDefaults.imageSize, @@ -373,7 +377,7 @@ export function createSoundEffectGenerationDialogDraft({ const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); return { mode: 'audio-sound-effect', - prompt: DEFAULT_SOUND_EFFECT_PROMPT, + prompt: '', status: 'idle', composerOpen: true, soundModel: DEFAULT_SOUND_EFFECT_MODEL, @@ -399,7 +403,7 @@ export function createBackgroundMusicGenerationDialogDraft({ const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); return { mode: 'audio-background-music', - prompt: DEFAULT_BACKGROUND_MUSIC_PROMPT, + prompt: '', status: 'idle', composerOpen: true, makeInstrumental: true, diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index df6449cf4..2bafdbee3 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -103,6 +103,18 @@ describe('ImageCanvasIconSpritesheetComposerView', () => { expect(requestUpload).toHaveBeenCalledWith('icon-spec'); }); + it('uses a grey hint instead of default icon text for new icon drafts', () => { + render( + , + ); + + const input = screen.getByLabelText('素材描述') as HTMLTextAreaElement; + expect(input.value).toBe(''); + expect(input.placeholder).toBe('你需要哪些图标?'); + }); + it('keeps the spec reference as a single first-row card without extra source buttons', () => { render(); diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index 2f984da58..360ca7732 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -63,8 +63,7 @@ export function ImageCanvasIconSpritesheetComposerView({ onSubmit, }: ImageCanvasIconSpritesheetComposerViewProps) { const descriptionText = - dialog.prompt || - (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).join('\n'); + dialog.prompt || (dialog.iconDescriptions ?? []).join('\n'); useImageCanvasFloatingOptionDismiss({ isOpen: isIconSpecMenuOpen, boundaryRefs: [iconSpecButtonRef], @@ -176,7 +175,7 @@ export function ImageCanvasIconSpritesheetComposerView({ aria-label="素材描述" value={descriptionText} disabled={dialog.status === 'generating'} - placeholder="描述要生成的多个素材,每行一个。" + placeholder="你需要哪些图标?" size="sm" density="compact" className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless" diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 4fabeb456..0c348da35 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -115,6 +115,7 @@ function createSidebarProps( startRenamingAsset: vi.fn(), commitAssetRename: vi.fn(), deleteUploadedAsset: vi.fn(), + setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), toggleAllAssetsSelected: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 49bc454cc..89054f40a 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -96,6 +96,10 @@ export type ImageCanvasSidebarViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; @@ -160,6 +164,7 @@ export function ImageCanvasSidebarView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, @@ -313,6 +318,7 @@ export function ImageCanvasSidebarView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} toggleAllAssetsSelected={toggleAllAssetsSelected} diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index b4fb4bf76..0df94fd83 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -161,6 +161,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => { onUpdateSpecFormValue: updateSpecFormValue, }); + expect( + (screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder, + ).toBe('这是什么类型的游戏?'); + expect( + (screen.getByLabelText('美术风格') as HTMLInputElement).placeholder, + ).toBe('游戏的画风是怎样的?'); + fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '战棋玩法' }, }); @@ -183,6 +190,34 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ); }); + it('uses gameplay and art style hints for icon specs', () => { + renderPanel({ + dialog: createSpecDialog({ + specType: 'ui', + specValues: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + }), + }); + + expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( + '', + ); + expect( + (screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder, + ).toBe('这是什么类型的游戏?'); + expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( + '', + ); + expect( + (screen.getByLabelText('美术风格') as HTMLInputElement).placeholder, + ).toBe('游戏的画风是怎样的?'); + }); + it('renders custom prompt and hides reference upload for icon specs', () => { const updateSpecFormValue = vi.fn(); renderPanel({ @@ -199,6 +234,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => { onUpdateSpecFormValue: updateSpecFormValue, }); + const customPrompt = screen.getByLabelText( + '自定义规范提示词', + ) as HTMLTextAreaElement; + expect(customPrompt.placeholder).toBe( + '写下你想要的任何内容,陶泥儿帮你形成新的约束', + ); + fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: '新的规范提示' }, }); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 79158b7b2..fcb12a296 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -60,6 +60,10 @@ type ImageCanvasSpecGenerationPanelViewProps = { onSubmit: (dialog: GenerateDialogState) => void; }; +function shouldShowSpecInputPlaceholders(dialog: GenerateDialogState) { + return dialog.specType === 'character' || dialog.specType === 'ui'; +} + export function ImageCanvasSpecGenerationPanelView({ dialog, style, @@ -77,6 +81,7 @@ export function ImageCanvasSpecGenerationPanelView({ onSubmit, }: ImageCanvasSpecGenerationPanelViewProps) { const isUiDesignDialog = dialog.mode === 'ui-design'; + const showSpecInputPlaceholders = shouldShowSpecInputPlaceholders(dialog); const specSizeLabel = resolveEditorImageSizeLabel({ aspectRatio: SPEC_GENERATION_ASPECT_RATIO, imageSize: SPEC_GENERATION_IMAGE_SIZE, @@ -237,6 +242,7 @@ export function ImageCanvasSpecGenerationPanelView({ variant="textarea" aria-label="自定义规范提示词" value={dialog.specValues?.customPrompt ?? ''} + placeholder="写下你想要的任何内容,陶泥儿帮你形成新的约束" disabled={isGenerating} size="sm" density="compact" @@ -258,6 +264,11 @@ export function ImageCanvasSpecGenerationPanelView({ vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); +const updateEditorProjectResourceShowcaseMock = vi.hoisted(() => vi.fn()); const defaultOpenEditorLoginModal = () => {}; const defaultOnDeleteAssets = () => {}; @@ -31,6 +32,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { loadEditorAssetLibrary: loadEditorAssetLibraryMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, + updateEditorProjectResourceShowcase: updateEditorProjectResourceShowcaseMock, }; }); @@ -107,7 +109,12 @@ function AssetLibraryHarness({ {assetLibrary.assets - .map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`) + .map( + (asset) => + `${asset.id}:${asset.label}:${asset.folderId}:${ + asset.publicShowcaseEnabled ?? 'unset' + }`, + ) .join('|')} @@ -154,6 +161,19 @@ function AssetLibraryHarness({ > delete asset + @@ -186,6 +206,15 @@ describe('useImageCanvasAssetLibrary', () => { systemDefault: false, }); updateEditorAssetMock.mockResolvedValue(createAssetSnapshot()); + updateEditorProjectResourceShowcaseMock.mockResolvedValue({ + resourceId: 'resource-a', + projectId: 'project-1', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'generated', + publicShowcaseEnabled: false, + }); deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot()); deleteEditorAssetFolderMock.mockResolvedValue({ folders: [ @@ -236,7 +265,7 @@ describe('useImageCanvasAssetLibrary', () => { ); }); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-a:素材A:project', + 'asset-a:素材A:project:unset', ); expect(screen.getByTestId('active-upload-folder').textContent).toBe( 'project', @@ -342,7 +371,7 @@ describe('useImageCanvasAssetLibrary', () => { act(() => screen.getByRole('button', { name: 'move asset' }).click()); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-a:素材A:folder-role', + 'asset-a:素材A:folder-role:unset', ); expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', { folderId: 'folder-role', @@ -393,7 +422,7 @@ describe('useImageCanvasAssetLibrary', () => { act(() => screen.getByRole('button', { name: 'delete selected' }).click()); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-b:素材B:project', + 'asset-b:素材B:project:unset', ); expect(onDeleteAssets).toHaveBeenCalledWith([ expect.objectContaining({ id: 'asset-a' }), @@ -401,4 +430,112 @@ describe('useImageCanvasAssetLibrary', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b'); }); + + it('updates public showcase state through the linked project resource', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-a', + publicShowcaseEnabled: true, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project:false', + ); + expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith( + 'resource-a', + false, + ); + }); + + it('updates legacy generated asset showcase state when backend infers source resource id', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-inferred', + publicShowcaseEnabled: null, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + + expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith( + 'resource-inferred', + false, + ); + }); + + it('rolls back public showcase state when update fails', async () => { + updateEditorProjectResourceShowcaseMock.mockRejectedValueOnce( + new Error('network'), + ); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-a', + publicShowcaseEnabled: true, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + await waitFor(() => { + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project:true', + ); + }); + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 38eb96380..29b76d57b 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -17,6 +17,7 @@ import { loadEditorAssetLibrary, updateEditorAsset, updateEditorAssetFolder, + updateEditorProjectResourceShowcase, } from '../../services/image-editor/editorProjectClient'; import { EDITOR_ASSET_FOLDERS, @@ -320,6 +321,38 @@ export function useImageCanvasAssetLibrary({ [onDeleteAssets], ); + const setAssetPublicShowcaseEnabled = useCallback( + (asset: EditorAsset, publicShowcaseEnabled: boolean) => { + if (!asset.sourceResourceId) { + return; + } + const previousValue = asset.publicShowcaseEnabled ?? true; + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { ...currentAsset, publicShowcaseEnabled } + : currentAsset, + ), + ); + updateEditorProjectResourceShowcase( + asset.sourceResourceId, + publicShowcaseEnabled, + ).catch(() => { + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { + ...currentAsset, + publicShowcaseEnabled: previousValue, + } + : currentAsset, + ), + ); + }); + }, + [], + ); + const startRenamingFolder = useCallback((folder: EditorAssetFolder) => { setRenamingFolder({ folderId: folder.id, @@ -558,6 +591,7 @@ export function useImageCanvasAssetLibrary({ toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, startRenamingFolder, commitFolderRename, deleteAssetFolder, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 63754d279..5946e8727 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -15437,11 +15437,6 @@ export function PlatformEntryFlowShellImpl({ }> { - platformBootstrap.setPlatformTab('home'); - setSelectionStage('platform'); - pushAppHistoryPath('/'); - }} /> ); diff --git a/src/components/project/ProjectGalleryView.test.tsx b/src/components/project/ProjectGalleryView.test.tsx index 58ac64918..c89259eab 100644 --- a/src/components/project/ProjectGalleryView.test.tsx +++ b/src/components/project/ProjectGalleryView.test.tsx @@ -78,7 +78,6 @@ function renderProjectGalleryView( return render( , ); @@ -105,19 +104,6 @@ describe('ProjectGalleryView', () => { expect(onOpenProject).toHaveBeenCalledWith('editor-project-1'); }); - it('returns to the main site from the project header', async () => { - const onReturnToMainSite = vi.fn(); - listEditorProjectsMock.mockResolvedValueOnce(projectItems); - const user = userEvent.setup(); - - renderProjectGalleryView({ onReturnToMainSite }); - - await screen.findByText('角色设定板'); - await user.click(screen.getByRole('button', { name: '返回主站' })); - - expect(onReturnToMainSite).toHaveBeenCalledTimes(1); - }); - it('uses canvas-center layer composition as the project cover', async () => { listEditorProjectsMock.mockResolvedValueOnce([ { @@ -202,7 +188,6 @@ describe('ProjectGalleryView', () => { , ); @@ -224,7 +209,6 @@ describe('ProjectGalleryView', () => { , ); diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx index 1a29fdf55..f1792545c 100644 --- a/src/components/project/ProjectGalleryView.tsx +++ b/src/components/project/ProjectGalleryView.tsx @@ -1,5 +1,4 @@ import { - ArrowLeft, Check, CheckSquare, MoreHorizontal, @@ -35,7 +34,6 @@ import { ProjectCanvasCover } from './ProjectCanvasCover'; type ProjectGalleryViewProps = { onOpenProject: (projectId: string) => void; - onReturnToMainSite: () => void; }; type RenameDraft = { @@ -62,7 +60,6 @@ function formatProjectUpdatedAt(value: string) { export function ProjectGalleryView({ onOpenProject, - onReturnToMainSite, }: ProjectGalleryViewProps) { const authUi = useAuthUi(); const [projects, setProjects] = useState([]); @@ -308,14 +305,6 @@ export function ProjectGalleryView({ {projects.length} 个画布项目
- - - 返回主站 - { ); }); -test('project page header can return to main site', async () => { - const user = userEvent.setup(); - mockDesktopPlatformLayout(); - window.history.replaceState(null, '', '/project'); - render(); - - expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '返回主站' })); - - await waitFor(() => { - expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); - }); - expect(window.location.pathname).toBe('/'); -}); - test('direct mobile creation home guides users to desktop without mobile nav entry', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/creation'); diff --git a/src/index.css b/src/index.css index a2310fe43..ef88a31ee 100644 --- a/src/index.css +++ b/src/index.css @@ -7667,10 +7667,18 @@ button.image-canvas-editor__reference-chip:disabled { min-height: 1.96rem; } +.image-canvas-editor__spec-input::placeholder { + color: #94a3b8; +} + .image-canvas-editor__spec-textarea { min-height: 3.8rem; } +.image-canvas-editor__spec-textarea::placeholder { + color: #94a3b8; +} + .image-canvas-editor__spec-textarea--view { min-height: 3.1rem; } @@ -7735,6 +7743,17 @@ button.image-canvas-editor__reference-chip:disabled { color: #a3acba; } +.image-canvas-editor__asset-context-menu { + z-index: 42; +} + +.image-canvas-editor__asset-context-menu button span[aria-hidden='true'] { + display: inline-flex; + width: 1rem; + flex: 0 0 1rem; + justify-content: center; +} + .image-canvas-editor__context-menu hr { width: 100%; height: 1px; diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 230cdfc5c..976d8dd1a 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -362,6 +362,62 @@ describe('assetReadUrlService', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); + test('getSignedAssetReadUrl throttles distinct signed url bursts', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input) => { + const url = new URL( + String(input), + globalThis.location?.origin ?? 'https://www.genarrative.world', + ); + const objectKey = url.searchParams.get('objectKey') ?? 'missing.png'; + return new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey, + signedUrl: `https://signed.example.com/${encodeURIComponent(objectKey)}`, + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }, + ); + + const requests = Array.from({ length: 28 }, (_, index) => + getSignedAssetReadUrl({ + objectKey: `generated-character-drafts/editor/ui-design-assets/result/asset-${String(index + 1).padStart(3, '0')}.png`, + }), + ); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchSpy).toHaveBeenCalledTimes(24); + + await vi.advanceTimersByTimeAsync(16); + expect(fetchSpy).toHaveBeenCalledTimes(25); + + await vi.advanceTimersByTimeAsync(48); + expect(fetchSpy).toHaveBeenCalledTimes(28); + await expect(Promise.all(requests)).resolves.toHaveLength(28); + }); + test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 6f44fb624..1507a35aa 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -61,9 +61,13 @@ const ASSET_READ_URL_BACKGROUND_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions; const SIGNED_READ_URL_SESSION_CACHE_PREFIX = 'genarrative.assetReadUrlCache.v1:'; +const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24; +const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16; const signedReadUrlCache = new Map(); const signedReadUrlFailureCache = new Map(); const pendingSignedReadUrlRequests = new Map>(); +let signedReadUrlDispatchBurstUsed = 0; +let signedReadUrlNextDispatchAtMs = 0; export function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); @@ -261,6 +265,64 @@ function writeSignedUrlSessionCache( } } +function resetSignedReadUrlDispatchLimiter() { + signedReadUrlDispatchBurstUsed = 0; + signedReadUrlNextDispatchAtMs = 0; +} + +function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) { + if (signedReadUrlNextDispatchAtMs < nowMs) { + signedReadUrlDispatchBurstUsed = 0; + signedReadUrlNextDispatchAtMs = nowMs; + } + + if ( + signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST + ) { + signedReadUrlDispatchBurstUsed += 1; + return 0; + } + + const dispatchAtMs = Math.max( + signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, + nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, + ); + signedReadUrlNextDispatchAtMs = dispatchAtMs; + return Math.max(0, dispatchAtMs - nowMs); +} + +function createSignedReadUrlAbortError() { + if (typeof DOMException === 'function') { + return new DOMException('The operation was aborted.', 'AbortError'); + } + return new Error('The operation was aborted.'); +} + +async function waitForSignedReadUrlDispatch(signal?: AbortSignal) { + if (signal?.aborted) { + throw createSignedReadUrlAbortError(); + } + + const delayMs = reserveSignedReadUrlDispatchDelayMs(); + if (delayMs <= 0) { + return; + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort); + resolve(); + }, delayMs); + + function handleAbort() { + clearTimeout(timer); + reject(createSignedReadUrlAbortError()); + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + }); +} + function clearSignedUrlSessionCache() { const storage = getSignedReadUrlSessionStorage(); if (!storage) { @@ -323,6 +385,7 @@ export async function getSignedAssetReadUrl( const searchParams = buildAssetReadSearchParams(request); try { + await waitForSignedReadUrlDispatch(signal); const response = await requestJson( `${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`, { @@ -518,5 +581,6 @@ export function clearSignedAssetReadUrlCache() { signedReadUrlCache.clear(); signedReadUrlFailureCache.clear(); pendingSignedReadUrlRequests.clear(); + resetSignedReadUrlDispatchLimiter(); clearSignedUrlSessionCache(); } diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index d27b06db2..449a9b34a 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -17,6 +17,7 @@ import { generateEditorSoundEffect, generateEditorVideo, listEditorProjects, + listPublicEditorProjectResources, loadEditorGenerationPricing, loadEditorAssetLibrary, loadEditorProject, @@ -25,6 +26,7 @@ import { saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, + updateEditorProjectResourceShowcase, } from './editorProjectClient'; const requestJsonMock = vi.hoisted(() => vi.fn()); @@ -212,6 +214,39 @@ describe('editorProjectClient', () => { ); }); + it('loads public showcase project resources without requiring auth', async () => { + requestJsonMock.mockResolvedValueOnce({ + resources: [ + { + resourceId: 'resource-public-1', + projectId: 'editor-project-1', + label: '公开角色素材', + imageSrc: '/generated-editor-assets/public.png', + width: 512, + height: 512, + sourceType: 'generated', + publicShowcaseEnabled: true, + }, + ], + }); + + const resources = await listPublicEditorProjectResources(); + + expect(resources).toHaveLength(1); + expect(resources[0]?.resourceId).toBe('resource-public-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/showcase/resources', + { method: 'GET' }, + '读取陶泥儿精选素材失败', + { + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, + ); + }); + it('loads an explicit project by id', async () => { requestJsonMock.mockResolvedValueOnce({ project: { @@ -341,6 +376,38 @@ describe('editorProjectClient', () => { ); }); + it('updates project resource showcase visibility', async () => { + requestJsonMock.mockResolvedValueOnce({ + resource: { + resourceId: 'resource-generated-1', + projectId: 'editor-project-1', + label: '角色素材', + imageSrc: '/generated-editor-assets/project/image.png', + objectKey: 'generated-editor-assets/project/image.png', + width: 2048, + height: 2048, + sourceType: 'generated', + publicShowcaseEnabled: false, + }, + }); + + const resource = await updateEditorProjectResourceShowcase( + 'resource-generated-1', + false, + ); + + expect(resource.publicShowcaseEnabled).toBe(false); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/project-resources/resource-generated-1/showcase', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicShowcaseEnabled: false }), + }), + '更新素材公开展示状态失败', + ); + }); + it('loads and mutates the account-level asset library', async () => { requestJsonMock .mockResolvedValueOnce({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 88fd675e2..b0ce14151 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -4,6 +4,8 @@ import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; const EDITOR_ASSET_API_BASE = '/api/editor/assets'; +const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources'; +const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources'; const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations'; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_ICON_SPRITESHEET_GENERATION_API = @@ -72,6 +74,7 @@ export type EditorProjectResourceSnapshot = { sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; + publicShowcaseEnabled?: boolean; createdAt?: string; updatedAt?: string; }; @@ -105,6 +108,7 @@ export type EditorAssetSnapshot = { sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; + publicShowcaseEnabled?: boolean | null; createdAt?: string; updatedAt?: string; }; @@ -477,6 +481,10 @@ type EditorProjectResourceResponse = { resource: EditorProjectResourceSnapshot; }; +type EditorProjectResourceListResponse = { + resources: EditorProjectResourceSnapshot[]; +}; + type EditorAssetLibraryResponse = { library: EditorAssetLibrarySnapshot; }; @@ -516,6 +524,21 @@ export async function listEditorProjects() { return response.projects; } +export async function listPublicEditorProjectResources() { + const response = await requestJson( + EDITOR_SHOWCASE_RESOURCE_API, + { method: 'GET' }, + '读取陶泥儿精选素材失败', + { + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, + ); + return response.resources; +} + export async function loadRecentEditorProject() { return requestJson( `${EDITOR_PROJECT_API_BASE}/recent`, @@ -612,6 +635,18 @@ export async function createEditorProjectResource( return response.resource; } +export async function updateEditorProjectResourceShowcase( + resourceId: string, + publicShowcaseEnabled: boolean, +) { + const response = await requestJson( + `${EDITOR_PROJECT_RESOURCE_API_BASE}/${encodeURIComponent(resourceId)}/showcase`, + jsonRequest('PATCH', { publicShowcaseEnabled }), + '更新素材公开展示状态失败', + ); + return response.resource; +} + export async function loadEditorAssetLibrary() { const response = await requestJson( `${EDITOR_ASSET_API_BASE}/library`,