diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 932eebae8..bc2d27490 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -556,7 +556,9 @@ ## 2026-06-24 图片画布项目封面使用静态快照资源 - 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。 -- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。 +- 2026-07-29 补充:常规编辑仍沿用防抖保存;用户从画布返回项目页时必须取消待执行 timer,以最新权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存和正式项目资源后再导航。当前视口存在图层但全部位于取景外时仍生成纯背景封面,不沿用旧缩略图。 - 影响范围:`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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 35c409ae2..8bafd41c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -366,7 +366,9 @@ - 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。 - 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。 - 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。 -- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。 +- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。 +- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。 +- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。 - 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。 ## 图片画布框选预览要复用源图换签缓存 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 8569ba97b..a5536fdd7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -58,7 +58,8 @@ - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount`、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - 新增 `editor_showcase_asset`、`editor_showcase_asset_like` 和 `editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。 - `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` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景;存在 drawable 图层但当前取景全部离屏时仍输出纯背景封面。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。常规编辑允许封面滞后于最新 layout,但用户主动返回项目页时必须取消待执行防抖,以当前权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存、上传 OSS 和创建项目资源后再导航;不得用 `beforeunload` 代替受控返回。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 918925b4f..27e840204 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -20,6 +20,7 @@ import { dispatchPointerEvent, ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, + withEditorProjectCanvasRevision, } from './ImageCanvasEditorView.test-utils'; function render(ui: Parameters[0]) { @@ -870,7 +871,7 @@ describe('ImageCanvasEditorView asset library integration', () => { it('saves the canvas layout after deleting an asset linked by source resource id', async () => { const user = userEvent.setup(); - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '生成资源项目', viewport: { x: 0, y: 0, scale: 1 }, @@ -900,7 +901,7 @@ describe('ImageCanvasEditorView asset library integration', () => { }, ], updatedAt: '2026-06-12T00:00:00.000Z', - }); + })); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { @@ -971,14 +972,14 @@ describe('ImageCanvasEditorView asset library integration', () => { height: 240, sourceType: 'uploaded', }); - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', - }); + })); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 8bf6dafd2..a4458b743 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -74,17 +74,12 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, + uploadEditorMediaAssetObjectFile: vi.fn(), })); -vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => { - const actual = await vi.importActual< - typeof import('./ImageCanvasProjectCoverSnapshotModel') - >('./ImageCanvasProjectCoverSnapshotModel'); - return { - ...actual, - createProjectCoverSnapshotBlob: vi.fn(async () => null), - }; -}); +vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ + createProjectCoverSnapshotBlob: vi.fn(async () => null), +})); describe('ImageCanvasEditorView generation integration', () => { setupImageCanvasEditorViewTestLifecycle({ @@ -415,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => { project: { projectId: input.projectId, title: '默认项目', + canvas: { revision: 1 }, viewport: { x: 0, y: 0, scale: 1 }, resources: [ ...projectResources, @@ -482,6 +478,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-dialog', title: '已生成画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -551,6 +548,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-layer-only', title: '已生成画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -605,6 +603,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-uploaded-tagged-layer', title: '标签素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -650,6 +649,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-context-tagged-layer', title: '右键标签素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -700,6 +700,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-publication-dialog', title: '宣发画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1327,6 +1328,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons-generating', title: '图标素材生成中画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1699,6 +1701,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '默认项目', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: rememberedProjectLayers, resources: rememberedProjectResources, @@ -2334,6 +2337,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '默认项目', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: characterReferenceProjectLayers, resources: characterReferenceProjectResources, @@ -2511,6 +2515,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons', title: '图标素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2686,6 +2691,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icon-library-refresh', title: '图标素材刷新画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2831,6 +2837,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2873,6 +2880,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3141,6 +3149,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-character-animation', title: '角色动画画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3368,6 +3377,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-ui-extract', title: 'UI素材提取画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3541,6 +3551,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-ui-extract-dismiss', title: 'UI素材提取外部关闭画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 2d5082721..2fee555f1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -4,6 +4,11 @@ import type { ContextType } from 'react'; import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; +import type { + EditorProjectLayerSnapshot, + EditorProjectResourceSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; @@ -73,7 +78,7 @@ export const defaultEditorProjectResources = [ height: 405, sourceType: 'uploaded', }, -]; +] satisfies EditorProjectResourceSnapshot[]; export const defaultEditorProjectLayers = [ { @@ -102,7 +107,31 @@ export const defaultEditorProjectLayers = [ zIndex: 2, sourceType: 'uploaded', }, -]; +] satisfies EditorProjectLayerSnapshot[]; + +export function withEditorProjectCanvasRevision< + T extends EditorProjectSnapshot, +>(project: T, revision = 0): T { + if (typeof project.canvas?.revision === 'number') { + return project; + } + return { + ...project, + canvas: { + canvasId: + project.canvas?.canvasId ?? `${project.projectId}:canvas:default`, + projectId: project.projectId, + title: project.canvas?.title ?? '默认画布', + viewport: project.canvas?.viewport ?? project.viewport, + layers: project.canvas?.layers ?? project.layers, + revision, + layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0, + backgroundColor: project.canvas?.backgroundColor, + createdAt: project.canvas?.createdAt, + updatedAt: project.canvas?.updatedAt ?? project.updatedAt, + }, + }; +} export const defaultEditorAssetLibraryAssets = [ { @@ -235,14 +264,14 @@ export function setupImageCanvasEditorViewTestLifecycle({ window.sessionStorage.clear(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); loadOrCreateRecentEditorProjectMock.mockImplementation(() => - immediateAsync({ + immediateAsync(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', - }), + })), ); loadEditorAssetLibraryMock.mockImplementation(() => immediateAsync({ diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 9f0360f90..9c171e9e1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -26,6 +26,7 @@ import { immediateAsync, readZipText, setupImageCanvasEditorViewTestLifecycle, + withEditorProjectCanvasRevision, } from './ImageCanvasEditorView.test-utils'; type EditorAgentListConversations = @@ -457,14 +458,16 @@ describe('ImageCanvasEditorView', () => { }); it('shows the toolbar guide for a newly created blank project until a generator opens', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-guide', - title: '空白项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); + loadEditorProjectMock.mockResolvedValueOnce( + withEditorProjectCanvasRevision({ + projectId: 'editor-project-guide', + title: '空白项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }), + ); window.history.replaceState( null, '', @@ -505,14 +508,16 @@ describe('ImageCanvasEditorView', () => { }); it('opens the requested canvas startup tool from the project query once', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-music', - title: '音乐项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); + loadEditorProjectMock.mockResolvedValueOnce( + withEditorProjectCanvasRevision({ + projectId: 'editor-project-music', + title: '音乐项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }), + ); window.history.replaceState( null, '', @@ -528,7 +533,9 @@ describe('ImageCanvasEditorView', () => { expect(window.location.search).toBe('?projectid=editor-project-music'); }); - it('shows the loaded project title and a topbar entry back to projects', async () => { + it('flushes project persistence before returning from the topbar', async () => { + const onPopState = vi.fn(); + window.addEventListener('popstate', onPopState); render(); expect( @@ -538,6 +545,16 @@ describe('ImageCanvasEditorView', () => { expect(projectLink.getAttribute('href')).toBe('/project'); expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); + saveEditorProjectLayoutMock.mockClear(); + + fireEvent.click(projectLink); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); + expect(window.location.pathname).toBe('/project'); + expect(onPopState).toHaveBeenCalledTimes(1); + }); + window.removeEventListener('popstate', onPopState); }); it('opens login modal when the asset library is unauthorized', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c30236015..85243ec91 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes'; import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService'; import { createEditorAsset, @@ -310,9 +311,9 @@ export function ImageCanvasEditorView({ const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR); - const captureCanvasHistoryRef = useRef< - (action: CanvasHistoryAction) => void - >(() => {}); + const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>( + () => {}, + ); const discardCanvasHistoryEntriesContainingLayerRef = useRef< (matchesLayer: (layer: CanvasLayer) => boolean) => void >(() => {}); @@ -790,10 +791,8 @@ export function ImageCanvasEditorView({ ); const canvasHistoryEffects = useMemo( () => ({ - persistRestoredAssetKind: ( - layer: CanvasLayer, - historyEntryId?: number, - ) => persistRestoredAssetKindRef.current(layer, historyEntryId), + persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) => + persistRestoredAssetKindRef.current(layer, historyEntryId), }), [], ); @@ -1159,12 +1158,14 @@ export function ImageCanvasEditorView({ isProjectReady, appendCanvasLayersWithResources, applyProjectSnapshot, + flushProjectPersistence, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, layers, canvasGenerationDialogs, viewport, + canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, @@ -1172,6 +1173,11 @@ export function ImageCanvasEditorView({ openEditorLoginModal, onProjectAccessLost, }); + const returnToProjects = useCallback(async () => { + await flushProjectPersistence(); + replaceAppHistoryPath('/project'); + window.dispatchEvent(new PopStateEvent('popstate')); + }, [flushProjectPersistence]); const setEditorProjectContextId = useImageCanvasContextStore( (state) => state.setProjectId, ); @@ -1368,11 +1374,7 @@ export function ImageCanvasEditorView({ const rollbackLayer = layersRef.current.find( (currentLayer) => currentLayer.id === layer.id, ); - persistUpdatedLayerResource( - layer, - rollbackLayer ?? layer, - historyEntryId, - ); + persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId); }; const { assetExportStatus, @@ -1477,7 +1479,9 @@ export function ImageCanvasEditorView({ if (!projectId || tasks.length === 0) { return; } - const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim(); + const warning = tasks + .find((task) => task.warning?.trim()) + ?.warning?.trim(); if (warning) { showGenerationWarning(warning); } @@ -2001,9 +2005,7 @@ export function ImageCanvasEditorView({ return; } captureCanvasHistory({ - type: targetLayerIds.length - ? 'move-image' - : 'move-generation-result', + type: targetLayerIds.length ? 'move-image' : 'move-generation-result', count: targetLayerIds.length || targetDialogIds.length, }); if (targetLayerIds.length) { @@ -2309,6 +2311,7 @@ export function ImageCanvasEditorView({ onRequestWalletDetails: loadRechargeCenter, onRecharge: openAccountPaymentModal, onOpenWalletLedger: openWalletLedgerPanel, + onReturnToProjects: returnToProjects, onOpenAccount: () => { if (authUi?.user) { authUi.openAccountModal(); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 063be7378..805f0d367 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, + normalizeProjectCoverSnapshotViewport, resolveProjectCoverDrawableLayers, + resolveProjectCoverSnapshotFrame, } from './ImageCanvasProjectCoverSnapshotModel'; function layer(overrides: Partial = {}): CanvasLayer { @@ -25,9 +27,33 @@ function layer(overrides: Partial = {}): CanvasLayer { } describe('ImageCanvasProjectCoverSnapshotModel', () => { + it('uses at least a centered 1280 by 960 viewport while preserving larger viewports', () => { + expect( + normalizeProjectCoverSnapshotViewport({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + }), + ).toEqual({ + viewport: { x: 202, y: 152, scale: 0.5 }, + viewportSize: { width: 1280, height: 960 }, + }); + + expect( + normalizeProjectCoverSnapshotViewport({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 1920, height: 1080 }, + }), + ).toEqual({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 1920, height: 1080 }, + }); + }); + it('builds a stable cover signature from visible drawable layers', () => { const signature = buildProjectCoverSnapshotSignature({ viewport: { x: 12, y: -8, scale: 0.75 }, + viewportSize: { width: 900, height: 640 }, + backgroundColor: '#f8fafc', layers: [ layer({ id: 'hidden', hidden: true }), layer({ @@ -49,12 +75,36 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { }); expect(signature).toContain('"scale":0.75'); + expect(signature).toContain('"width":900'); + expect(signature).toContain('"backgroundColor":"#f8fafc"'); expect(signature).toContain('"id":"back"'); expect(signature).toContain('"id":"front"'); expect(signature).not.toContain('"id":"hidden"'); expect(signature).not.toContain('"id":"audio"'); }); + it('center-crops the live canvas viewport to fill the 4:3 cover', () => { + const landscapeFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: -260, y: 70, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + }); + + expect(landscapeFrame.renderScale).toBe(0.5); + expect(landscapeFrame.frameScale).toBeCloseTo(0.375); + expect(landscapeFrame.offsetX).toBeCloseTo(-8.75); + expect(landscapeFrame.offsetY).toBeCloseTo(0); + + const portraitFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: 30, y: -40, scale: 1.25 }, + viewportSize: { width: 640, height: 900 }, + }); + + expect(portraitFrame.renderScale).toBe(1.25); + expect(portraitFrame.frameScale).toBeCloseTo(0.5); + expect(portraitFrame.offsetX).toBeCloseTo(0); + expect(portraitFrame.offsetY).toBeCloseTo(-105); + }); + it('uses the first image-sequence frame as the drawable source', () => { expect( resolveProjectCoverDrawableLayers([ diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index c56250b31..4b4aabba3 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -1,19 +1,28 @@ -import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; +import { + DEFAULT_CANVAS_BACKGROUND_COLOR, + normalizeCanvasBackgroundHex, +} from './ImageCanvasEditorModel'; 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, +export const PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE = { + width: 1280, + height: 960, } as const; -type ProjectCoverDrawableLayer = { +export type ProjectCoverSnapshotViewportSize = { + width: number; + height: number; +}; + +export type ProjectCoverDrawableLayer = { id: string; src: string; objectKey: string | null; + cacheVersion: string; x: number; y: number; width: number; @@ -25,6 +34,73 @@ function numberOrFallback(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback; } +function positiveNumberOrFallback(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function normalizeProjectCoverViewportSize( + viewportSize: ProjectCoverSnapshotViewportSize, +) { + return { + width: positiveNumberOrFallback( + viewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.width, + ), + height: positiveNumberOrFallback( + viewportSize.height, + PROJECT_COVER_SNAPSHOT_SIZE.height, + ), + }; +} + +export function normalizeProjectCoverSnapshotViewport({ + viewport, + viewportSize, +}: { + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; +}) { + const sourceViewportSize = { + width: positiveNumberOrFallback( + viewportSize.width, + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, + ), + height: positiveNumberOrFallback( + viewportSize.height, + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, + ), + }; + const normalizedViewportSize = { + width: Math.max( + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, + sourceViewportSize.width, + ), + height: Math.max( + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, + sourceViewportSize.height, + ), + }; + return { + viewport: { + x: + numberOrFallback(viewport.x, 0) + + (normalizedViewportSize.width - sourceViewportSize.width) / 2, + y: + numberOrFallback(viewport.y, 0) + + (normalizedViewportSize.height - sourceViewportSize.height) / 2, + scale: numberOrFallback(viewport.scale, 1), + }, + viewportSize: normalizedViewportSize, + }; +} + +export function normalizeProjectCoverBackgroundColor(backgroundColor: string) { + return ( + normalizeCanvasBackgroundHex(backgroundColor) ?? + DEFAULT_CANVAS_BACKGROUND_COLOR + ); +} + function normalizeObjectKey(value: string | null | undefined) { const normalized = value?.trim().replace(/^\/+/u, '') ?? ''; return normalized || null; @@ -41,6 +117,7 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), + cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`, }; } } @@ -53,6 +130,9 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { objectKey: layer.thumbnailSrc?.trim() ? null : normalizeObjectKey(layer.objectKey), + cacheVersion: layer.thumbnailSrc?.trim() + ? `${layer.taskId ?? layer.resourceId}:poster` + : (layer.taskId ?? layer.resourceId), }; } @@ -69,10 +149,14 @@ export function resolveProjectCoverDrawableLayers( id: layer.id, src: source.src, objectKey: source.objectKey, + cacheVersion: source.cacheVersion, 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)), + height: Math.max( + 1, + numberOrFallback(layer.height, layer.originalHeight), + ), zIndex: numberOrFallback(layer.zIndex, 0), } satisfies ProjectCoverDrawableLayer; }) @@ -83,116 +167,78 @@ export function resolveProjectCoverDrawableLayers( export function buildProjectCoverSnapshotSignature({ layers, viewport, + viewportSize, + backgroundColor, }: { layers: readonly CanvasLayer[]; + // we may call it camera viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; }) { const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); return JSON.stringify({ viewport: { x: numberOrFallback(viewport.x, 0), y: numberOrFallback(viewport.y, 0), scale: numberOrFallback(viewport.scale, 1), }, + viewportSize: normalizedViewportSize, + backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor), layers: coverLayers, }); } -function resolveProjectCoverPlacement( - viewport: CanvasViewport, - layer: ProjectCoverDrawableLayer, -) { - const safeScale = +export function resolveProjectCoverSnapshotFrame({ + viewport, + viewportSize, +}: { + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; +}) { + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); + const renderScale = 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 frameScale = Math.max( + PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height, ); - 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, + viewportX: numberOrFallback(viewport.x, 0), + viewportY: numberOrFallback(viewport.y, 0), + renderScale, + frameScale, + offsetX: + (PROJECT_COVER_SNAPSHOT_SIZE.width - + normalizedViewportSize.width * frameScale) / + 2, + offsetY: + (PROJECT_COVER_SNAPSHOT_SIZE.height - + normalizedViewportSize.height * frameScale) / + 2, }; } -async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { - const src = layer.objectKey - ? await getSignedAssetReadUrl({ objectKey: layer.objectKey }) - : layer.src; - return new Promise((resolve, reject) => { - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.onload = () => resolve(image); - image.onerror = () => reject(new Error('project cover image load failed')); - image.src = src; - }); -} - -function canvasToBlob(canvas: HTMLCanvasElement) { - return new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), 'image/png'); - }); -} - -export async function createProjectCoverSnapshotBlob({ - layers, - viewport, -}: { - layers: readonly CanvasLayer[]; - viewport: CanvasViewport; -}) { - if (typeof document === 'undefined') { - return null; - } - const coverLayers = resolveProjectCoverDrawableLayers(layers); - if (coverLayers.length === 0) { - return null; - } - const canvas = document.createElement('canvas'); - canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; - canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; - const context = canvas.getContext('2d'); - if (!context || typeof canvas.toBlob !== 'function') { - return null; - } - context.clearRect(0, 0, canvas.width, canvas.height); - - let drawnLayerCount = 0; - for (const layer of coverLayers) { - try { - const image = await loadProjectCoverImage(layer); - const placement = resolveProjectCoverPlacement(viewport, layer); - context.drawImage( - image, - placement.x, - placement.y, - placement.width, - placement.height, - ); - drawnLayerCount += 1; - } catch { - // Individual layer load failures should not block a cover from other layers. - } - } - - if (drawnLayerCount === 0) { - return null; - } - return canvasToBlob(canvas); +export function resolveProjectCoverPlacement( + viewport: CanvasViewport, + viewportSize: ProjectCoverSnapshotViewportSize, + layer: ProjectCoverDrawableLayer, +) { + const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize }); + return { + x: + frame.offsetX + + (frame.viewportX + layer.x * frame.renderScale) * frame.frameScale, + y: + frame.offsetY + + (frame.viewportY + layer.y * frame.renderScale) * frame.frameScale, + width: layer.width * frame.renderScale * frame.frameScale, + height: layer.height * frame.renderScale * frame.frameScale, + }; } diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts new file mode 100644 index 000000000..11ddbfd8b --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer'; + +function layer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-offscreen', + resourceId: 'resource-offscreen', + title: '离屏图层', + src: '/generated/offscreen.webp', + x: 10_000, + y: 10_000, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + ...overrides, + }; +} + +describe('ImageCanvasProjectCoverSnapshotRenderer', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exports the canvas background when every drawable layer is outside the cover frame', async () => { + const fillRect = vi.fn(); + const drawImage = vi.fn(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fillRect, + drawImage, + fillStyle: '', + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback) => callback(new Blob(['cover'], { type: 'image/webp' })), + ); + + const blob = await createProjectCoverSnapshotBlob({ + layers: [layer()], + viewport: { x: 0, y: 0, scale: 1 }, + viewportSize: { width: 1280, height: 960 }, + backgroundColor: '#f8fafc', + }); + + expect(blob?.type).toBe('image/webp'); + expect(fillRect).toHaveBeenCalledWith(0, 0, 320, 240); + expect(drawImage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts new file mode 100644 index 000000000..0c790b8ec --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts @@ -0,0 +1,158 @@ +import { + getSignedAssetReadUrl, + resolveAssetReadUrl, + shouldResolveAssetReadUrl, +} from '../../services/assetReadUrlService'; +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import { + normalizeProjectCoverBackgroundColor, + PROJECT_COVER_SNAPSHOT_SIZE, + type ProjectCoverDrawableLayer, + type ProjectCoverSnapshotViewportSize, + resolveProjectCoverDrawableLayers, + resolveProjectCoverPlacement, +} from './ImageCanvasProjectCoverSnapshotModel'; + +const PROJECT_COVER_IMAGE_LOAD_CONCURRENCY = 6; +const PROJECT_COVER_SNAPSHOT_MIME_TYPE = 'image/webp'; +const PROJECT_COVER_SNAPSHOT_WEBP_QUALITY = 0.82; + +type ProjectCoverDrawItem = { + layer: ProjectCoverDrawableLayer; + placement: ReturnType; +}; + +function placementIntersectsCover( + placement: ProjectCoverDrawItem['placement'], +) { + return ( + placement.x < PROJECT_COVER_SNAPSHOT_SIZE.width && + placement.y < PROJECT_COVER_SNAPSHOT_SIZE.height && + placement.x + placement.width > 0 && + placement.y + placement.height > 0 + ); +} + +async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) { + if (layer.objectKey) { + return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, { + cacheVersion: layer.cacheVersion, + }); + } + if (shouldResolveAssetReadUrl(layer.src)) { + return resolveAssetReadUrl(layer.src, { + refreshKey: layer.cacheVersion, + }); + } + return layer.src; +} + +async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { + const src = await resolveProjectCoverImageUrl(layer); + const image = await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('project cover image load failed')); + image.src = src; + }); + return image; +} + +async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) { + const loadedImages = new Array(items.length).fill( + null, + ); + let nextIndex = 0; + const loadNext = async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + try { + const item = items[index]; + if (item) { + loadedImages[index] = await loadProjectCoverImage(item.layer); + } + } catch { + // Individual layer load failures should not block other cover layers. + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(PROJECT_COVER_IMAGE_LOAD_CONCURRENCY, items.length) }, + () => loadNext(), + ), + ); + return loadedImages; +} + +function canvasToBlob(canvas: HTMLCanvasElement) { + return new Promise((resolve) => { + canvas.toBlob( + (blob) => resolve(blob), + PROJECT_COVER_SNAPSHOT_MIME_TYPE, + PROJECT_COVER_SNAPSHOT_WEBP_QUALITY, + ); + }); +} + +export async function createProjectCoverSnapshotBlob({ + layers, + viewport, + viewportSize, + backgroundColor, +}: { + layers: readonly CanvasLayer[]; + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; +}) { + if (typeof document === 'undefined') { + return null; + } + const drawableLayers = resolveProjectCoverDrawableLayers(layers); + if (drawableLayers.length === 0) { + return null; + } + const drawItems = drawableLayers + .map((layer) => ({ + layer, + placement: resolveProjectCoverPlacement(viewport, viewportSize, layer), + })) + .filter(({ placement }) => placementIntersectsCover(placement)); + 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.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); + context.fillRect(0, 0, canvas.width, canvas.height); + + if (drawItems.length === 0) { + return canvasToBlob(canvas); + } + + const loadedImages = await loadProjectCoverDrawItems(drawItems); + let drawnLayerCount = 0; + drawItems.forEach(({ placement }, index) => { + const image = loadedImages[index]; + if (!image) { + return; + } + context.drawImage( + image, + placement.x, + placement.y, + placement.width, + placement.height, + ); + drawnLayerCount += 1; + }); + if (drawnLayerCount === 0) { + return null; + } + return canvasToBlob(canvas); +} diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2ff3eeaf5..d8b2a1ff1 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -67,6 +73,7 @@ function renderTopbar( onRecharge: vi.fn(), onOpenWalletLedger: vi.fn(), onOpenAccount: vi.fn(), + onReturnToProjects: vi.fn(), ...overrides, }; @@ -123,6 +130,29 @@ describe('ImageCanvasTopbarView', () => { expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1); }); + it('waits for project persistence before allowing another return request', async () => { + let resolveReturn!: () => void; + const pendingReturn = new Promise((resolve) => { + resolveReturn = resolve; + }); + const onReturnToProjects = vi.fn(() => pendingReturn); + renderTopbar({ onReturnToProjects }); + const projectLink = screen.getByRole('link', { + name: '返回项目页面', + }); + + fireEvent.click(projectLink); + fireEvent.click(projectLink); + + expect(onReturnToProjects).toHaveBeenCalledTimes(1); + expect(projectLink.getAttribute('aria-disabled')).toBe('true'); + + resolveReturn(); + await waitFor(() => { + expect(projectLink.getAttribute('aria-disabled')).toBe('false'); + }); + }); + it('shows the current user avatar beside the mud point balance', () => { const props = renderTopbar({ currentUser: { diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index ba20c2e4c..b2660d5d9 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -6,6 +6,7 @@ import { Pencil, X, } from 'lucide-react'; +import { useState } from 'react'; import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; @@ -51,6 +52,7 @@ export type ImageCanvasTopbarViewProps = { onRecharge: () => void; onOpenWalletLedger: () => void; onOpenAccount: () => void; + onReturnToProjects?: () => void | Promise; }; function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) { @@ -88,7 +90,9 @@ export function ImageCanvasTopbarView({ onRecharge, onOpenWalletLedger, onOpenAccount, + onReturnToProjects, }: ImageCanvasTopbarViewProps) { + const [isReturningToProjects, setIsReturningToProjects] = useState(false); const projectId = useImageCanvasContextStore((state) => state.projectId); const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, @@ -107,7 +111,21 @@ export function ImageCanvasTopbarView({ className="image-canvas-editor__project-back-button" href="/project" aria-label="返回项目页面" + aria-disabled={isReturningToProjects} title="返回项目" + onClick={(event) => { + if (!onReturnToProjects) { + return; + } + event.preventDefault(); + if (isReturningToProjects) { + return; + } + setIsReturningToProjects(true); + void Promise.resolve(onReturnToProjects()).finally(() => { + setIsReturningToProjects(false); + }); + }} >