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] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=B0=81=E9=9D=A2=E8=A7=86?= =?UTF-8?q?=E5=8F=A3=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=9C=80=E5=B0=8F=E5=8F=96=E6=99=AF=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E4=B8=BA=201280x960=20=E4=B8=94=E5=B1=85=E4=B8=AD=E8=A3=81?= =?UTF-8?q?=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,