diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 96f75b1b8..29d29c4f3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,16 @@ --- +## 2026-07-29 角色带背景原图与透明图统一交付尺寸 + +- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。 +- 决策:角色分支必须在持久化带纯色背景原图和调用 BgFilter 之前,先按统一业务像素矩阵执行一次尺寸归一;该原图和透明派生图始终使用同一实际像素尺寸,1K 的长边为 `1024`。若 provider 回图任意一边小于业务目标或比例偏差过大,仍禁止放大或大幅裁切;此时两张图一同保留 provider 实际尺寸并返回通用 `warning`,不允许只改透明图。BgFilter 回图尺寸漂移时只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并回贴到该原图;蒙版比例超限、回贴失败或尺寸验证失败时必须改用原图单产物降级,不持久化尺寸或比例不一致的透明图。若尺寸降级和后处理降级同时发生,同一条 `warning.reason` 必须同时保留两个原因。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色生成、原图持久化、BgFilter 输入、项目资源尺寸与画布图层 Resolution;不改变前端请求 DTO、扣费、素材类型或多产物布局。 +- 验证方式:后端定向测试覆盖角色全尺寸矩阵:`nanobanana2` 的 `0.5K / 1K / 2K` 和 `gpt-image-2` 的 `1K / 2K`,每档均覆盖 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`,30 个组合全部构造大于目标尺寸的真实 PNG provider 回图并执行像素恢复,不只校验字符串映射;另覆盖欠尺寸禁止放大、比例超限、BgFilter 错比例 alpha 蒙版拒绝和组合告警。同时从函数调用顺序上固定“尺寸归一 → 持久化带背景原图 → BgFilter”。运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-29 图标图集 BgFilter 开启 cross-check - 背景:图标 spritesheet 的透明化需要提高主体内部孔洞、轮廓和相邻小图标边缘的交叉校验质量。 @@ -566,7 +576,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`。 @@ -4525,7 +4537,7 @@ - 契约:普通图片 / 角色共用的图片生成请求和图标图集生成请求增加可选字符串 `style`,当前公开合法值为 `none / pixelArt`。省略、`null`、空字符串和 `none` 归一为内部 `None` 且不告警;未知字符串、或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `pixelArt` 时,按 `None` 继续原管线并返回 `unsupported-image-style` 通用告警;非字符串 JSON 返回 `400`。旧队列 payload 缺少字段时兼容为 `None`。 - UI 边界:只有普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 显示 `像素艺术` 勾选项;当前选择可进入已有生成器快照和请求 / 队列 payload,但不写入 `generationInputs`、素材元数据或新表。画布 Agent 和其它生成 / 编辑入口不开放该选项。 -- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。 +- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。2026-07-29 合并「角色带背景原图与透明图统一交付尺寸」后本条修订:像素模式不再豁免提前归一,网格分析源是已按业务像素矩阵 `resize_to_fill`(Lanczos 重采样 + 居中裁切)后的交付尺寸平底图,不再是 provider 原生分辨率图;像素规整在交付尺寸上完成、由 snapper 自行还原回输入尺寸,因此不再执行后置的 nearest 二次恢复。 - 执行边界:像素规整 CPU 工作使用进程级最大并发 2;取得并发许可的排队时间与实际处理时间共享最多 30 秒预算,同时不得晚于当前请求 deadline,最终取更早者。输入图片任一边上限为 10000 像素、总像素上限为 8294400;超限、排队超时或处理超时均按 best-effort 非致命降级,不持久化部分结果。 - 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。 - 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 35c409ae2..8bafd41c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -366,7 +366,9 @@ - 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。 - 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。 - 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。 -- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。 +- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。 +- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。 +- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。 - 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。 ## 图片画布框选预览要复用源图换签缓存 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index b05ff5432..792db3f8b 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -70,7 +70,8 @@ - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount`、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - 新增 `editor_showcase_asset`、`editor_showcase_asset_like` 和 `editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 -- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP(quality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景;存在 drawable 图层但当前取景全部离屏时仍输出纯背景封面。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。常规编辑允许封面滞后于最新 layout,但用户主动返回项目页时必须取消待执行防抖,以当前权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存、上传 OSS 和创建项目资源后再导航;不得用 `beforeunload` 代替受控返回。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 @@ -104,7 +105,7 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3134c8d00..f36f40205 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -125,6 +125,7 @@ const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-sou const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; +const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { @@ -655,6 +656,19 @@ fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse { editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。") } +fn editor_postprocess_fallback_warning_with_dimension( + reason: &'static str, + dimension_warning: Option<&EditorGenerationWarningResponse>, +) -> EditorGenerationWarningResponse { + let reason = dimension_warning + .map(|warning| format!("{reason} {}", warning.reason)) + .unwrap_or_else(|| reason.to_string()); + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE, + reason, + } +} + fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { let reason = error .details() @@ -1864,25 +1878,19 @@ pub(crate) async fn generate_editor_image_for_owner( // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, // 保证不同模型的完成图与生成前占位标注一致。 + // 角色带背景原图和透明结果是同一个业务产物的两个版本,必须在 + // provider 原图持久化前就收口到同一交付尺寸。否则 1K 任务会把模型原生 + // 2752x1536 原图和长边 1024 的透明图同时放入画布。像素模式不再豁免, + // 逻辑像素规整直接在归一并居中裁切后的交付尺寸图上进行。 let (restored_image, dimension_restore_error) = - if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt { - // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; - // 透明主结果会在后处理完成后再归一到画布业务规格。像素模式同样必须 - // 先完成逻辑像素规整,再用 nearest 恢复交付尺寸,不能提前走 Lanczos。 - (image, None) - } else { - restore_editor_generated_image_output_dimensions_or_original( - image, - generation_options.model, - image_size.as_ref(), - ) - }; - generation_warning = merge_editor_generation_warnings( - generation_warning, - dimension_restore_error - .as_ref() - .map(editor_dimension_restore_warning), - ); + restore_editor_generated_image_output_dimensions_or_original( + image, + generation_options.model, + image_size.as_ref(), + ); + let dimension_warning = dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -1903,7 +1911,6 @@ pub(crate) async fn generate_editor_image_for_owner( let mut output_model = generation_options.model.to_string(); let mut output_provider = "VectorEngine".to_string(); let mut output_generation_inputs = generation_inputs.clone(); - let mut pixel_art_applied = false; let source_record = if is_character_generation { let source_persisted = persist_editor_provider_source_image( state, @@ -2005,8 +2012,9 @@ pub(crate) async fn generate_editor_image_for_owner( project: completed_project, warning: merge_editor_generation_warnings( generation_warning, - Some(editor_postprocess_fallback_warning( + Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", + dimension_warning.as_ref(), )), ), }, @@ -2036,8 +2044,6 @@ pub(crate) async fn generate_editor_image_for_owner( .await; (image, error, None) }; - pixel_art_applied = - image_style == EditorImageGenerationStyle::PixelArt && pixel_art_error.is_none(); if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, @@ -2047,8 +2053,39 @@ pub(crate) async fn generate_editor_image_for_owner( delivery_width, delivery_height, error = ?error, - "角色透明图尺寸恢复失败,保留去背景服务原始输出" + "角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布" ); + let completed_project = complete_editor_canvas_generation( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record.resource.as_ref(), + ) + .await?; + return Ok(json_success_body( + Some(&request_context), + EditorImageGenerationResponse { + image_src: source_image_src, + object_key: Some(source_object_key), + asset_object_id: Some(source_asset_object_id), + width: delivery_width, + height: delivery_height, + source_type: "generated", + prompt: role_setting, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + resource: source_record.resource, + asset: source_record.asset, + project: completed_project, + warning: Some(editor_postprocess_fallback_warning_with_dimension( + "生成任务成功,后处理失败。", + dimension_warning.as_ref(), + )), + }, + )); } if let Some(error) = pixel_art_error { tracing::warn!( @@ -2074,12 +2111,13 @@ pub(crate) async fn generate_editor_image_for_owner( None }; + // 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行,snapper 会把结果 + // 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。 if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt { let (pixel_art_image, pixel_art_error) = snap_editor_pixel_art_or_original(image, request_context.external_call_deadline()) .await; image = pixel_art_image; - pixel_art_applied = pixel_art_error.is_none(); if let Some(error) = pixel_art_error { tracing::warn!( task_id = %generated.task_id, @@ -2093,35 +2131,6 @@ pub(crate) async fn generate_editor_image_for_owner( } } - if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt { - let (restored_image, dimension_restore_error) = - restore_editor_generated_image_output_dimensions_or_original_with_filter( - image, - generation_options.model, - image_size.as_ref(), - if pixel_art_applied { - image::imageops::FilterType::Nearest - } else { - image::imageops::FilterType::Lanczos3 - }, - ); - if let Some(error) = dimension_restore_error { - generation_warning = merge_editor_generation_warnings( - generation_warning, - Some(editor_dimension_restore_warning(&error)), - ); - tracing::warn!( - task_id = %generated.task_id, - provider_width, - provider_height, - target_size = %image_size, - error = ?error, - "画板角色透明主图尺寸恢复失败,保留后处理原图" - ); - } - image = restored_image; - } - let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2214,7 +2223,8 @@ pub(crate) async fn generate_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: generation_warning, + // 中文注释:成功路径同时收口尺寸归一降级与像素规整等生成告警。 + warning: merge_editor_generation_warnings(generation_warning, dimension_warning), }, )) } @@ -3011,6 +3021,28 @@ fn apply_editor_postprocessed_alpha_to_provider_source_with_policy( return Ok(None); } + let source_ratio = f64::from(provider_source.width()) / f64::from(provider_source.height()); + let postprocessed_ratio = f64::from(postprocessed.width()) / f64::from(postprocessed.height()); + let ratio_drift = if source_ratio >= postprocessed_ratio { + 1.0 - postprocessed_ratio / source_ratio + } else { + 1.0 - source_ratio / postprocessed_ratio + }; + if ratio_drift > EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "code": "postprocessed-alpha-aspect-ratio-mismatch", + "message": "透明后处理图比例与原图偏差过大,禁止拉伸 Alpha 蒙版", + "sourceWidth": provider_source.width(), + "sourceHeight": provider_source.height(), + "postprocessedWidth": postprocessed.width(), + "postprocessedHeight": postprocessed.height(), + "ratioDrift": ratio_drift, + })), + ); + } + // 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。 // 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。 let postprocessed = postprocessed.to_rgba8(); @@ -9049,6 +9081,39 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + fn static_image_postprocess_rejects_wrong_aspect_ratio_alpha_mask() { + let provider_source = image::DynamicImage::new_rgba8(1024, 576); + let mut provider_source_bytes = Cursor::new(Vec::new()); + provider_source + .write_to(&mut provider_source_bytes, image::ImageFormat::Png) + .expect("provider source should encode"); + let provider_source = DownloadedOpenAiImage { + bytes: provider_source_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let postprocessed = image::DynamicImage::new_rgba8(512, 512); + let mut postprocessed_bytes = Cursor::new(Vec::new()); + postprocessed + .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) + .expect("postprocessed image should encode"); + let postprocessed = DownloadedOpenAiImage { + bytes: postprocessed_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let error = + apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) + .expect_err("wrong-aspect alpha masks must use source-only fallback"); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!("postprocessed-alpha-aspect-ratio-mismatch")) + ); + } + #[test] fn pixel_art_sampling_uses_provider_rgb_even_when_bgfilter_dimensions_match() { let provider_source = image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 10, 20, 255])); @@ -9463,6 +9528,85 @@ mod tests { assert_eq!(fallback.provider_image_size, "1K"); } + #[test] + fn character_generation_covers_full_model_ratio_and_size_matrix() { + for (image_size, aspect_ratio, expected_delivery_size) in [ + ("0.5K", "1:1", "512x512"), + ("0.5K", "4:3", "512x384"), + ("0.5K", "3:2", "512x341"), + ("0.5K", "2:3", "341x512"), + ("0.5K", "9:16", "288x512"), + ("0.5K", "16:9", "512x288"), + ("1K", "1:1", "1024x1024"), + ("1K", "4:3", "1024x768"), + ("1K", "3:2", "1024x683"), + ("1K", "2:3", "683x1024"), + ("1K", "9:16", "576x1024"), + ("1K", "16:9", "1024x576"), + ("2K", "1:1", "2048x2048"), + ("2K", "4:3", "2048x1536"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), + ("2K", "9:16", "1152x2048"), + ("2K", "16:9", "2048x1152"), + ] { + let mut models = vec![EDITOR_IMAGE_MODEL_NANOBANANA2]; + if image_size != "0.5K" { + models.push(GPT_IMAGE_2_MODEL); + } + for model in models { + let (expected_width, expected_height) = + parse_editor_image_edit_pixel_size(expected_delivery_size) + .expect("delivery matrix should contain valid pixel dimensions"); + let options = normalize_editor_generation_options( + Some(model), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!( + options.size, expected_delivery_size, + "{model} {image_size} {aspect_ratio} should use the shared delivery matrix" + ); + assert_eq!( + resolve_editor_image_request_size(Some("character"), None, true, &options,), + expected_delivery_size, + "character source and cutout should share {model} {image_size} {aspect_ratio} delivery dimensions" + ); + + let provider_width = (expected_width * 9).div_ceil(8); + let provider_height = (expected_height * 9).div_ceil(8); + let provider_image = + image::DynamicImage::new_luma8(provider_width, provider_height); + let mut provider_bytes = Cursor::new(Vec::new()); + provider_image + .write_to(&mut provider_bytes, image::ImageFormat::Png) + .expect("synthetic provider image should encode"); + let provider_output = DownloadedOpenAiImage { + bytes: provider_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let (restored, restore_error) = + restore_editor_generated_image_output_dimensions_or_original( + provider_output, + model, + expected_delivery_size, + ); + assert!( + restore_error.is_none(), + "{model} {image_size} {aspect_ratio} should restore a larger provider image" + ); + let restored = image::load_from_memory(restored.bytes.as_slice()) + .expect("restored character source should remain a valid image"); + assert_eq!( + (restored.width(), restored.height()), + (expected_width, expected_height), + "{model} {image_size} {aspect_ratio} should restore real pixels before background removal" + ); + } + } + } + #[test] fn publication_material_request_size_keeps_workflow_pixels() { let options = @@ -10654,7 +10798,11 @@ mod tests { } #[test] - fn editor_character_postprocess_fallback_response_keeps_source_and_warning() { + fn editor_character_postprocess_fallback_response_keeps_source_and_dimension_warning() { + let dimension_warning = EditorGenerationWarningResponse { + code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, + reason: "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位".to_string(), + }; let response = EditorImageGenerationResponse { image_src: "/api/assets/source-character.png".to_string(), object_key: Some("generated/editor/character-source.png".to_string()), @@ -10670,8 +10818,9 @@ mod tests { resource: None, asset: None, project: None, - warning: Some(editor_postprocess_fallback_warning( + warning: Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", + Some(&dimension_warning), )), }; @@ -10691,7 +10840,9 @@ mod tests { ); assert_eq!( payload["warning"]["reason"], - json!("生成任务成功,后处理失败。") + json!( + "生成任务成功,后处理失败。 provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位" + ) ); } @@ -11822,7 +11973,10 @@ mod tests { "complete_editor_canvas_generation", "source_record.resource.as_ref()", warning_assignment, - "editor_postprocess_fallback_warning(", + // 中文注释:普通图片与角色分支的后处理降级分别使用 + // editor_postprocess_fallback_warning 和 _with_dimension, + // 断言只固定共同前缀,不锁定具体重载。 + "editor_postprocess_fallback_warning", ], ); @@ -11882,6 +12036,12 @@ mod tests { .expect("character generation function end marker should exist"); let body = &function_tail[..end]; + let dimension_restore = body + .find("restore_editor_generated_image_output_dimensions_or_original") + .expect("character provider image should be restored to delivery dimensions"); + let source_persist = body + .find("persist_editor_provider_source_image") + .expect("character provider source should be persisted"); let phase = body .find("caller.report_processing_phase(state).await?") .expect("phase report should remain fallible"); @@ -11901,13 +12061,18 @@ mod tests { phase < removal, "phase errors must fail before fallback is considered" ); + assert!( + dimension_restore < source_persist && source_persist < removal, + "character delivery dimensions must be restored before source persistence and background removal" + ); for snippet in [ "Err(error)", "complete_editor_canvas_generation", "source_record.resource.as_ref()", "return Ok(json_success_body", "warning: merge_editor_generation_warnings(", - "editor_postprocess_fallback_warning(", + // 中文注释:角色分支现在走带尺寸告警的重载,断言只固定共同前缀。 + "editor_postprocess_fallback_warning", ] { assert!( fallback.contains(snippet), @@ -11930,6 +12095,30 @@ mod tests { assert!(success_tail.contains("persist_editor_generated_image(")); assert!(success_tail.contains("persist_editor_generated_asset(")); assert!(success_tail.contains(".await?;")); + + let alpha_restore = body + .find("apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original") + .expect("character postprocess should restore alpha to the source dimensions"); + let dimension_fallback = body[alpha_restore..] + .find("if let Some(error) = postprocess_dimension_error") + .map(|offset| alpha_restore + offset) + .expect("character postprocess dimension errors should be handled"); + let processed_image = body[dimension_fallback..] + .find("image = restored_removal_image;") + .map(|offset| dimension_fallback + offset) + .expect("processed image should only continue after dimension validation"); + let dimension_fallback_body = &body[dimension_fallback..processed_image]; + for snippet in [ + "complete_editor_canvas_generation", + "source_record.resource.as_ref()", + "return Ok(json_success_body", + "warning: Some(editor_postprocess_fallback_warning", + ] { + assert!( + dimension_fallback_body.contains(snippet), + "character dimension fallback should contain {snippet}" + ); + } } #[test] 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 0202a3d50..551005038 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -74,17 +74,12 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, + uploadEditorMediaAssetObjectFile: vi.fn(), })); -vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => { - const actual = await vi.importActual< - typeof import('./ImageCanvasProjectCoverSnapshotModel') - >('./ImageCanvasProjectCoverSnapshotModel'); - return { - ...actual, - createProjectCoverSnapshotBlob: vi.fn(async () => null), - }; -}); +vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ + createProjectCoverSnapshotBlob: vi.fn(async () => null), +})); describe('ImageCanvasEditorView generation integration', () => { setupImageCanvasEditorViewTestLifecycle({ @@ -415,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => { project: { projectId: input.projectId, title: '默认项目', + canvas: { revision: 1 }, viewport: { x: 0, y: 0, scale: 1 }, resources: [ ...projectResources, @@ -482,6 +478,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-dialog', title: '已生成画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -551,6 +548,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-layer-only', title: '已生成画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -605,6 +603,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-uploaded-tagged-layer', title: '标签素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -650,6 +649,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-context-tagged-layer', title: '右键标签素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -700,6 +700,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-publication-dialog', title: '宣发画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1338,6 +1339,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons-generating', title: '图标素材生成中画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1710,6 +1712,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, @@ -2345,6 +2348,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, @@ -2522,6 +2526,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons', title: '图标素材画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2697,6 +2702,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: [ { @@ -2842,6 +2848,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -2884,6 +2891,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3152,6 +3160,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-character-animation', title: '角色动画画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -3379,6 +3388,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: [ { @@ -3552,6 +3562,7 @@ describe('ImageCanvasEditorView generation integration', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-ui-extract-dismiss', title: 'UI素材提取外部关闭画布', + canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 2d5082721..2fee555f1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -4,6 +4,11 @@ import type { ContextType } from 'react'; import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; +import type { + EditorProjectLayerSnapshot, + EditorProjectResourceSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; @@ -73,7 +78,7 @@ export const defaultEditorProjectResources = [ height: 405, sourceType: 'uploaded', }, -]; +] satisfies EditorProjectResourceSnapshot[]; export const defaultEditorProjectLayers = [ { @@ -102,7 +107,31 @@ export const defaultEditorProjectLayers = [ zIndex: 2, sourceType: 'uploaded', }, -]; +] satisfies EditorProjectLayerSnapshot[]; + +export function withEditorProjectCanvasRevision< + T extends EditorProjectSnapshot, +>(project: T, revision = 0): T { + if (typeof project.canvas?.revision === 'number') { + return project; + } + return { + ...project, + canvas: { + canvasId: + project.canvas?.canvasId ?? `${project.projectId}:canvas:default`, + projectId: project.projectId, + title: project.canvas?.title ?? '默认画布', + viewport: project.canvas?.viewport ?? project.viewport, + layers: project.canvas?.layers ?? project.layers, + revision, + layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0, + backgroundColor: project.canvas?.backgroundColor, + createdAt: project.canvas?.createdAt, + updatedAt: project.canvas?.updatedAt ?? project.updatedAt, + }, + }; +} export const defaultEditorAssetLibraryAssets = [ { @@ -235,14 +264,14 @@ export function setupImageCanvasEditorViewTestLifecycle({ window.sessionStorage.clear(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); loadOrCreateRecentEditorProjectMock.mockImplementation(() => - immediateAsync({ + immediateAsync(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', - }), + })), ); loadEditorAssetLibraryMock.mockImplementation(() => immediateAsync({ diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 9f0360f90..9c171e9e1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -26,6 +26,7 @@ import { immediateAsync, readZipText, setupImageCanvasEditorViewTestLifecycle, + withEditorProjectCanvasRevision, } from './ImageCanvasEditorView.test-utils'; type EditorAgentListConversations = @@ -457,14 +458,16 @@ describe('ImageCanvasEditorView', () => { }); it('shows the toolbar guide for a newly created blank project until a generator opens', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-guide', - title: '空白项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); + loadEditorProjectMock.mockResolvedValueOnce( + withEditorProjectCanvasRevision({ + projectId: 'editor-project-guide', + title: '空白项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }), + ); window.history.replaceState( null, '', @@ -505,14 +508,16 @@ describe('ImageCanvasEditorView', () => { }); it('opens the requested canvas startup tool from the project query once', async () => { - loadEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-music', - title: '音乐项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); + loadEditorProjectMock.mockResolvedValueOnce( + withEditorProjectCanvasRevision({ + projectId: 'editor-project-music', + title: '音乐项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }), + ); window.history.replaceState( null, '', @@ -528,7 +533,9 @@ describe('ImageCanvasEditorView', () => { expect(window.location.search).toBe('?projectid=editor-project-music'); }); - it('shows the loaded project title and a topbar entry back to projects', async () => { + it('flushes project persistence before returning from the topbar', async () => { + const onPopState = vi.fn(); + window.addEventListener('popstate', onPopState); render(); expect( @@ -538,6 +545,16 @@ describe('ImageCanvasEditorView', () => { expect(projectLink.getAttribute('href')).toBe('/project'); expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); + saveEditorProjectLayoutMock.mockClear(); + + fireEvent.click(projectLink); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); + expect(window.location.pathname).toBe('/project'); + expect(onPopState).toHaveBeenCalledTimes(1); + }); + window.removeEventListener('popstate', onPopState); }); it('opens login modal when the asset library is unauthorized', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c30236015..85243ec91 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes'; import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService'; import { createEditorAsset, @@ -310,9 +311,9 @@ export function ImageCanvasEditorView({ const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR); - const captureCanvasHistoryRef = useRef< - (action: CanvasHistoryAction) => void - >(() => {}); + const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>( + () => {}, + ); const discardCanvasHistoryEntriesContainingLayerRef = useRef< (matchesLayer: (layer: CanvasLayer) => boolean) => void >(() => {}); @@ -790,10 +791,8 @@ export function ImageCanvasEditorView({ ); const canvasHistoryEffects = useMemo( () => ({ - persistRestoredAssetKind: ( - layer: CanvasLayer, - historyEntryId?: number, - ) => persistRestoredAssetKindRef.current(layer, historyEntryId), + persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) => + persistRestoredAssetKindRef.current(layer, historyEntryId), }), [], ); @@ -1159,12 +1158,14 @@ export function ImageCanvasEditorView({ isProjectReady, appendCanvasLayersWithResources, applyProjectSnapshot, + flushProjectPersistence, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, layers, canvasGenerationDialogs, viewport, + canvasSize, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, @@ -1172,6 +1173,11 @@ export function ImageCanvasEditorView({ openEditorLoginModal, onProjectAccessLost, }); + const returnToProjects = useCallback(async () => { + await flushProjectPersistence(); + replaceAppHistoryPath('/project'); + window.dispatchEvent(new PopStateEvent('popstate')); + }, [flushProjectPersistence]); const setEditorProjectContextId = useImageCanvasContextStore( (state) => state.setProjectId, ); @@ -1368,11 +1374,7 @@ export function ImageCanvasEditorView({ const rollbackLayer = layersRef.current.find( (currentLayer) => currentLayer.id === layer.id, ); - persistUpdatedLayerResource( - layer, - rollbackLayer ?? layer, - historyEntryId, - ); + persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId); }; const { assetExportStatus, @@ -1477,7 +1479,9 @@ export function ImageCanvasEditorView({ if (!projectId || tasks.length === 0) { return; } - const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim(); + const warning = tasks + .find((task) => task.warning?.trim()) + ?.warning?.trim(); if (warning) { showGenerationWarning(warning); } @@ -2001,9 +2005,7 @@ export function ImageCanvasEditorView({ return; } captureCanvasHistory({ - type: targetLayerIds.length - ? 'move-image' - : 'move-generation-result', + type: targetLayerIds.length ? 'move-image' : 'move-generation-result', count: targetLayerIds.length || targetDialogIds.length, }); if (targetLayerIds.length) { @@ -2309,6 +2311,7 @@ export function ImageCanvasEditorView({ onRequestWalletDetails: loadRechargeCenter, onRecharge: openAccountPaymentModal, onOpenWalletLedger: openWalletLedgerPanel, + onReturnToProjects: returnToProjects, onOpenAccount: () => { if (authUi?.user) { authUi.openAccountModal(); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 063be7378..805f0d367 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildProjectCoverSnapshotSignature, + normalizeProjectCoverSnapshotViewport, resolveProjectCoverDrawableLayers, + resolveProjectCoverSnapshotFrame, } from './ImageCanvasProjectCoverSnapshotModel'; function layer(overrides: Partial = {}): CanvasLayer { @@ -25,9 +27,33 @@ function layer(overrides: Partial = {}): CanvasLayer { } describe('ImageCanvasProjectCoverSnapshotModel', () => { + it('uses at least a centered 1280 by 960 viewport while preserving larger viewports', () => { + expect( + normalizeProjectCoverSnapshotViewport({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + }), + ).toEqual({ + viewport: { x: 202, y: 152, scale: 0.5 }, + viewportSize: { width: 1280, height: 960 }, + }); + + expect( + normalizeProjectCoverSnapshotViewport({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 1920, height: 1080 }, + }), + ).toEqual({ + viewport: { x: 12, y: -8, scale: 0.5 }, + viewportSize: { width: 1920, height: 1080 }, + }); + }); + it('builds a stable cover signature from visible drawable layers', () => { const signature = buildProjectCoverSnapshotSignature({ viewport: { x: 12, y: -8, scale: 0.75 }, + viewportSize: { width: 900, height: 640 }, + backgroundColor: '#f8fafc', layers: [ layer({ id: 'hidden', hidden: true }), layer({ @@ -49,12 +75,36 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { }); expect(signature).toContain('"scale":0.75'); + expect(signature).toContain('"width":900'); + expect(signature).toContain('"backgroundColor":"#f8fafc"'); expect(signature).toContain('"id":"back"'); expect(signature).toContain('"id":"front"'); expect(signature).not.toContain('"id":"hidden"'); expect(signature).not.toContain('"id":"audio"'); }); + it('center-crops the live canvas viewport to fill the 4:3 cover', () => { + const landscapeFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: -260, y: 70, scale: 0.5 }, + viewportSize: { width: 900, height: 640 }, + }); + + expect(landscapeFrame.renderScale).toBe(0.5); + expect(landscapeFrame.frameScale).toBeCloseTo(0.375); + expect(landscapeFrame.offsetX).toBeCloseTo(-8.75); + expect(landscapeFrame.offsetY).toBeCloseTo(0); + + const portraitFrame = resolveProjectCoverSnapshotFrame({ + viewport: { x: 30, y: -40, scale: 1.25 }, + viewportSize: { width: 640, height: 900 }, + }); + + expect(portraitFrame.renderScale).toBe(1.25); + expect(portraitFrame.frameScale).toBeCloseTo(0.5); + expect(portraitFrame.offsetX).toBeCloseTo(0); + expect(portraitFrame.offsetY).toBeCloseTo(-105); + }); + it('uses the first image-sequence frame as the drawable source', () => { expect( resolveProjectCoverDrawableLayers([ diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index c56250b31..4b4aabba3 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -1,19 +1,28 @@ -import { getSignedAssetReadUrl } from '../../services/assetReadUrlService'; +import { + DEFAULT_CANVAS_BACKGROUND_COLOR, + normalizeCanvasBackgroundHex, +} from './ImageCanvasEditorModel'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot'; export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND = 'editor_project_cover_snapshot'; export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const; -export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = { - width: 900, - height: 640, +export const PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE = { + width: 1280, + height: 960, } as const; -type ProjectCoverDrawableLayer = { +export type ProjectCoverSnapshotViewportSize = { + width: number; + height: number; +}; + +export type ProjectCoverDrawableLayer = { id: string; src: string; objectKey: string | null; + cacheVersion: string; x: number; y: number; width: number; @@ -25,6 +34,73 @@ function numberOrFallback(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback; } +function positiveNumberOrFallback(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function normalizeProjectCoverViewportSize( + viewportSize: ProjectCoverSnapshotViewportSize, +) { + return { + width: positiveNumberOrFallback( + viewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.width, + ), + height: positiveNumberOrFallback( + viewportSize.height, + PROJECT_COVER_SNAPSHOT_SIZE.height, + ), + }; +} + +export function normalizeProjectCoverSnapshotViewport({ + viewport, + viewportSize, +}: { + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; +}) { + const sourceViewportSize = { + width: positiveNumberOrFallback( + viewportSize.width, + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, + ), + height: positiveNumberOrFallback( + viewportSize.height, + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, + ), + }; + const normalizedViewportSize = { + width: Math.max( + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width, + sourceViewportSize.width, + ), + height: Math.max( + PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height, + sourceViewportSize.height, + ), + }; + return { + viewport: { + x: + numberOrFallback(viewport.x, 0) + + (normalizedViewportSize.width - sourceViewportSize.width) / 2, + y: + numberOrFallback(viewport.y, 0) + + (normalizedViewportSize.height - sourceViewportSize.height) / 2, + scale: numberOrFallback(viewport.scale, 1), + }, + viewportSize: normalizedViewportSize, + }; +} + +export function normalizeProjectCoverBackgroundColor(backgroundColor: string) { + return ( + normalizeCanvasBackgroundHex(backgroundColor) ?? + DEFAULT_CANVAS_BACKGROUND_COLOR + ); +} + function normalizeObjectKey(value: string | null | undefined) { const normalized = value?.trim().replace(/^\/+/u, '') ?? ''; return normalized || null; @@ -41,6 +117,7 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), + cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`, }; } } @@ -53,6 +130,9 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { objectKey: layer.thumbnailSrc?.trim() ? null : normalizeObjectKey(layer.objectKey), + cacheVersion: layer.thumbnailSrc?.trim() + ? `${layer.taskId ?? layer.resourceId}:poster` + : (layer.taskId ?? layer.resourceId), }; } @@ -69,10 +149,14 @@ export function resolveProjectCoverDrawableLayers( id: layer.id, src: source.src, objectKey: source.objectKey, + cacheVersion: source.cacheVersion, x: numberOrFallback(layer.x, 0), y: numberOrFallback(layer.y, 0), width: Math.max(1, numberOrFallback(layer.width, layer.originalWidth)), - height: Math.max(1, numberOrFallback(layer.height, layer.originalHeight)), + height: Math.max( + 1, + numberOrFallback(layer.height, layer.originalHeight), + ), zIndex: numberOrFallback(layer.zIndex, 0), } satisfies ProjectCoverDrawableLayer; }) @@ -83,116 +167,78 @@ export function resolveProjectCoverDrawableLayers( export function buildProjectCoverSnapshotSignature({ layers, viewport, + viewportSize, + backgroundColor, }: { layers: readonly CanvasLayer[]; + // we may call it camera viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; }) { const coverLayers = resolveProjectCoverDrawableLayers(layers); if (coverLayers.length === 0) { return null; } + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); return JSON.stringify({ viewport: { x: numberOrFallback(viewport.x, 0), y: numberOrFallback(viewport.y, 0), scale: numberOrFallback(viewport.scale, 1), }, + viewportSize: normalizedViewportSize, + backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor), layers: coverLayers, }); } -function resolveProjectCoverPlacement( - viewport: CanvasViewport, - layer: ProjectCoverDrawableLayer, -) { - const safeScale = +export function resolveProjectCoverSnapshotFrame({ + viewport, + viewportSize, +}: { + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; +}) { + const normalizedViewportSize = + normalizeProjectCoverViewportSize(viewportSize); + const renderScale = viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1; - const viewportCenterX = - (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale; - const viewportCenterY = - (PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale; - const worldPreviewWidth = - PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale; - const worldPreviewHeight = - PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale; - const previewMinX = viewportCenterX - worldPreviewWidth / 2; - const previewMinY = viewportCenterY - worldPreviewHeight / 2; - const scale = Math.min( - PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth, - PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight, + const frameScale = Math.max( + PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width, + PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height, ); - const offsetX = -previewMinX * scale; - const offsetY = -previewMinY * scale; return { - x: offsetX + layer.x * scale, - y: offsetY + layer.y * scale, - width: layer.width * scale, - height: layer.height * scale, + viewportX: numberOrFallback(viewport.x, 0), + viewportY: numberOrFallback(viewport.y, 0), + renderScale, + frameScale, + offsetX: + (PROJECT_COVER_SNAPSHOT_SIZE.width - + normalizedViewportSize.width * frameScale) / + 2, + offsetY: + (PROJECT_COVER_SNAPSHOT_SIZE.height - + normalizedViewportSize.height * frameScale) / + 2, }; } -async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { - const src = layer.objectKey - ? await getSignedAssetReadUrl({ objectKey: layer.objectKey }) - : layer.src; - return new Promise((resolve, reject) => { - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.onload = () => resolve(image); - image.onerror = () => reject(new Error('project cover image load failed')); - image.src = src; - }); -} - -function canvasToBlob(canvas: HTMLCanvasElement) { - return new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), 'image/png'); - }); -} - -export async function createProjectCoverSnapshotBlob({ - layers, - viewport, -}: { - layers: readonly CanvasLayer[]; - viewport: CanvasViewport; -}) { - if (typeof document === 'undefined') { - return null; - } - const coverLayers = resolveProjectCoverDrawableLayers(layers); - if (coverLayers.length === 0) { - return null; - } - const canvas = document.createElement('canvas'); - canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; - canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; - const context = canvas.getContext('2d'); - if (!context || typeof canvas.toBlob !== 'function') { - return null; - } - context.clearRect(0, 0, canvas.width, canvas.height); - - let drawnLayerCount = 0; - for (const layer of coverLayers) { - try { - const image = await loadProjectCoverImage(layer); - const placement = resolveProjectCoverPlacement(viewport, layer); - context.drawImage( - image, - placement.x, - placement.y, - placement.width, - placement.height, - ); - drawnLayerCount += 1; - } catch { - // Individual layer load failures should not block a cover from other layers. - } - } - - if (drawnLayerCount === 0) { - return null; - } - return canvasToBlob(canvas); +export function resolveProjectCoverPlacement( + viewport: CanvasViewport, + viewportSize: ProjectCoverSnapshotViewportSize, + layer: ProjectCoverDrawableLayer, +) { + const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize }); + return { + x: + frame.offsetX + + (frame.viewportX + layer.x * frame.renderScale) * frame.frameScale, + y: + frame.offsetY + + (frame.viewportY + layer.y * frame.renderScale) * frame.frameScale, + width: layer.width * frame.renderScale * frame.frameScale, + height: layer.height * frame.renderScale * frame.frameScale, + }; } diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts new file mode 100644 index 000000000..11ddbfd8b --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.test.ts @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer'; + +function layer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-offscreen', + resourceId: 'resource-offscreen', + title: '离屏图层', + src: '/generated/offscreen.webp', + x: 10_000, + y: 10_000, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + ...overrides, + }; +} + +describe('ImageCanvasProjectCoverSnapshotRenderer', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exports the canvas background when every drawable layer is outside the cover frame', async () => { + const fillRect = vi.fn(); + const drawImage = vi.fn(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fillRect, + drawImage, + fillStyle: '', + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback) => callback(new Blob(['cover'], { type: 'image/webp' })), + ); + + const blob = await createProjectCoverSnapshotBlob({ + layers: [layer()], + viewport: { x: 0, y: 0, scale: 1 }, + viewportSize: { width: 1280, height: 960 }, + backgroundColor: '#f8fafc', + }); + + expect(blob?.type).toBe('image/webp'); + expect(fillRect).toHaveBeenCalledWith(0, 0, 320, 240); + expect(drawImage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts new file mode 100644 index 000000000..0c790b8ec --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotRenderer.ts @@ -0,0 +1,158 @@ +import { + getSignedAssetReadUrl, + resolveAssetReadUrl, + shouldResolveAssetReadUrl, +} from '../../services/assetReadUrlService'; +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import { + normalizeProjectCoverBackgroundColor, + PROJECT_COVER_SNAPSHOT_SIZE, + type ProjectCoverDrawableLayer, + type ProjectCoverSnapshotViewportSize, + resolveProjectCoverDrawableLayers, + resolveProjectCoverPlacement, +} from './ImageCanvasProjectCoverSnapshotModel'; + +const PROJECT_COVER_IMAGE_LOAD_CONCURRENCY = 6; +const PROJECT_COVER_SNAPSHOT_MIME_TYPE = 'image/webp'; +const PROJECT_COVER_SNAPSHOT_WEBP_QUALITY = 0.82; + +type ProjectCoverDrawItem = { + layer: ProjectCoverDrawableLayer; + placement: ReturnType; +}; + +function placementIntersectsCover( + placement: ProjectCoverDrawItem['placement'], +) { + return ( + placement.x < PROJECT_COVER_SNAPSHOT_SIZE.width && + placement.y < PROJECT_COVER_SNAPSHOT_SIZE.height && + placement.x + placement.width > 0 && + placement.y + placement.height > 0 + ); +} + +async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) { + if (layer.objectKey) { + return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, { + cacheVersion: layer.cacheVersion, + }); + } + if (shouldResolveAssetReadUrl(layer.src)) { + return resolveAssetReadUrl(layer.src, { + refreshKey: layer.cacheVersion, + }); + } + return layer.src; +} + +async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) { + const src = await resolveProjectCoverImageUrl(layer); + const image = await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('project cover image load failed')); + image.src = src; + }); + return image; +} + +async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) { + const loadedImages = new Array(items.length).fill( + null, + ); + let nextIndex = 0; + const loadNext = async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + try { + const item = items[index]; + if (item) { + loadedImages[index] = await loadProjectCoverImage(item.layer); + } + } catch { + // Individual layer load failures should not block other cover layers. + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(PROJECT_COVER_IMAGE_LOAD_CONCURRENCY, items.length) }, + () => loadNext(), + ), + ); + return loadedImages; +} + +function canvasToBlob(canvas: HTMLCanvasElement) { + return new Promise((resolve) => { + canvas.toBlob( + (blob) => resolve(blob), + PROJECT_COVER_SNAPSHOT_MIME_TYPE, + PROJECT_COVER_SNAPSHOT_WEBP_QUALITY, + ); + }); +} + +export async function createProjectCoverSnapshotBlob({ + layers, + viewport, + viewportSize, + backgroundColor, +}: { + layers: readonly CanvasLayer[]; + viewport: CanvasViewport; + viewportSize: ProjectCoverSnapshotViewportSize; + backgroundColor: string; +}) { + if (typeof document === 'undefined') { + return null; + } + const drawableLayers = resolveProjectCoverDrawableLayers(layers); + if (drawableLayers.length === 0) { + return null; + } + const drawItems = drawableLayers + .map((layer) => ({ + layer, + placement: resolveProjectCoverPlacement(viewport, viewportSize, layer), + })) + .filter(({ placement }) => placementIntersectsCover(placement)); + const canvas = document.createElement('canvas'); + canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width; + canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height; + const context = canvas.getContext('2d'); + if (!context || typeof canvas.toBlob !== 'function') { + return null; + } + context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor); + context.fillRect(0, 0, canvas.width, canvas.height); + + if (drawItems.length === 0) { + return canvasToBlob(canvas); + } + + const loadedImages = await loadProjectCoverDrawItems(drawItems); + let drawnLayerCount = 0; + drawItems.forEach(({ placement }, index) => { + const image = loadedImages[index]; + if (!image) { + return; + } + context.drawImage( + image, + placement.x, + placement.y, + placement.width, + placement.height, + ); + drawnLayerCount += 1; + }); + if (drawnLayerCount === 0) { + return null; + } + return canvasToBlob(canvas); +} diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2ff3eeaf5..d8b2a1ff1 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -67,6 +73,7 @@ function renderTopbar( onRecharge: vi.fn(), onOpenWalletLedger: vi.fn(), onOpenAccount: vi.fn(), + onReturnToProjects: vi.fn(), ...overrides, }; @@ -123,6 +130,29 @@ describe('ImageCanvasTopbarView', () => { expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1); }); + it('waits for project persistence before allowing another return request', async () => { + let resolveReturn!: () => void; + const pendingReturn = new Promise((resolve) => { + resolveReturn = resolve; + }); + const onReturnToProjects = vi.fn(() => pendingReturn); + renderTopbar({ onReturnToProjects }); + const projectLink = screen.getByRole('link', { + name: '返回项目页面', + }); + + fireEvent.click(projectLink); + fireEvent.click(projectLink); + + expect(onReturnToProjects).toHaveBeenCalledTimes(1); + expect(projectLink.getAttribute('aria-disabled')).toBe('true'); + + resolveReturn(); + await waitFor(() => { + expect(projectLink.getAttribute('aria-disabled')).toBe('false'); + }); + }); + it('shows the current user avatar beside the mud point balance', () => { const props = renderTopbar({ currentUser: { diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index ba20c2e4c..b2660d5d9 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -6,6 +6,7 @@ import { Pencil, X, } from 'lucide-react'; +import { useState } from 'react'; import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx'; @@ -51,6 +52,7 @@ export type ImageCanvasTopbarViewProps = { onRecharge: () => void; onOpenWalletLedger: () => void; onOpenAccount: () => void; + onReturnToProjects?: () => void | Promise; }; function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) { @@ -88,7 +90,9 @@ export function ImageCanvasTopbarView({ onRecharge, onOpenWalletLedger, onOpenAccount, + onReturnToProjects, }: ImageCanvasTopbarViewProps) { + const [isReturningToProjects, setIsReturningToProjects] = useState(false); const projectId = useImageCanvasContextStore((state) => state.projectId); const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, @@ -107,7 +111,21 @@ export function ImageCanvasTopbarView({ className="image-canvas-editor__project-back-button" href="/project" aria-label="返回项目页面" + aria-disabled={isReturningToProjects} title="返回项目" + onClick={(event) => { + if (!onReturnToProjects) { + return; + } + event.preventDefault(); + if (isReturningToProjects) { + return; + } + setIsReturningToProjects(true); + void Promise.resolve(onReturnToProjects()).finally(() => { + setIsReturningToProjects(false); + }); + }} >