修复生成完成提示与项目封面快照

生成完成提示展示后后台确认,避免刷新重复弹出

项目封面改为保存并读取静态快照资源

补充封面快照上传、选择和展示测试

同步外部生成与图片画布契约文档
This commit is contained in:
2026-06-24 19:30:16 +08:00
parent 32fc4089cb
commit b6c5fdeb30
16 changed files with 877 additions and 342 deletions
@@ -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`,用户会继续看到“生成中”卡片。
@@ -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>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `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`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
@@ -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:<api-port>/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 与手动兜底示例:
@@ -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`
@@ -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({
</PlatformEmptyState>
) : (
recentProjects.map((project) => (
<button
key={project.projectId}
type="button"
className="creation-landing__project-card"
onClick={() => onOpenProject(project.projectId)}
>
<PlatformMediaFrame
src={null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
className="creation-landing__project-cover"
fallbackContent={<ProjectCanvasCover project={project} />}
/>
<span className="creation-landing__project-meta">
<strong>{project.title}</strong>
<small>{formatProjectUpdatedAt(project.updatedAt)}</small>
</span>
</button>
))
recentProjects.map((project) => {
const coverSnapshot =
resolveProjectCoverSnapshotResource(project);
return (
<button
key={project.projectId}
type="button"
className="creation-landing__project-card"
onClick={() => onOpenProject(project.projectId)}
>
<PlatformMediaFrame
src={coverSnapshot?.imageSrc ?? null}
objectKey={coverSnapshot?.objectKey ?? null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
refreshKey={
coverSnapshot?.updatedAt ??
coverSnapshot?.createdAt ??
coverSnapshot?.resourceId ??
null
}
className="creation-landing__project-cover"
/>
<span className="creation-landing__project-meta">
<strong>{project.title}</strong>
<small>{formatProjectUpdatedAt(project.updatedAt)}</small>
</span>
</button>
);
})
)}
</div>
</section>
@@ -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> = {}): 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',
},
]);
});
});
@@ -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<HTMLImageElement>((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<Blob | null>((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);
}
@@ -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<CanvasLayer> = {},
@@ -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(<ProjectPersistenceHarness />);
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(<ProjectPersistenceHarness />);
@@ -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<number | null>(null);
const projectTitleRef = useRef('未命名画布');
const coverSnapshotSignatureRef = useRef<string | null>(null);
const coverSnapshotUploadRequestRef = useRef(0);
const [projectId, setProjectId] = useState<string | null>(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,
@@ -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<Set<string>>(
new Set(),
);
const acknowledgingExternalGenerationDialogKeysRef = useRef<Set<string>>(
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 &&
@@ -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();
});
});
+36 -219
View File
@@ -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<string | undefined>) {
return classNames.filter(Boolean).join(' ');
}
export function resolveProjectCanvasCoverLayers(
project: EditorProjectSnapshot,
): ProjectCanvasCoverLayer[] {
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
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 ? (
<span
className="project-gallery__canvas-cover-loading"
aria-hidden="true"
/>
) : null}
{resolvedUrl ? (
<img
src={resolvedUrl}
alt=""
loading="lazy"
decoding="async"
className={joinClassNames(
'project-gallery__canvas-cover-image',
isLoaded
? 'project-gallery__canvas-cover-image--loaded'
: 'project-gallery__canvas-cover-image--loading',
className,
)}
onLoad={() => setIsLoaded(true)}
onError={() => setIsLoaded(true)}
/>
) : null}
</>
);
}
export function ProjectCanvasCover({
project,
classNames,
}: ProjectCanvasCoverProps) {
const coverLayers = resolveProjectCanvasCoverLayers(project);
if (!coverLayers.length) {
return (
<span
className={joinClassNames(
'project-gallery__preview-empty',
classNames?.empty,
)}
/>
);
function projectCoverSnapshotSortScore(resource: EditorProjectResourceSnapshot) {
const objectTimestamp = projectCoverSnapshotObjectTimestamp(resource);
if (objectTimestamp > 0) {
return objectTimestamp;
}
const safeScale =
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
? project.viewport.scale
: 1;
const viewportCenterX =
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) /
safeScale;
const viewportCenterY =
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) /
safeScale;
const worldPreviewWidth = PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / safeScale;
const worldPreviewHeight =
PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / safeScale;
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
const scale = Math.min(
PROJECT_CANVAS_COVER_SIZE.width / worldPreviewWidth,
PROJECT_CANVAS_COVER_SIZE.height / worldPreviewHeight,
);
const offsetX = -previewMinX * scale;
const offsetY = -previewMinY * scale;
return (
<span
className={joinClassNames(
'project-gallery__canvas-cover',
classNames?.root,
)}
aria-hidden="true"
>
{coverLayers.map((layer) => (
<span
key={layer.layerId}
className={joinClassNames(
'project-gallery__canvas-cover-layer',
classNames?.layer,
)}
style={{
left: offsetX + layer.x * scale,
top: offsetY + layer.y * scale,
width: layer.width * scale,
height: layer.height * scale,
zIndex: layer.zIndex,
}}
>
<ProjectCanvasCoverImage layer={layer} className={classNames?.image} />
</span>
))}
</span>
);
const updatedAt = resource.updatedAt ?? resource.createdAt ?? '';
const updatedAtMs = new Date(updatedAt).getTime();
return Number.isFinite(updatedAtMs) ? updatedAtMs : 0;
}
export function resolveProjectCoverSnapshotResource(
project: EditorProjectSnapshot,
) {
return [...project.resources]
.filter(
(resource) =>
resource.assetKind === PROJECT_COVER_SNAPSHOT_ASSET_KIND &&
hasProjectCoverSnapshotSource(resource),
)
.sort((left, right) => {
const byUpdatedAt =
projectCoverSnapshotSortScore(right) -
projectCoverSnapshotSortScore(left);
return byUpdatedAt || right.resourceId.localeCompare(left.resourceId);
})[0] ?? null;
}
@@ -104,7 +104,7 @@ describe('ProjectGalleryView', () => {
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('uses canvas-center layer composition as the project cover', async () => {
it('uses the saved project cover snapshot resource as the project cover', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-cover',
@@ -149,6 +149,16 @@ describe('ProjectGalleryView', () => {
height: 180,
sourceType: 'uploaded',
},
{
resourceId: 'resource-cover',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,cover',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'project-cover-snapshot',
updatedAt: '2026-06-12T09:00:00.000Z',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
@@ -157,21 +167,15 @@ describe('ProjectGalleryView', () => {
renderProjectGalleryView();
await screen.findByText('封面项目');
const cover = document.querySelector('.project-gallery__canvas-cover');
const coverLayers = document.querySelectorAll(
'.project-gallery__canvas-cover-layer',
);
expect(cover).toBeTruthy();
expect(coverLayers).toHaveLength(2);
expect(
document.querySelector('.project-gallery__canvas-cover-loading'),
).toBeTruthy();
document.querySelector('.project-gallery__canvas-cover'),
).toBeNull();
expect(
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
).toBe('data:image/png;base64,back');
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('data:image/png;base64,cover');
});
it('opens the login modal when project list loading is unauthorized', async () => {
+12 -5
View File
@@ -9,14 +9,14 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorProject,
deleteEditorProject,
type EditorProjectSnapshot,
listEditorProjects,
renameEditorProject,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
@@ -30,7 +30,7 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { ProjectCanvasCover } from './ProjectCanvasCover';
import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover';
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
@@ -197,6 +197,7 @@ export function ProjectGalleryView({
() =>
projects.map((project) => {
const selected = selectedProjectIds.has(project.projectId);
const coverSnapshot = resolveProjectCoverSnapshotResource(project);
return (
<article
key={project.projectId}
@@ -224,13 +225,19 @@ export function ProjectGalleryView({
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={null}
src={coverSnapshot?.imageSrc ?? null}
objectKey={coverSnapshot?.objectKey ?? null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
refreshKey={
coverSnapshot?.updatedAt ??
coverSnapshot?.createdAt ??
coverSnapshot?.resourceId ??
null
}
className="project-gallery__preview"
fallbackContent={<ProjectCanvasCover project={project} />}
>
{isSelectionMode ? (
<span className="project-gallery__checkbox">
@@ -209,4 +209,84 @@ describe('editorMediaAssetUploadClient', () => {
'/generated-character-drafts/editor/asset-library/image/spec.png',
});
});
it('supports project cover snapshot upload metadata and object binding', async () => {
requestJsonMock
.mockResolvedValueOnce({
upload: {
bucket: 'bucket',
host: 'https://oss.example.com',
objectKey:
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
legacyPublicPath:
'/generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
formFields: {
key: 'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
policy: 'policy',
},
},
})
.mockResolvedValueOnce({
assetObject: {
assetObjectId: 'assetobj-cover',
objectKey:
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
assetKind: 'editor_project_cover_snapshot',
},
});
getSignedAssetReadUrlMock.mockResolvedValueOnce(
'https://signed.example.com/project-cover.png',
);
const result = await uploadEditorMediaAssetFile(
new File(['cover'], 'project-1-cover.png', { type: 'image/png' }),
'image',
{
assetKind: 'editor_project_cover_snapshot',
pathSegments: [
'editor',
'project-covers',
'project-1',
'1771400000000',
],
entityId: 'editor-project-cover-project-1',
metadata: {
editor_project_id: 'project-1',
},
},
);
const ticketBody = JSON.parse(
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
);
expect(ticketBody).toMatchObject({
pathSegments: [
'editor',
'project-covers',
'project-1',
'1771400000000',
],
fileName: 'project-1-cover.png',
metadata: {
asset_kind: 'editor_project_cover_snapshot',
editor_media_type: 'image',
editor_project_id: 'project-1',
},
});
const confirmBody = JSON.parse(
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
);
expect(confirmBody).toMatchObject({
assetKind: 'editor_project_cover_snapshot',
entityId: 'editor-project-cover-project-1',
});
expect(result).toEqual({
src: 'https://signed.example.com/project-cover.png',
objectKey:
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
assetObjectId: 'assetobj-cover',
legacyPublicPath:
'/generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
});
});
});
@@ -32,6 +32,13 @@ export type UploadedEditorMediaAsset = {
legacyPublicPath: string;
};
export type EditorMediaAssetUploadOptions = {
assetKind?: string;
pathSegments?: string[];
entityId?: string;
metadata?: Record<string, string>;
};
const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60;
function resolveEditorMediaAssetKind(mediaType: EditorMediaAssetUploadType) {
@@ -65,8 +72,9 @@ function sanitizeEditorMediaFileName(
export async function uploadEditorMediaAssetFile(
file: File,
mediaType: EditorMediaAssetUploadType,
options: EditorMediaAssetUploadOptions = {},
): Promise<UploadedEditorMediaAsset> {
const assetKind = resolveEditorMediaAssetKind(mediaType);
const assetKind = options.assetKind ?? resolveEditorMediaAssetKind(mediaType);
const fileName = sanitizeEditorMediaFileName(file.name, mediaType);
const contentType = file.type.trim() || 'application/octet-stream';
@@ -77,7 +85,12 @@ export async function uploadEditorMediaAssetFile(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-character-drafts',
pathSegments: ['editor', 'asset-library', mediaType, `${Date.now()}`],
pathSegments: options.pathSegments ?? [
'editor',
'asset-library',
mediaType,
`${Date.now()}`,
],
fileName,
contentType,
access: 'private',
@@ -86,6 +99,7 @@ export async function uploadEditorMediaAssetFile(
metadata: {
asset_kind: assetKind,
editor_media_type: mediaType,
...options.metadata,
},
}),
},
@@ -107,7 +121,7 @@ export async function uploadEditorMediaAssetFile(
contentLength: file.size,
assetKind,
accessPolicy: 'private',
entityId: `editor-asset-library-${mediaType}`,
entityId: options.entityId ?? `editor-asset-library-${mediaType}`,
}),
},
'确认素材资产失败',