From 23e2d099ff3af46a50921f89ecc18233612e5f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 24 Jul 2026 14:27:54 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E9=A6=96=E9=A1=B5=E3=80=8C=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E9=A1=B9=E7=9B=AE=E3=80=8D=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=E4=B8=8E=E9=80=80=E5=87=BA=E5=89=8D=E8=A7=86=E8=A7=92=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=8C=E5=87=BA=E7=8E=B0=E5=81=8F=E7=A7=BB?= =?UTF-8?q?=E6=88=96=E8=A3=81=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 封面使用真实画布尺寸(clientWidth/Height)及最终 viewport.x/y/scale。 - 320×240 封面改为居中 cover 裁切,不再从左上角取景。 - 正确绘制画布背景色。 --- .../shared-memory/decision-log.md | 1 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 1 + ...ageCanvasProjectCoverSnapshotModel.test.ts | 27 ++++ .../ImageCanvasProjectCoverSnapshotModel.ts | 124 ++++++++++++++---- .../useImageCanvasProjectPersistence.test.tsx | 37 ++++-- .../useImageCanvasProjectPersistence.ts | 31 ++++- 7 files changed, 184 insertions(+), 39 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8604b6bdc..aedadcd76 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -539,6 +539,7 @@ - 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。 - 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。 - 影响范围:`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/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 50ab585e2..c75be9180 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -58,7 +58,7 @@ - 新增 `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 PNG:取景必须使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale` 和当前工作区尺寸,先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得按固定工作区尺寸或左上角锚定取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 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/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 2e4db9067..dc4512b39 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1116,6 +1116,7 @@ export function ImageCanvasEditorView({ () => ({ layersRef, viewportRef, + canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 063be7378..a2eb0c9d0 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -4,6 +4,7 @@ import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, resolveProjectCoverDrawableLayers, + resolveProjectCoverSnapshotFrame, } from './ImageCanvasProjectCoverSnapshotModel'; function layer(overrides: Partial = {}): CanvasLayer { @@ -28,6 +29,8 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { 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 +52,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..5b661ba3d 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -1,14 +1,19 @@ 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, -} as const; + +export type ProjectCoverSnapshotViewportSize = { + width: number; + height: number; +}; type ProjectCoverDrawableLayer = { id: string; @@ -25,6 +30,32 @@ 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, + ), + }; +} + +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; @@ -83,51 +114,79 @@ 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, }); } +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 frameScale = Math.max( + PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height, + ); + return { + 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, + }; +} + function resolveProjectCoverPlacement( viewport: CanvasViewport, + viewportSize: ProjectCoverSnapshotViewportSize, 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; + const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize }); return { - x: offsetX + layer.x * scale, - y: offsetY + layer.y * scale, - width: layer.width * scale, - height: layer.height * scale, + 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, }; } @@ -153,9 +212,13 @@ function canvasToBlob(canvas: HTMLCanvasElement) { export async function createProjectCoverSnapshotBlob({ layers, viewport, + viewportSize, + backgroundColor, }: { layers: readonly CanvasLayer[]; viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; }) { if (typeof document === 'undefined') { return null; @@ -171,13 +234,18 @@ export async function createProjectCoverSnapshotBlob({ if (!context || typeof canvas.toBlob !== 'function') { return null; } - context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); + context.fillRect(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); + const placement = resolveProjectCoverPlacement( + viewport, + viewportSize, + layer, + ); context.drawImage( image, placement.x, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index c433c4597..8ebb77d8f 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -118,6 +118,7 @@ function ProjectPersistenceHarness({ const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); const viewportRef = useRef(viewport); + const canvasViewportRef = useRef(null); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); const selectedLayerRef = useRef(null); @@ -146,6 +147,7 @@ function ProjectPersistenceHarness({ () => ({ layersRef, viewportRef, + canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), @@ -949,6 +951,15 @@ describe('useImageCanvasProjectPersistence', () => { render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith( + expect.objectContaining({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR, + }), + ); + }); await waitFor(() => { expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -1073,14 +1084,12 @@ describe('useImageCanvasProjectPersistence', () => { }); it('does not serialize or write session cache while viewport interactions are active', async () => { - const sessionSetItemSpy = vi.spyOn( - Storage.prototype, - 'setItem', - ); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); - sessionSetItemSpy.mockClear(); + const cachedBeforeInteraction = globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ); saveEditorProjectLayoutMock.mockClear(); vi.useFakeTimers(); @@ -1097,7 +1106,11 @@ describe('useImageCanvasProjectPersistence', () => { }); expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); - expect(sessionSetItemSpy).not.toHaveBeenCalled(); + expect( + globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ), + ).toBe(cachedBeforeInteraction); act(() => { screen.getByRole('button', { name: 'end viewport interaction' }).click(); @@ -1115,9 +1128,17 @@ describe('useImageCanvasProjectPersistence', () => { viewport: { x: 20, y: 10, scale: 1 }, }), ); - expect(sessionSetItemSpy).toHaveBeenCalled(); + const cachedAfterInteraction = JSON.parse( + globalThis.sessionStorage.getItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + ) ?? '{}', + ) as { project?: EditorProjectSnapshot }; + expect(cachedAfterInteraction.project?.viewport).toEqual({ + x: 20, + y: 10, + scale: 1, + }); vi.useRealTimers(); - sessionSetItemSpy.mockRestore(); }); it('does not create a project resource again for backend-persisted generated layers', async () => { diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 3adecfecf..b1617927e 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -22,6 +22,7 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, + DEFAULT_CANVAS_SIZE, hydrateLayer, isInlineEditorMediaSource, serializeCanvasLayout, @@ -65,6 +66,7 @@ type CachedEditorProjectSnapshot = { type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; + canvasViewportRef: RefObject; canvasGenerationDialogsRef: RefObject; canvasBackgroundColorRef: RefObject; }; @@ -114,6 +116,21 @@ function isEditorProjectRevisionConflict(error: unknown) { return error instanceof ApiClientError && error.status === 409; } +function resolveProjectCoverViewportSize( + viewportElement: HTMLDivElement | null, +) { + const width = viewportElement?.clientWidth ?? 0; + const height = viewportElement?.clientHeight ?? 0; + return { + width: + width > 0 && Number.isFinite(width) ? width : DEFAULT_CANVAS_SIZE.width, + height: + height > 0 && Number.isFinite(height) + ? height + : DEFAULT_CANVAS_SIZE.height, + }; +} + const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([ 408, 425, 429, 502, 503, 504, ]); @@ -453,15 +470,23 @@ export function useImageCanvasProjectPersistence({ const persistProjectCoverSnapshot = useCallback( ( nextProjectId: string, - coverViewport: CanvasViewport, + coverDisplayViewport: CanvasViewport, coverLayers: readonly CanvasLayer[], ) => { if (!canAccessProtectedData) { return; } + const coverViewport = + canvasDisplayViewportToViewport(coverDisplayViewport); + const viewportSize = resolveProjectCoverViewportSize( + refs.canvasViewportRef.current, + ); + const backgroundColor = refs.canvasBackgroundColorRef.current; const signature = buildProjectCoverSnapshotSignature({ layers: coverLayers, viewport: coverViewport, + viewportSize, + backgroundColor, }); if (!signature || coverSnapshotSignatureRef.current === signature) { return; @@ -473,6 +498,8 @@ export function useImageCanvasProjectPersistence({ void createProjectCoverSnapshotBlob({ layers: coverLayers, viewport: coverViewport, + viewportSize, + backgroundColor, }) .then(async (blob) => { if (!blob) { @@ -530,7 +557,7 @@ export function useImageCanvasProjectPersistence({ } }); }, - [canAccessProtectedData, openEditorLoginModal], + [canAccessProtectedData, openEditorLoginModal, refs], ); const queueProjectLayoutSave = useCallback( -- 2.52.0 From 08cda72ea000a2be311e63e42625aa528af587fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 24 Jul 2026 16:08:49 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E7=8A=B6=E6=80=81=E7=94=9F=E6=88=90=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=B0=81=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 ResizeObserver 维护的 canvasSize 传入项目持久化 hook 移除持久化层对 canvasViewportRef 和 clientWidth/clientHeight 的直接读取 更新持久化测试夹具并保持封面尺寸断言 --- .../image-editor/ImageCanvasEditorView.tsx | 2 +- .../useImageCanvasProjectPersistence.test.tsx | 4 +-- .../useImageCanvasProjectPersistence.ts | 26 +++++-------------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index dc4512b39..1e50147d2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1116,7 +1116,6 @@ export function ImageCanvasEditorView({ () => ({ layersRef, viewportRef, - canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), @@ -1156,6 +1155,7 @@ export function ImageCanvasEditorView({ layers, canvasGenerationDialogs, viewport, + canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 8ebb77d8f..1673a2011 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -113,12 +113,12 @@ function ProjectPersistenceHarness({ y: 0, scale: 1, }); + const canvasSize = { width: 900, height: 640 }; const [isViewportInteracting, setIsViewportInteracting] = useState(false); const [projectTitle, setProjectTitle] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); const viewportRef = useRef(viewport); - const canvasViewportRef = useRef(null); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); const selectedLayerRef = useRef(null); @@ -147,7 +147,6 @@ function ProjectPersistenceHarness({ () => ({ layersRef, viewportRef, - canvasViewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), @@ -173,6 +172,7 @@ function ProjectPersistenceHarness({ layers, canvasGenerationDialogs: generationDialogs, viewport, + canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index b1617927e..44a447ce4 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -22,7 +22,6 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, - DEFAULT_CANVAS_SIZE, hydrateLayer, isInlineEditorMediaSource, serializeCanvasLayout, @@ -40,6 +39,7 @@ import { PROJECT_COVER_SNAPSHOT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_SIZE, + type ProjectCoverSnapshotViewportSize, } from './ImageCanvasProjectCoverSnapshotModel'; type ProjectResourceOptions = { @@ -66,7 +66,6 @@ type CachedEditorProjectSnapshot = { type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; - canvasViewportRef: RefObject; canvasGenerationDialogsRef: RefObject; canvasBackgroundColorRef: RefObject; }; @@ -90,6 +89,7 @@ type ImageCanvasProjectPersistenceOptions = { layers: CanvasLayer[]; canvasGenerationDialogs: CanvasGenerationDialogState[]; viewport: CanvasViewport; + canvasSize: ProjectCoverSnapshotViewportSize; canvasBackgroundColor: string; isViewportInteracting: boolean; canAccessProtectedData: boolean; @@ -116,21 +116,6 @@ function isEditorProjectRevisionConflict(error: unknown) { return error instanceof ApiClientError && error.status === 409; } -function resolveProjectCoverViewportSize( - viewportElement: HTMLDivElement | null, -) { - const width = viewportElement?.clientWidth ?? 0; - const height = viewportElement?.clientHeight ?? 0; - return { - width: - width > 0 && Number.isFinite(width) ? width : DEFAULT_CANVAS_SIZE.width, - height: - height > 0 && Number.isFinite(height) - ? height - : DEFAULT_CANVAS_SIZE.height, - }; -} - const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([ 408, 425, 429, 502, 503, 504, ]); @@ -330,6 +315,7 @@ export function useImageCanvasProjectPersistence({ layers, canvasGenerationDialogs, viewport, + canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, @@ -354,6 +340,7 @@ export function useImageCanvasProjectPersistence({ const projectTitleRef = useRef('未命名画布'); const coverSnapshotSignatureRef = useRef(null); const coverSnapshotUploadRequestRef = useRef(0); + const coverSnapshotViewportSizeRef = useRef(canvasSize); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const { @@ -366,6 +353,7 @@ export function useImageCanvasProjectPersistence({ restoreCanvasGenerationDialogs, applyCanvasBackgroundColor, } = setters; + coverSnapshotViewportSizeRef.current = canvasSize; const clearPendingProjectLayoutSave = useCallback(() => { pendingProjectLayoutSaveRef.current = null; @@ -478,9 +466,7 @@ export function useImageCanvasProjectPersistence({ } const coverViewport = canvasDisplayViewportToViewport(coverDisplayViewport); - const viewportSize = resolveProjectCoverViewportSize( - refs.canvasViewportRef.current, - ); + const viewportSize = coverSnapshotViewportSizeRef.current; const backgroundColor = refs.canvasBackgroundColorRef.current; const signature = buildProjectCoverSnapshotSignature({ layers: coverLayers, -- 2.52.0 From f71e7607c7c756f87961e083dc79f066f7d22b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 29 Jul 2026 11:28:25 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E8=A7=86=E5=8F=A3=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=9C=80=E5=B0=8F=E5=8F=96=E6=99=AF=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E4=B8=BA=201280x960=20=E4=B8=94=E5=B1=85=E4=B8=AD?= =?UTF-8?q?=E8=A3=81=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project-memory/shared-memory/pitfalls.md | 3 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...ageCanvasProjectCoverSnapshotModel.test.ts | 23 ++++++++++ .../ImageCanvasProjectCoverSnapshotModel.ts | 45 +++++++++++++++++++ .../useImageCanvasProjectPersistence.test.tsx | 4 +- .../useImageCanvasProjectPersistence.ts | 9 +++- 6 files changed, 80 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 94fe87ed5..a50a6cd41 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -358,7 +358,8 @@ - 现象:画布项目已反复打开、保存或操作,但 `/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 尺寸变化单独生成封面,也不为封面增加强一致的返回阻塞。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。 +- 验证:`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 c75be9180..4790aa10a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -58,7 +58,7 @@ - 新增 `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:取景必须使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale` 和当前工作区尺寸,先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得按固定工作区尺寸或左上角锚定取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG:只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。封面只是弱一致的展示派生物,可以滞后于最新 layout;它不是项目真相,不得为了追求强一致而监听尺寸变化、增加保存频率或阻塞离开画布。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 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/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index a2eb0c9d0..805f0d367 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, + normalizeProjectCoverSnapshotViewport, resolveProjectCoverDrawableLayers, resolveProjectCoverSnapshotFrame, } from './ImageCanvasProjectCoverSnapshotModel'; @@ -26,6 +27,28 @@ 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 }, diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index 5b661ba3d..e6a4b5edb 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -9,6 +9,10 @@ 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_MIN_VIEWPORT_SIZE = { + width: 1280, + height: 960, +} as const; export type ProjectCoverSnapshotViewportSize = { width: number; @@ -49,6 +53,47 @@ function normalizeProjectCoverViewportSize( }; } +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, + }; +} + function normalizeProjectCoverBackgroundColor(backgroundColor: string) { return ( normalizeCanvasBackgroundHex(backgroundColor) ?? diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 1673a2011..49c56c1d7 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -954,8 +954,8 @@ describe('useImageCanvasProjectPersistence', () => { await waitFor(() => { expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith( expect.objectContaining({ - viewport: { x: 12, y: -8, scale: 0.5 }, - viewportSize: { width: 900, height: 640 }, + viewport: { x: 202, y: 152, scale: 0.5 }, + viewportSize: { width: 1280, height: 960 }, backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR, }), ); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 44a447ce4..7f1c48a8a 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -36,6 +36,7 @@ import type { import { buildProjectCoverSnapshotSignature, createProjectCoverSnapshotBlob, + normalizeProjectCoverSnapshotViewport, PROJECT_COVER_SNAPSHOT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_SIZE, @@ -464,9 +465,13 @@ export function useImageCanvasProjectPersistence({ if (!canAccessProtectedData) { return; } - const coverViewport = + const rawCoverViewport = canvasDisplayViewportToViewport(coverDisplayViewport); - const viewportSize = coverSnapshotViewportSizeRef.current; + const { viewport: coverViewport, viewportSize } = + normalizeProjectCoverSnapshotViewport({ + viewport: rawCoverViewport, + viewportSize: coverSnapshotViewportSizeRef.current, + }); const backgroundColor = refs.canvasBackgroundColorRef.current; const signature = buildProjectCoverSnapshotSignature({ layers: coverLayers, -- 2.52.0 From 08a251150f1d94c125215692e934c21e464d5471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 29 Jul 2026 14:29:54 +0800 Subject: [PATCH 4/6] Refactor project cover snapshot handling into a dedicated renderer, ensuring efficient layer filtering, caching, and concurrent image loading. --- docs/project-memory/shared-memory/pitfalls.md | 1 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...CanvasEditorGenerationIntegration.test.tsx | 13 +- .../ImageCanvasProjectCoverSnapshotModel.ts | 88 +--------- ...ImageCanvasProjectCoverSnapshotRenderer.ts | 155 ++++++++++++++++++ .../useImageCanvasProjectPersistence.test.tsx | 14 +- .../useImageCanvasProjectPersistence.ts | 16 +- .../editorMediaAssetUploadClient.ts | 45 +++-- 8 files changed, 217 insertions(+), 118 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9756b7ed8..24cd90211 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -367,6 +367,7 @@ - 原因:项目封面快照需要先在浏览器生成 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 正式持久化。 - 封面是弱一致的展示派生物,不是 layout 真相。只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独生成封面,也不为封面增加强一致的返回阻塞。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。 +- 封面生成不要为同一 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 8a268ac74..a5f4f2e6f 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:只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。封面只是弱一致的展示派生物,可以滞后于最新 layout;它不是项目真相,不得为了追求强一致而监听尺寸变化、增加保存频率或阻塞离开画布。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。封面只是弱一致的展示派生物,可以滞后于最新 layout;它不是项目真相,不得为了追求强一致而监听尺寸变化、增加保存频率或阻塞离开画布。`/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/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 8bf6dafd2..05a945f78 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({ diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index e6a4b5edb..80539acbf 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -1,4 +1,3 @@ -import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; import { DEFAULT_CANVAS_BACKGROUND_COLOR, normalizeCanvasBackgroundHex, @@ -19,10 +18,11 @@ export type ProjectCoverSnapshotViewportSize = { height: number; }; -type ProjectCoverDrawableLayer = { +export type ProjectCoverDrawableLayer = { id: string; src: string; objectKey: string | null; + cacheVersion: string; x: number; y: number; width: number; @@ -94,7 +94,7 @@ export function normalizeProjectCoverSnapshotViewport({ }; } -function normalizeProjectCoverBackgroundColor(backgroundColor: string) { +export function normalizeProjectCoverBackgroundColor(backgroundColor: string) { return ( normalizeCanvasBackgroundHex(backgroundColor) ?? DEFAULT_CANVAS_BACKGROUND_COLOR @@ -117,6 +117,7 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), + cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`, }; } } @@ -129,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), }; } @@ -145,6 +149,7 @@ 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)), @@ -217,7 +222,7 @@ export function resolveProjectCoverSnapshotFrame({ }; } -function resolveProjectCoverPlacement( +export function resolveProjectCoverPlacement( viewport: CanvasViewport, viewportSize: ProjectCoverSnapshotViewportSize, layer: ProjectCoverDrawableLayer, @@ -234,78 +239,3 @@ function resolveProjectCoverPlacement( height: layer.height * frame.renderScale * frame.frameScale, }; } - -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, - viewportSize, - backgroundColor, -}: { - layers: readonly CanvasLayer[]; - viewport: CanvasViewport; - viewportSize: ProjectCoverSnapshotViewportSize; - backgroundColor: string; -}) { - 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.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); - context.fillRect(0, 0, canvas.width, canvas.height); - - let drawnLayerCount = 0; - for (const layer of coverLayers) { - try { - const image = await loadProjectCoverImage(layer); - const placement = resolveProjectCoverPlacement( - viewport, - viewportSize, - layer, - ); - context.drawImage( - image, - placement.x, - placement.y, - placement.width, - placement.height, - ); - drawnLayerCount += 1; - } catch { - // Individual layer load failures should not block a cover from other layers. - } - } - - if (drawnLayerCount === 0) { - return null; - } - return canvasToBlob(canvas); -} diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts new file mode 100644 index 000000000..fb6493c94 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts @@ -0,0 +1,155 @@ +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 drawItems = resolveProjectCoverDrawableLayers(layers) + .map((layer) => ({ + layer, + placement: resolveProjectCoverPlacement(viewport, viewportSize, layer), + })) + .filter(({ placement }) => placementIntersectsCover(placement)); + if (drawItems.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.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); + context.fillRect(0, 0, canvas.width, canvas.height); + + 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/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 49c56c1d7..fc11ca2f3 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -27,15 +27,9 @@ 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('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ + createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock, +})); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -51,7 +45,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ - uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetFileMock, })); vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({ diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 7f1c48a8a..86f172567 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -8,7 +8,7 @@ import { import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes'; import { ApiClientError } from '../../services/apiClient'; -import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; +import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient'; import { createEditorProjectResource, type EditorProjectResourceSnapshot, @@ -35,13 +35,13 @@ import type { } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, - createProjectCoverSnapshotBlob, normalizeProjectCoverSnapshotViewport, PROJECT_COVER_SNAPSHOT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND, PROJECT_COVER_SNAPSHOT_SIZE, type ProjectCoverSnapshotViewportSize, } from './ImageCanvasProjectCoverSnapshotModel'; +import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -506,10 +506,14 @@ export function useImageCanvasProjectPersistence({ projectId: nextProjectId, blob, }); - const coverFile = new File([blob], `${nextProjectId}-cover.png`, { - type: 'image/png', - }); - const uploadedCover = await uploadEditorMediaAssetFile( + const coverFileExtension = + blob.type === 'image/webp' ? 'webp' : 'png'; + const coverFile = new File( + [blob], + `${nextProjectId}-cover.${coverFileExtension}`, + { type: blob.type || 'image/webp' }, + ); + const uploadedCover = await uploadEditorMediaAssetObjectFile( coverFile, 'image', { diff --git a/src/services/image-editor/editorMediaAssetUploadClient.ts b/src/services/image-editor/editorMediaAssetUploadClient.ts index 9efadd72e..366676891 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.ts @@ -32,6 +32,11 @@ export type UploadedEditorMediaAsset = { legacyPublicPath: string; }; +export type UploadedEditorMediaAssetObject = Omit< + UploadedEditorMediaAsset, + 'src' +>; + export type EditorMediaAssetUploadOptions = { assetKind?: string; pathSegments?: string[]; @@ -69,11 +74,11 @@ function sanitizeEditorMediaFileName( return trimmedName || resolveFallbackFileName(mediaType); } -export async function uploadEditorMediaAssetFile( +export async function uploadEditorMediaAssetObjectFile( file: File, mediaType: EditorMediaAssetUploadType, options: EditorMediaAssetUploadOptions = {}, -): Promise { +): Promise { const assetKind = options.assetKind ?? resolveEditorMediaAssetKind(mediaType); const fileName = sanitizeEditorMediaFileName(file.name, mediaType); const contentType = file.type.trim() || 'application/octet-stream'; @@ -128,20 +133,34 @@ export async function uploadEditorMediaAssetFile( { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); - // 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。 - const signedUrl = await getSignedAssetReadUrl( - { - objectKey: confirmed.assetObject.objectKey, - expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS, - }, - undefined, - { bypassCache: true }, - ); - return { - src: signedUrl, objectKey: confirmed.assetObject.objectKey, assetObjectId: confirmed.assetObject.assetObjectId, legacyPublicPath: ticket.upload.legacyPublicPath, }; } + +export async function uploadEditorMediaAssetFile( + file: File, + mediaType: EditorMediaAssetUploadType, + options: EditorMediaAssetUploadOptions = {}, +): Promise { + const uploaded = await uploadEditorMediaAssetObjectFile( + file, + mediaType, + options, + ); + // 中文注释:只有需要立即预览的调用方才换签;只做持久化的封面路径直接复用 object-only 结果。 + const signedUrl = await getSignedAssetReadUrl( + { + objectKey: uploaded.objectKey, + expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS, + }, + undefined, + { bypassCache: true }, + ); + return { + src: signedUrl, + ...uploaded, + }; +} -- 2.52.0 From c826aa0f4164f5b149d5403128c0a8820a528216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 29 Jul 2026 15:00:59 +0800 Subject: [PATCH 5/6] fix merge error --- ...mageCanvasEditorAssetsIntegration.test.tsx | 9 +++-- ...CanvasEditorGenerationIntegration.test.tsx | 16 ++++++++ .../ImageCanvasEditorView.test-utils.ts | 37 +++++++++++++++++-- .../ImageCanvasEditorView.test.tsx | 9 +++-- 4 files changed, 59 insertions(+), 12 deletions(-) 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 05a945f78..a4458b743 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -410,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => { project: { projectId: input.projectId, title: '默认项目', + canvas: { revision: 1 }, viewport: { x: 0, y: 0, scale: 1 }, resources: [ ...projectResources, @@ -477,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: [ { @@ -546,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: [ { @@ -600,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: [ { @@ -645,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: [ { @@ -695,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: [ { @@ -1322,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: [ { @@ -1694,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, @@ -2329,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, @@ -2506,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: [ { @@ -2681,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: [ { @@ -2826,6 +2837,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2868,6 +2880,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3136,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: [ { @@ -3363,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: [ { @@ -3536,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..4c4a56e21 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,14 @@ describe('ImageCanvasEditorView', () => { }); it('shows the toolbar guide for a newly created blank project until a generator opens', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ + 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 +506,14 @@ describe('ImageCanvasEditorView', () => { }); it('opens the requested canvas startup tool from the project query once', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ + 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, '', -- 2.52.0 From 2ced33a23254c61081af1fe9c84099e4cb80cf93 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 29 Jul 2026 15:59:42 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=89=8D=E5=B0=81=E9=9D=A2=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 返回项目页前立即刷新并等待权威画布布局保存 等待本地封面缓存、OSS 上传和项目资源创建完成 离屏视口生成纯背景封面并补充回归测试 同步图片画布封面技术文档和共享记忆 --- .../shared-memory/decision-log.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasEditorView.test.tsx | 50 +++--- .../image-editor/ImageCanvasEditorView.tsx | 34 +++-- .../ImageCanvasProjectCoverSnapshotModel.ts | 5 +- ...CanvasProjectCoverSnapshotRenderer.test.ts | 54 +++++++ ...ImageCanvasProjectCoverSnapshotRenderer.ts | 21 +-- .../ImageCanvasTopbarView.test.tsx | 32 +++- .../image-editor/ImageCanvasTopbarView.tsx | 18 +++ .../useImageCanvasProjectPersistence.test.tsx | 70 ++++++++- .../useImageCanvasProjectPersistence.ts | 143 +++++++++++++++--- 12 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 740ca6c4a..bc2d27490 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -556,8 +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 b540e084d..8bafd41c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -366,7 +366,7 @@ - 现象:画布项目已反复打开、保存或操作,但 `/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 正式持久化。 -- 封面是弱一致的展示派生物,不是 layout 真相。只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独生成封面,也不为封面增加强一致的返回阻塞。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。 +- 封面是展示派生物,不是 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 6fd0e61a4..a5536fdd7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -58,7 +58,7 @@ - 新增 `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 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。封面只是弱一致的展示派生物,可以滞后于最新 layout;它不是项目真相,不得为了追求强一致而监听尺寸变化、增加保存频率或阻塞离开画布。`/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 或临时图片源。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 4c4a56e21..9c171e9e1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -458,14 +458,16 @@ describe('ImageCanvasEditorView', () => { }); it('shows the toolbar guide for a newly created blank project until a generator opens', async () => { - loadEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({ - 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, '', @@ -506,14 +508,16 @@ describe('ImageCanvasEditorView', () => { }); it('opens the requested canvas startup tool from the project query once', async () => { - loadEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({ - 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, '', @@ -529,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( @@ -539,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 549d75dd7..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,6 +1158,7 @@ export function ImageCanvasEditorView({ isProjectReady, appendCanvasLayersWithResources, applyProjectSnapshot, + flushProjectPersistence, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, @@ -1173,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, ); @@ -1369,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, @@ -1478,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); } @@ -2002,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) { @@ -2310,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.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index 80539acbf..4b4aabba3 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -153,7 +153,10 @@ export function resolveProjectCoverDrawableLayers( 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; }) 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 index fb6493c94..0c790b8ec 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts @@ -35,11 +35,9 @@ function placementIntersectsCover( async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) { if (layer.objectKey) { - return getSignedAssetReadUrl( - { objectKey: layer.objectKey }, - undefined, - { cacheVersion: layer.cacheVersion }, - ); + return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, { + cacheVersion: layer.cacheVersion, + }); } if (shouldResolveAssetReadUrl(layer.src)) { return resolveAssetReadUrl(layer.src, { @@ -113,15 +111,16 @@ export async function createProjectCoverSnapshotBlob({ if (typeof document === 'undefined') { return null; } - const drawItems = resolveProjectCoverDrawableLayers(layers) + 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)); - if (drawItems.length === 0) { - return null; - } const canvas = document.createElement('canvas'); canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; @@ -132,6 +131,10 @@ export async function createProjectCoverSnapshotBlob({ 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) => { 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); + }); + }} >