From b6c5fdeb309a4dd60b2d81a0e449f05dfb341419 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 24 Jun 2026 19:30:16 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=8F=90=E7=A4=BA=E4=B8=8E=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成完成提示展示后后台确认,避免刷新重复弹出 项目封面改为保存并读取静态快照资源 补充封面快照上传、选择和展示测试 同步外部生成与图片画布契约文档 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + ...端架构】外部生成Worker化方案-2026-06-03.md | 6 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../creation-home/CreationLandingView.tsx | 64 +++-- ...ageCanvasProjectCoverSnapshotModel.test.ts | 81 ++++++ .../ImageCanvasProjectCoverSnapshotModel.ts | 198 ++++++++++++++ .../useImageCanvasProjectPersistence.test.tsx | 105 +++++++- .../useImageCanvasProjectPersistence.ts | 117 +++++++- .../PlatformEntryFlowShellImpl.tsx | 152 ++++++----- .../project/ProjectCanvasCover.test.ts | 81 ++++++ src/components/project/ProjectCanvasCover.tsx | 255 +++--------------- .../project/ProjectGalleryView.test.tsx | 30 ++- src/components/project/ProjectGalleryView.tsx | 17 +- .../editorMediaAssetUploadClient.test.ts | 80 ++++++ .../editorMediaAssetUploadClient.ts | 20 +- 16 files changed, 877 insertions(+), 342 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts create mode 100644 src/components/project/ProjectCanvasCover.test.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 71cf20114..725ea49da 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -80,6 +80,14 @@ - 验证方式:运行角色动作图层工厂、画布展示、画布持久化、生成提交和素材导出相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +## 2026-06-24 图片画布项目封面使用静态快照资源 + +- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。 +- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/project/ProjectGalleryView.tsx`、`src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。 +- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-21 图片画布生成完成态由后端写入画布布局 - 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 87ae87f6f..baee4e762 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -46,6 +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 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 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`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6ce4f841d..3cc32e059 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -39,10 +39,10 @@ - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。 -- `POST /api/runtime/external-generation/jobs/acknowledge`:用户关闭生成完成 / 失败提示时调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 +- `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 -BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端关闭提示只能调用 acknowledge 接口,不能本地永久吞掉任务。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 ## 任务表 @@ -214,7 +214,7 @@ curl -f http://127.0.0.1:/healthz 本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。 -生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、acknowledge 后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index cb5e2c70e..848cb1884 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -245,7 +245,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示关闭时必须调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示展示后后台调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,关闭按钮只收起本地弹窗,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 ### `external_generation_job_event` @@ -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` 和 `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` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 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 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `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` diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 096b07439..a5a9a486f 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,15 +1,15 @@ import { Film, Image as ImageIcon, Music2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { ApiClientError } from '../../services/apiClient'; import { createEditorProject, - listPublicEditorProjectResources, - listEditorProjects, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, + listEditorProjects, + listPublicEditorProjectResources, } from '../../services/image-editor/editorProjectClient'; -import { ApiClientError } from '../../services/apiClient'; -import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; @@ -17,7 +17,7 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { UnifiedModal } from '../common/UnifiedModal'; -import { ProjectCanvasCover } from '../project/ProjectCanvasCover'; +import { resolveProjectCoverSnapshotResource } from '../project/ProjectCanvasCover'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetVideo } from '../ResolvedAssetVideo'; import { @@ -644,28 +644,38 @@ export function CreationLandingView({ 正在读取项目 ) : ( - recentProjects.map((project) => ( - - )) + recentProjects.map((project) => { + const coverSnapshot = + resolveProjectCoverSnapshotResource(project); + return ( + + ); + }) )} diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts new file mode 100644 index 000000000..063be7378 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { + buildProjectCoverSnapshotSignature, + resolveProjectCoverDrawableLayers, +} from './ImageCanvasProjectCoverSnapshotModel'; + +function layer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-a', + resourceId: 'resource-a', + title: '图层', + src: '/generated/layer-a.png', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 2, + sourceType: 'generated', + ...overrides, + }; +} + +describe('ImageCanvasProjectCoverSnapshotModel', () => { + it('builds a stable cover signature from visible drawable layers', () => { + const signature = buildProjectCoverSnapshotSignature({ + viewport: { x: 12, y: -8, scale: 0.75 }, + layers: [ + layer({ id: 'hidden', hidden: true }), + layer({ + id: 'audio', + mediaType: 'audio', + src: '/generated/audio.mp3', + }), + layer({ + id: 'front', + objectKey: 'generated/front.png', + zIndex: 5, + }), + layer({ + id: 'back', + src: '/generated/back.png', + zIndex: 1, + }), + ], + }); + + expect(signature).toContain('"scale":0.75'); + expect(signature).toContain('"id":"back"'); + expect(signature).toContain('"id":"front"'); + expect(signature).not.toContain('"id":"hidden"'); + expect(signature).not.toContain('"id":"audio"'); + }); + + it('uses the first image-sequence frame as the drawable source', () => { + expect( + resolveProjectCoverDrawableLayers([ + layer({ + mediaType: 'image-sequence', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated/frame-1.png', + objectKey: 'generated/frame-1.png', + width: 320, + height: 240, + }, + ], + }), + ]), + ).toMatchObject([ + { + src: '/generated/frame-1.png', + objectKey: 'generated/frame-1.png', + }, + ]); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts new file mode 100644 index 000000000..c56250b31 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -0,0 +1,198 @@ +import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; + +export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot'; +export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND = + 'editor_project_cover_snapshot'; +export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const; +export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = { + width: 900, + height: 640, +} as const; + +type ProjectCoverDrawableLayer = { + id: string; + src: string; + objectKey: string | null; + x: number; + y: number; + width: number; + height: number; + zIndex: number; +}; + +function numberOrFallback(value: number, fallback: number) { + return Number.isFinite(value) ? value : fallback; +} + +function normalizeObjectKey(value: string | null | undefined) { + const normalized = value?.trim().replace(/^\/+/u, '') ?? ''; + return normalized || null; +} + +function resolveLayerSnapshotSource(layer: CanvasLayer) { + if (layer.hidden || layer.mediaType === 'audio') { + return null; + } + if (layer.mediaType === 'image-sequence') { + const frame = layer.imageSequenceFrames?.[0]; + const frameSrc = frame?.imageSrc?.trim(); + if (frameSrc) { + return { + src: frameSrc, + objectKey: normalizeObjectKey(frame?.objectKey), + }; + } + } + const snapshotSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); + if (!snapshotSrc) { + return null; + } + return { + src: snapshotSrc, + objectKey: layer.thumbnailSrc?.trim() + ? null + : normalizeObjectKey(layer.objectKey), + }; +} + +export function resolveProjectCoverDrawableLayers( + layers: readonly CanvasLayer[], +): ProjectCoverDrawableLayer[] { + return layers + .map((layer) => { + const source = resolveLayerSnapshotSource(layer); + if (!source) { + return null; + } + return { + id: layer.id, + src: source.src, + objectKey: source.objectKey, + x: numberOrFallback(layer.x, 0), + y: numberOrFallback(layer.y, 0), + width: Math.max(1, numberOrFallback(layer.width, layer.originalWidth)), + height: Math.max(1, numberOrFallback(layer.height, layer.originalHeight)), + zIndex: numberOrFallback(layer.zIndex, 0), + } satisfies ProjectCoverDrawableLayer; + }) + .filter((layer): layer is ProjectCoverDrawableLayer => Boolean(layer)) + .sort((left, right) => left.zIndex - right.zIndex); +} + +export function buildProjectCoverSnapshotSignature({ + layers, + viewport, +}: { + layers: readonly CanvasLayer[]; + viewport: CanvasViewport; +}) { + const coverLayers = resolveProjectCoverDrawableLayers(layers); + if (coverLayers.length === 0) { + return null; + } + return JSON.stringify({ + viewport: { + x: numberOrFallback(viewport.x, 0), + y: numberOrFallback(viewport.y, 0), + scale: numberOrFallback(viewport.scale, 1), + }, + layers: coverLayers, + }); +} + +function resolveProjectCoverPlacement( + viewport: CanvasViewport, + layer: ProjectCoverDrawableLayer, +) { + const safeScale = + viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; + const viewportCenterX = + (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale; + const viewportCenterY = + (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale; + const worldPreviewWidth = + PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale; + const worldPreviewHeight = + PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale; + const previewMinX = viewportCenterX - worldPreviewWidth / 2; + const previewMinY = viewportCenterY - worldPreviewHeight / 2; + const scale = Math.min( + PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth, + PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight, + ); + const offsetX = -previewMinX * scale; + const offsetY = -previewMinY * scale; + return { + x: offsetX + layer.x * scale, + y: offsetY + layer.y * scale, + width: layer.width * scale, + height: layer.height * scale, + }; +} + +async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { + const src = layer.objectKey + ? await getSignedAssetReadUrl({ objectKey: layer.objectKey }) + : layer.src; + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('project cover image load failed')); + image.src = src; + }); +} + +function canvasToBlob(canvas: HTMLCanvasElement) { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png'); + }); +} + +export async function createProjectCoverSnapshotBlob({ + layers, + viewport, +}: { + layers: readonly CanvasLayer[]; + viewport: CanvasViewport; +}) { + if (typeof document === 'undefined') { + return null; + } + const coverLayers = resolveProjectCoverDrawableLayers(layers); + if (coverLayers.length === 0) { + return null; + } + const canvas = document.createElement('canvas'); + canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; + canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; + const context = canvas.getContext('2d'); + if (!context || typeof canvas.toBlob !== 'function') { + return null; + } + context.clearRect(0, 0, canvas.width, canvas.height); + + let drawnLayerCount = 0; + for (const layer of coverLayers) { + try { + const image = await loadProjectCoverImage(layer); + const placement = resolveProjectCoverPlacement(viewport, layer); + context.drawImage( + image, + placement.x, + placement.y, + placement.width, + placement.height, + ); + drawnLayerCount += 1; + } catch { + // Individual layer load failures should not block a cover from other layers. + } + } + + if (drawnLayerCount === 0) { + return null; + } + return canvasToBlob(canvas); +} diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index dbc56dd03..cbcc16b40 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -4,21 +4,33 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; -import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = 'genarrative.imageCanvas.projectSnapshot.v1:recent'; +vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => { + const actual = await vi.importActual< + typeof import('./ImageCanvasProjectCoverSnapshotModel') + >('./ImageCanvasProjectCoverSnapshotModel'); + return { + ...actual, + createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock, + }; +}); + vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') @@ -32,6 +44,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); +vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, +})); + function createLayer( id: string, overrides: Partial = {}, @@ -373,6 +389,15 @@ describe('useImageCanvasProjectPersistence', () => { resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); + createProjectCoverSnapshotBlobMock.mockResolvedValue(null); + uploadEditorMediaAssetFileMock.mockResolvedValue({ + src: 'https://signed.example.test/project-cover.png', + objectKey: + 'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', + assetObjectId: 'asset-object-cover', + legacyPublicPath: + '/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', + }); }); it('saves appended layers with the server resource id immediately after resource creation', async () => { @@ -454,6 +479,84 @@ describe('useImageCanvasProjectPersistence', () => { ]); }); + it('persists a static project cover snapshot resource after loading drawable layers', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '已有封面画布', + viewport: { x: 12, y: -8, scale: 1 }, + layers: [ + { + layerId: 'layer-cover', + resourceId: 'resource-cover-source', + title: '封面源图', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + }, + ], + resources: [ + { + resourceId: 'resource-cover-source', + projectId: 'editor-project-default', + imageSrc: '/generated/source.png', + objectKey: 'generated/source.png', + assetObjectId: 'asset-object-source', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + createProjectCoverSnapshotBlobMock.mockResolvedValueOnce( + new Blob(['cover'], { type: 'image/png' }), + ); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect.any(File), + 'image', + expect.objectContaining({ + assetKind: 'editor_project_cover_snapshot', + pathSegments: [ + 'editor', + 'project-covers', + 'editor-project-default', + expect.any(String), + ], + entityId: 'editor-project-cover-editor-project-default', + metadata: { + editor_project_id: 'editor-project-default', + }, + }), + ); + }); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + imageSrc: + '/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', + objectKey: + 'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', + assetObjectId: 'asset-object-cover', + width: 320, + height: 240, + sourceType: 'uploaded', + assetKind: 'project-cover-snapshot', + }), + ); + }); + }); + it('loads and saves viewport scale with display zoom semantics', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index d4bc1b2e5..86a967509 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -1,19 +1,20 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { ApiClientError } from '../../services/apiClient'; +import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; import { createEditorProjectResource, + type EditorProjectResourceSnapshot, + type EditorProjectSnapshot, loadEditorProject, loadOrCreateRecentEditorProject, saveEditorProjectLayout, - type EditorProjectResourceSnapshot, - type EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { canvasDisplayViewportToViewport, - isInlineEditorMediaSource, type CanvasLayerResourceMetadata, hydrateLayer, + isInlineEditorMediaSource, serializeCanvasLayout, splitCanvasLayoutItems, viewportToCanvasDisplayViewport, @@ -23,6 +24,13 @@ import type { CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; +import { + buildProjectCoverSnapshotSignature, + createProjectCoverSnapshotBlob, + PROJECT_COVER_SNAPSHOT_ASSET_KIND, + PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, + PROJECT_COVER_SNAPSHOT_SIZE, +} from './ImageCanvasProjectCoverSnapshotModel'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -263,6 +271,8 @@ export function useImageCanvasProjectPersistence({ const skipNextProjectLayoutSaveRef = useRef(false); const saveTimerRef = useRef(null); const projectTitleRef = useRef('未命名画布'); + const coverSnapshotSignatureRef = useRef(null); + const coverSnapshotUploadRequestRef = useRef(0); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const { @@ -316,6 +326,85 @@ export function useImageCanvasProjectPersistence({ [openEditorLoginModal], ); + const persistProjectCoverSnapshot = useCallback( + ( + nextProjectId: string, + coverViewport: CanvasViewport, + coverLayers: readonly CanvasLayer[], + ) => { + if (!canAccessProtectedData) { + return; + } + const signature = buildProjectCoverSnapshotSignature({ + layers: coverLayers, + viewport: coverViewport, + }); + if (!signature || coverSnapshotSignatureRef.current === signature) { + return; + } + coverSnapshotSignatureRef.current = signature; + coverSnapshotUploadRequestRef.current += 1; + const requestId = coverSnapshotUploadRequestRef.current; + + void createProjectCoverSnapshotBlob({ + layers: coverLayers, + viewport: coverViewport, + }) + .then(async (blob) => { + if (!blob) { + if (coverSnapshotSignatureRef.current === signature) { + coverSnapshotSignatureRef.current = null; + } + return; + } + if (coverSnapshotUploadRequestRef.current !== requestId) { + return; + } + const coverFile = new File([blob], `${nextProjectId}-cover.png`, { + type: 'image/png', + }); + const uploadedCover = await uploadEditorMediaAssetFile( + coverFile, + 'image', + { + assetKind: PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, + pathSegments: [ + 'editor', + 'project-covers', + nextProjectId, + `${Date.now()}`, + ], + entityId: `editor-project-cover-${nextProjectId}`, + metadata: { + editor_project_id: nextProjectId, + }, + }, + ); + if (coverSnapshotUploadRequestRef.current !== requestId) { + return; + } + await createEditorProjectResource(nextProjectId, { + imageSrc: uploadedCover.legacyPublicPath, + objectKey: uploadedCover.objectKey, + assetObjectId: uploadedCover.assetObjectId, + width: PROJECT_COVER_SNAPSHOT_SIZE.width, + height: PROJECT_COVER_SNAPSHOT_SIZE.height, + sourceType: 'uploaded', + assetKind: PROJECT_COVER_SNAPSHOT_ASSET_KIND, + }); + }) + .catch((error: unknown) => { + if (coverSnapshotSignatureRef.current === signature) { + coverSnapshotSignatureRef.current = null; + } + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + }, + [canAccessProtectedData, openEditorLoginModal], + ); + const queueProjectLayoutSave = useCallback( ( nextProjectId: string, @@ -345,14 +434,24 @@ export function useImageCanvasProjectPersistence({ if (delayMs > 0) { saveTimerRef.current = window.setTimeout(() => { saveTimerRef.current = null; + persistProjectCoverSnapshot( + nextProjectId, + input.viewport, + refs.layersRef.current, + ); runPendingProjectLayoutSave(); }, delayMs); return; } + persistProjectCoverSnapshot( + nextProjectId, + input.viewport, + refs.layersRef.current, + ); runPendingProjectLayoutSave(); }, - [runPendingProjectLayoutSave], + [persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave], ); const createProjectResourceForLayer = useCallback( @@ -433,6 +532,10 @@ export function useImageCanvasProjectPersistence({ (project: EditorProjectSnapshot) => { clearPendingProjectLayoutSave(); skipNextProjectLayoutSaveRef.current = true; + if (projectIdRef.current !== project.projectId) { + coverSnapshotSignatureRef.current = null; + coverSnapshotUploadRequestRef.current += 1; + } projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; @@ -572,6 +675,11 @@ export function useImageCanvasProjectPersistence({ } if (skipNextProjectLayoutSaveRef.current) { skipNextProjectLayoutSaveRef.current = false; + persistProjectCoverSnapshot( + projectId, + viewportToCanvasDisplayViewport(viewport), + layers, + ); return undefined; } queueProjectLayoutSave( @@ -596,6 +704,7 @@ export function useImageCanvasProjectPersistence({ isProjectReady, canvasGenerationDialogs, layers, + persistProjectCoverSnapshot, projectId, queueProjectLayoutSave, refs, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 5946e8727..34d2e3815 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,5 +1,4 @@ -import { Home, Loader2, Monitor } from 'lucide-react'; - +import { Home, Monitor } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { type Dispatch, @@ -168,8 +167,8 @@ import { fetchCreationEntryConfig, } from '../../services/creationEntryConfigService'; import { - type CreationUrlState, clearCreationUrlState, + type CreationUrlState, readCreationUrlState, writeCreationUrlState, } from '../../services/creationUrlState'; @@ -197,6 +196,7 @@ import { acknowledgeExternalGenerationTasks, listExternalGenerationTasks, } from '../../services/external-generation'; +import { navigateHostNativePage } from '../../services/host-bridge/hostBridge'; import { jumpHopClient, type JumpHopGalleryCardResponse, @@ -322,7 +322,6 @@ import { recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import { navigateHostNativePage } from '../../services/host-bridge/hostBridge'; import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; import { type RuntimeGuestRequestOptions } from '../../services/runtimeGuestAuth'; @@ -372,9 +371,9 @@ import { } from '../../services/wooden-fish/woodenFishClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; +import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; import { PlatformActionButton } from '../common/PlatformActionButton'; -import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; @@ -490,6 +489,7 @@ import { buildPendingSquareHoleWorks, buildPendingVisualNovelWorks, buildPendingWoodenFishWorks, + buildPuzzleDraftOpenNoticeKeys, collectDraftNoticeKeys, collectVisibleDraftNoticeKeys, countUnreadDraftGenerationUpdates, @@ -504,7 +504,6 @@ import { type PendingDraftShelfKind, type PendingDraftShelfMap, type PendingDraftShelfMetadata, - buildPuzzleDraftOpenNoticeKeys, resolveBigFishDraftOpenIntent, resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, @@ -534,11 +533,6 @@ import { PlatformEntryHomeView, type PlatformHomeTab, } from './PlatformEntryHomeView'; -import { - PlatformMobileHomeWelcomeDialog, - shouldOpenMobileHomeWelcomeDialog, -} from './PlatformMobileHomeWelcomeDialog'; -import { PlatformProfileReferralModal } from './PlatformProfileReferralModal'; import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { buildCreationHubFallbackItems, @@ -552,6 +546,10 @@ import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformErrorDialog } from './PlatformErrorDialog'; import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel'; import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; +import { + sendPlatformHostDraftNotificationToHost, + syncPlatformHostDraftBadgeCountToHost, +} from './platformHostBridgeSync'; import { buildPlatformHostDraftFailureNotification, buildPlatformHostDraftNotificationResetKeys, @@ -559,10 +557,6 @@ import { type PlatformHostDraftNotification, sendPlatformHostDraftNotificationOnce, } from './platformHostNotificationModel'; -import { - sendPlatformHostDraftNotificationToHost, - syncPlatformHostDraftBadgeCountToHost, -} from './platformHostBridgeSync'; import { buildMatch3DProfileFromSession, hasMatch3DRuntimeAsset, @@ -616,7 +610,12 @@ import { buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; +import { + PlatformMobileHomeWelcomeDialog, + shouldOpenMobileHomeWelcomeDialog, +} from './PlatformMobileHomeWelcomeDialog'; import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel'; +import { PlatformProfileReferralModal } from './PlatformProfileReferralModal'; import { adjustProfileDashboardWalletBalance, reconcileProfileWalletLocalDeltaWithServerDashboard, @@ -709,20 +708,6 @@ type PuzzleBackgroundCompileTask = { error: string | null; }; -function isExternalGenerationQueueStage(selectionStage: SelectionStage) { - return ( - selectionStage === 'puzzle-generating' || - selectionStage === 'big-fish-generating' || - selectionStage === 'square-hole-generating' || - selectionStage === 'match3d-generating' || - selectionStage === 'baby-object-match-generating' || - selectionStage === 'jump-hop-generating' || - selectionStage === 'puzzle-clear-generating' || - selectionStage === 'wooden-fish-generating' || - selectionStage === 'visual-novel-generating' - ); -} - function isUnacknowledgedExternalGenerationTerminalTask( task: ExternalGenerationTaskRecord, ) { @@ -2158,6 +2143,9 @@ export function PlatformEntryFlowShellImpl({ const sentPlatformHostDraftNotificationKeysRef = useRef>( new Set(), ); + const acknowledgingExternalGenerationDialogKeysRef = useRef>( + new Set(), + ); const [ pendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog, @@ -4975,11 +4963,6 @@ export function PlatformEntryFlowShellImpl({ isWoodenFishBusy, woodenFishGenerationState, ); - const shouldShowExternalGenerationQueueStatus = - isExternalGenerationQueueStage(selectionStage) || - (selectionStage === 'platform' && - platformBootstrap.platformTab === 'profile' && - platformBootstrap.canReadProtectedData); const isHostNetworkOnline = useHostNetworkOnline(); const shouldPollExternalGenerationTasks = platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id); @@ -4989,6 +4972,7 @@ export function PlatformEntryFlowShellImpl({ : null; useEffect(() => { + acknowledgingExternalGenerationDialogKeysRef.current.clear(); setExternalGenerationTasks([]); setExternalGenerationTaskOverview(null); }, [externalGenerationQueueOwnerKey]); @@ -5340,6 +5324,68 @@ export function PlatformEntryFlowShellImpl({ dismissedPlatformErrorDialogKey, buildPlatformErrorDialogDismissKey, ); + const acknowledgeExternalGenerationCompletionDialog = useCallback( + (completion: PlatformTaskCompletionDialogState | null) => { + const taskJobIds = completion?.taskJobIds ?? []; + if (taskJobIds.length === 0) { + return; + } + + const dismissKey = + buildPlatformTaskCompletionDialogDismissKey(completion); + const acknowledgingKeys = + acknowledgingExternalGenerationDialogKeysRef.current; + if (dismissKey && acknowledgingKeys.has(dismissKey)) { + return; + } + if (dismissKey) { + acknowledgingKeys.add(dismissKey); + } + + const acknowledgedJobIds = new Set(taskJobIds); + setExternalGenerationTasks((current) => + current.filter((task) => !acknowledgedJobIds.has(task.jobId)), + ); + setExternalGenerationTaskOverview((current) => + current + ? { + ...current, + unacknowledgedTerminalCount: Math.max( + 0, + current.unacknowledgedTerminalCount - acknowledgedJobIds.size, + ), + } + : current, + ); + void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds }) + .then((response) => { + setExternalGenerationTasks((current) => + current.filter( + (task) => + !response.acknowledgedTasks.some( + (acknowledgedTask) => acknowledgedTask.jobId === task.jobId, + ), + ), + ); + }) + .catch(() => { + if (dismissKey) { + acknowledgingKeys.delete(dismissKey); + } + }); + }, + [], + ); + + useEffect(() => { + acknowledgeExternalGenerationCompletionDialog( + activePlatformTaskCompletionDialog, + ); + }, [ + acknowledgeExternalGenerationCompletionDialog, + activePlatformTaskCompletionDialog, + ]); + const closePlatformErrorDialog = useCallback(() => { if (!currentPlatformErrorDialog) { return; @@ -5458,38 +5504,14 @@ export function PlatformEntryFlowShellImpl({ if (dismissKey) { setDismissedPlatformTaskCompletionDialogKey(dismissKey); } - const taskJobIds = currentPlatformTaskCompletionDialog.taskJobIds ?? []; - if (taskJobIds.length > 0) { - const acknowledgedJobIds = new Set(taskJobIds); - setExternalGenerationTasks((current) => - current.filter((task) => !acknowledgedJobIds.has(task.jobId)), - ); - setExternalGenerationTaskOverview((current) => - current - ? { - ...current, - unacknowledgedTerminalCount: Math.max( - 0, - current.unacknowledgedTerminalCount - acknowledgedJobIds.size, - ), - } - : current, - ); - void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds }) - .then((response) => { - setExternalGenerationTasks((current) => - current.filter( - (task) => - !response.acknowledgedTasks.some( - (acknowledgedTask) => acknowledgedTask.jobId === task.jobId, - ), - ), - ); - }) - .catch(() => undefined); - } + acknowledgeExternalGenerationCompletionDialog( + currentPlatformTaskCompletionDialog, + ); setPendingPlatformTaskCompletionDialog(null); - }, [currentPlatformTaskCompletionDialog]); + }, [ + acknowledgeExternalGenerationCompletionDialog, + currentPlatformTaskCompletionDialog, + ]); const shouldPollPuzzleGenerationSession = selectionStage === 'puzzle-generating' && activePuzzleGenerationSessionId != null && diff --git a/src/components/project/ProjectCanvasCover.test.ts b/src/components/project/ProjectCanvasCover.test.ts new file mode 100644 index 000000000..d12d2221f --- /dev/null +++ b/src/components/project/ProjectCanvasCover.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; +import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover'; + +function project( + resources: EditorProjectSnapshot['resources'], +): EditorProjectSnapshot { + return { + projectId: 'editor-project-cover', + title: '封面项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources, + updatedAt: '2026-06-12T00:00:00.000Z', + }; +} + +describe('resolveProjectCoverSnapshotResource', () => { + it('prefers the newest cover snapshot object timestamp', () => { + const cover = resolveProjectCoverSnapshotResource( + project([ + { + resourceId: 'resource-newer-created-row', + projectId: 'editor-project-cover', + imageSrc: + '/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000000/cover.png', + objectKey: + 'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000000/cover.png', + width: 320, + height: 240, + sourceType: 'uploaded', + assetKind: 'project-cover-snapshot', + updatedAt: '2026-06-12T09:10:00.000Z', + }, + { + resourceId: 'resource-newer-snapshot', + projectId: 'editor-project-cover', + imageSrc: + '/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000100/cover.png', + objectKey: + 'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000100/cover.png', + width: 320, + height: 240, + sourceType: 'uploaded', + assetKind: 'project-cover-snapshot', + updatedAt: '2026-06-12T09:00:00.000Z', + }, + ]), + ); + + expect(cover?.resourceId).toBe('resource-newer-snapshot'); + }); + + it('ignores non-cover resources and empty cover snapshots', () => { + const cover = resolveProjectCoverSnapshotResource( + project([ + { + resourceId: 'resource-image', + projectId: 'editor-project-cover', + imageSrc: '/generated/source.png', + width: 320, + height: 240, + sourceType: 'uploaded', + assetKind: 'character', + }, + { + resourceId: 'resource-empty-cover', + projectId: 'editor-project-cover', + imageSrc: '', + width: 320, + height: 240, + sourceType: 'uploaded', + assetKind: 'project-cover-snapshot', + }, + ]), + ); + + expect(cover).toBeNull(); + }); +}); diff --git a/src/components/project/ProjectCanvasCover.tsx b/src/components/project/ProjectCanvasCover.tsx index fa5cb5698..f6b656eb2 100644 --- a/src/components/project/ProjectCanvasCover.tsx +++ b/src/components/project/ProjectCanvasCover.tsx @@ -1,230 +1,47 @@ -import { useEffect, useState } from 'react'; - -import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import type { - EditorProjectLayerSnapshot, EditorProjectResourceSnapshot, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; +import { PROJECT_COVER_SNAPSHOT_ASSET_KIND } from '../image-editor/ImageCanvasProjectCoverSnapshotModel'; -export const PROJECT_CANVAS_COVER_SIZE = { width: 320, height: 240 } as const; -export const PROJECT_CANVAS_COVER_VIEWPORT_SIZE = { - width: 900, - height: 640, -} as const; +function hasProjectCoverSnapshotSource(resource: EditorProjectResourceSnapshot) { + return Boolean(resource.objectKey?.trim() || resource.imageSrc.trim()); +} -export type ProjectCanvasCoverLayer = { - layerId: string; - title: string; - imageSrc: string; - objectKey: string | null; - refreshKey: string | null; - x: number; - y: number; - width: number; - height: number; - zIndex: number; -}; - -export type ProjectCanvasCoverClassNames = { - root?: string; - empty?: string; - layer?: string; - image?: string; -}; - -export type ProjectCanvasCoverProps = { - project: EditorProjectSnapshot; - classNames?: ProjectCanvasCoverClassNames; -}; - -function numberFromLayer( - layer: EditorProjectLayerSnapshot, - key: string, - fallback: number, +function projectCoverSnapshotObjectTimestamp( + resource: EditorProjectResourceSnapshot, ) { - const value = layer[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; + const snapshotPath = resource.objectKey?.trim() || resource.imageSrc.trim(); + const timestampSegment = snapshotPath.match( + /\/project-covers\/[^/]+\/(\d{10,})\//u, + )?.[1]; + const timestampMs = Number(timestampSegment); + return Number.isFinite(timestampMs) ? timestampMs : 0; } -function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) { - const value = layer[key]; - return typeof value === 'string' ? value.trim() : ''; -} - -function isLayerHidden(layer: EditorProjectLayerSnapshot) { - return layer.hidden === true; -} - -function joinClassNames(...classNames: Array) { - return classNames.filter(Boolean).join(' '); -} - -export function resolveProjectCanvasCoverLayers( - project: EditorProjectSnapshot, -): ProjectCanvasCoverLayer[] { - const resourcesById = new Map( - project.resources.map((resource) => [resource.resourceId, resource]), - ); - - return project.layers - .filter((layer) => !isLayerHidden(layer)) - .map((layer) => { - const resource = resourcesById.get(layer.resourceId); - const imageSrc = - stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || ''; - if (!imageSrc) { - return null; - } - return { - layerId: layer.layerId, - title: stringFromLayer(layer, 'title') || '画布图片', - imageSrc, - objectKey: - stringFromLayer(layer, 'objectKey') || - resource?.objectKey?.trim() || - null, - refreshKey: - stringFromLayer(layer, 'taskId') || - resource?.taskId?.trim() || - resource?.updatedAt?.trim() || - resource?.resourceId || - null, - x: numberFromLayer(layer, 'x', 0), - y: numberFromLayer(layer, 'y', 0), - width: Math.max( - 1, - numberFromLayer(layer, 'width', resource?.width ?? 320), - ), - height: Math.max( - 1, - numberFromLayer(layer, 'height', resource?.height ?? 320), - ), - zIndex: numberFromLayer(layer, 'zIndex', 0), - } satisfies ProjectCanvasCoverLayer; - }) - .filter((layer): layer is ProjectCanvasCoverLayer => Boolean(layer)) - .sort((left, right) => left.zIndex - right.zIndex); -} - -function ProjectCanvasCoverImage({ - layer, - className, -}: { - layer: ProjectCanvasCoverLayer; - className?: string; -}) { - const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl( - layer.imageSrc, - { - objectKey: layer.objectKey, - refreshKey: layer.refreshKey, - }, - ); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - setIsLoaded(false); - }, [resolvedUrl]); - - const isLoading = - isResolving || - (shouldResolve && !resolvedUrl) || - Boolean(resolvedUrl && !isLoaded); - - return ( - <> - {isLoading ? ( -