修复生成完成提示与项目封面快照
生成完成提示展示后后台确认,避免刷新重复弹出 项目封面改为保存并读取静态快照资源 补充封面快照上传、选择和展示测试 同步外部生成与图片画布契约文档
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
},
|
||||
'确认素材资产失败',
|
||||
|
||||
Reference in New Issue
Block a user