fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切 #111

Merged
kdletters merged 8 commits from fix/canva-preview-mismatch into master 2026-07-29 16:09:31 +08:00
17 changed files with 853 additions and 209 deletions
@@ -556,7 +556,9 @@
## 2026-06-24 图片画布项目封面使用静态快照资源
- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。
- 2026-07-29 补充:常规编辑仍沿用防抖保存;用户从画布返回项目页时必须取消待执行 timer,以最新权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存和正式项目资源后再导航。当前视口存在图层但全部位于取景外时仍生成纯背景封面,不沿用旧缩略图。
- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts``src/components/project/ProjectCanvasCover.tsx``src/components/project/ProjectGalleryView.tsx``src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。
- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -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`
## 图片画布框选预览要复用源图换签缓存
@@ -58,7 +58,8 @@
- 新增 `editor_asset` 表保存账号级素材:`assetId``ownerUserId``folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount``assetKind``generationInputs``generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
- 新增 `editor_showcase_asset``editor_showcase_asset_like``editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind``generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"``/project``/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebPquality `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>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `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` 或资源 IDData URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
@@ -20,6 +20,7 @@ import {
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
withEditorProjectCanvasRevision,
} from './ImageCanvasEditorView.test-utils';
function render(ui: Parameters<typeof testingLibraryRender>[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: [
{
@@ -74,17 +74,12 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
uploadEditorMediaAssetObjectFile: vi.fn(),
}));
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
const actual = await vi.importActual<
typeof import('./ImageCanvasProjectCoverSnapshotModel')
>('./ImageCanvasProjectCoverSnapshotModel');
return {
...actual,
createProjectCoverSnapshotBlob: vi.fn(async () => null),
};
});
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
createProjectCoverSnapshotBlob: vi.fn(async () => null),
}));
describe('ImageCanvasEditorView generation integration', () => {
setupImageCanvasEditorViewTestLifecycle({
@@ -415,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => {
project: {
projectId: input.projectId,
title: '默认项目',
canvas: { revision: 1 },
viewport: { x: 0, y: 0, scale: 1 },
resources: [
...projectResources,
@@ -482,6 +478,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-generated-dialog',
title: '已生成画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -551,6 +548,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-generated-layer-only',
title: '已生成画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -605,6 +603,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-uploaded-tagged-layer',
title: '标签素材画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -650,6 +649,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-tagged-layer',
title: '右键标签素材画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -700,6 +700,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-publication-dialog',
title: '宣发画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -1327,6 +1328,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons-generating',
title: '图标素材生成中画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -1699,6 +1701,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '默认项目',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: rememberedProjectLayers,
resources: rememberedProjectResources,
@@ -2334,6 +2337,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '默认项目',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: characterReferenceProjectLayers,
resources: characterReferenceProjectResources,
@@ -2511,6 +2515,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons',
title: '图标素材画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -2686,6 +2691,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icon-library-refresh',
title: '图标素材刷新画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -2831,6 +2837,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId,
title: '刷新恢复画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -2873,6 +2880,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId,
title: '刷新恢复画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -3141,6 +3149,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-character-animation',
title: '角色动画画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -3368,6 +3377,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-ui-extract',
title: 'UI素材提取画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -3541,6 +3551,7 @@ describe('ImageCanvasEditorView generation integration', () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-ui-extract-dismiss',
title: 'UI素材提取外部关闭画布',
canvas: { revision: 0 },
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
@@ -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({
@@ -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(<ImageCanvasEditorView />);
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 () => {
@@ -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<CanvasGenerationDialogState[]>([]);
const viewportRef = useRef<CanvasViewport>(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();
@@ -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> = {}): CanvasLayer {
@@ -25,9 +27,33 @@ function layer(overrides: Partial<CanvasLayer> = {}): 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([
@@ -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<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('project cover image load failed'));
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/png');
});
}
export async function createProjectCoverSnapshotBlob({
layers,
viewport,
}: {
layers: readonly CanvasLayer[];
viewport: CanvasViewport;
}) {
if (typeof document === 'undefined') {
return null;
}
const coverLayers = resolveProjectCoverDrawableLayers(layers);
if (coverLayers.length === 0) {
return null;
}
const canvas = document.createElement('canvas');
canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width;
canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height;
const context = canvas.getContext('2d');
if (!context || typeof canvas.toBlob !== 'function') {
return null;
}
context.clearRect(0, 0, canvas.width, canvas.height);
let drawnLayerCount = 0;
for (const layer of coverLayers) {
try {
const image = await loadProjectCoverImage(layer);
const placement = resolveProjectCoverPlacement(viewport, layer);
context.drawImage(
image,
placement.x,
placement.y,
placement.width,
placement.height,
);
drawnLayerCount += 1;
} catch {
// Individual layer load failures should not block a cover from other layers.
}
}
if (drawnLayerCount === 0) {
return null;
}
return canvasToBlob(canvas);
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,
};
}
@@ -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> = {}): 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();
});
});
@@ -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<typeof resolveProjectCoverPlacement>;
};
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<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('project cover image load failed'));
image.src = src;
});
return image;
}
async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) {
const loadedImages = new Array<HTMLImageElement | null>(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<Blob | null>((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);
}
@@ -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<void>((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: {
@@ -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<void>;
};
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);
});
}}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
@@ -27,15 +27,9 @@ const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY =
'genarrative.imageCanvas.projectSnapshot.v2:user-test: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', () => ({
@@ -115,9 +109,11 @@ 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 [flushCompleted, setFlushCompleted] = useState(false);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const canvasGenerationDialogsRef = useRef(generationDialogs);
@@ -173,6 +169,7 @@ function ProjectPersistenceHarness({
layers,
canvasGenerationDialogs: generationDialogs,
viewport,
canvasSize,
canvasBackgroundColor,
isViewportInteracting,
canAccessProtectedData,
@@ -213,6 +210,18 @@ function ProjectPersistenceHarness({
.join(',')}
</span>
<span data-testid="background">{canvasBackgroundColor}</span>
<span data-testid="flush-completed">{String(flushCompleted)}</span>
<button
type="button"
onClick={() => {
setFlushCompleted(false);
void persistence.flushProjectPersistence().then(() => {
setFlushCompleted(true);
});
}}
>
flush project persistence
</button>
<button
type="button"
onClick={() => {
@@ -989,7 +998,9 @@ describe('useImageCanvasProjectPersistence', () => {
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
await Promise.resolve();
});
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(staleReload.promise);
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
staleReload.promise,
);
await act(async () => {
rerender(<ProjectPersistenceHarness />);
await Promise.resolve();
@@ -1605,6 +1616,15 @@ describe('useImageCanvasProjectPersistence', () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { x: 202, y: 152, scale: 0.5 },
viewportSize: { width: 1280, height: 960 },
backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR,
}),
);
});
await waitFor(() => {
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1718,6 +1738,59 @@ describe('useImageCanvasProjectPersistence', () => {
);
});
it('flushes the latest layout and waits for the local cover cache before returning', async () => {
const coverBlob = new Blob(['cover'], { type: 'image/webp' });
const coverCacheWrite = createDeferred<void>();
createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob);
putEditorProjectCoverCacheMock.mockReturnValue(coverCacheWrite.promise);
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'append server resource' }).click();
screen.getByRole('button', { name: 'move viewport' }).click();
});
await waitFor(() => {
expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5');
});
saveEditorProjectLayoutMock.mockClear();
act(() => {
screen.getByRole('button', { name: 'flush project persistence' }).click();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
viewport: { x: 10, y: 5, scale: 1 },
}),
);
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { x: 200, y: 165, scale: 0.5 },
viewportSize: { width: 1280, height: 960 },
}),
);
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
projectId: 'editor-project-default',
blob: coverBlob,
});
});
expect(screen.getByTestId('flush-completed').textContent).toBe('false');
coverCacheWrite.resolve();
await waitFor(() => {
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({ assetKind: 'project-cover-snapshot' }),
);
});
it('loads and saves viewport scale with display zoom semantics', async () => {
render(<ProjectPersistenceHarness />);
@@ -1739,11 +1812,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(<ProjectPersistenceHarness />);
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();
@@ -1762,7 +1836,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();
@@ -1780,9 +1858,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 () => {
@@ -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,11 +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;
@@ -111,6 +113,7 @@ type ImageCanvasProjectPersistenceOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
canvasSize: ProjectCoverSnapshotViewportSize;
canvasBackgroundColor: string;
isViewportInteracting: boolean;
canAccessProtectedData: boolean;
@@ -120,10 +123,7 @@ type ImageCanvasProjectPersistenceOptions = {
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
error.status === 401
);
return error instanceof ApiClientError && error.status === 401;
}
function isEditorProjectAccessError(error: unknown) {
@@ -377,6 +377,7 @@ export function useImageCanvasProjectPersistence({
layers,
canvasGenerationDialogs,
viewport,
canvasSize,
canvasBackgroundColor,
isViewportInteracting,
canAccessProtectedData,
@@ -416,11 +417,17 @@ export function useImageCanvasProjectPersistence({
null,
);
const isProjectLayoutSaveRunningRef = useRef(false);
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
const coverSnapshotSignatureRef = useRef<string | null>(null);
const coverSnapshotUploadRequestRef = useRef(0);
const activeCoverSnapshotPersistenceRef = useRef<{
signature: string;
promise: Promise<void>;
} | null>(null);
const coverSnapshotViewportSizeRef = useRef(canvasSize);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
const {
@@ -433,6 +440,7 @@ export function useImageCanvasProjectPersistence({
restoreCanvasGenerationDialogs,
applyCanvasBackgroundColor,
} = setters;
coverSnapshotViewportSizeRef.current = canvasSize;
const clearPendingProjectLayoutSave = useCallback(() => {
pendingProjectLayoutSaveRef.current = null;
@@ -476,7 +484,7 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current &&
authoritativeProjectIdRef.current === pendingSave.projectId;
let runNextSave = false;
void saveEditorProjectLayout(pendingSave.projectId, {
const savePromise = saveEditorProjectLayout(pendingSave.projectId, {
...pendingSave.input,
expectedRevision,
})
@@ -580,6 +588,9 @@ export function useImageCanvasProjectPersistence({
}
})
.finally(() => {
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
activeProjectLayoutSavePromiseRef.current = null;
}
isProjectLayoutSaveRunningRef.current = false;
if (
runNextSave &&
@@ -589,6 +600,8 @@ export function useImageCanvasProjectPersistence({
runPendingProjectLayoutSave();
}
});
activeProjectLayoutSavePromiseRef.current = savePromise;
void savePromise;
},
[openEditorLoginModal],
);
@@ -596,26 +609,44 @@ export function useImageCanvasProjectPersistence({
const persistProjectCoverSnapshot = useCallback(
(
nextProjectId: string,
coverViewport: CanvasViewport,
coverDisplayViewport: CanvasViewport,
coverLayers: readonly CanvasLayer[],
) => {
if (!canAccessProtectedData) {
return;
return Promise.resolve();
}
const rawCoverViewport =
canvasDisplayViewportToViewport(coverDisplayViewport);
const { viewport: coverViewport, viewportSize } =
normalizeProjectCoverSnapshotViewport({
viewport: rawCoverViewport,
viewportSize: coverSnapshotViewportSizeRef.current,
});
const backgroundColor = refs.canvasBackgroundColorRef.current;
const signature = buildProjectCoverSnapshotSignature({
layers: coverLayers,
viewport: coverViewport,
viewportSize,
backgroundColor,
});
if (!signature || coverSnapshotSignatureRef.current === signature) {
return;
if (!signature) {
return Promise.resolve();
}
if (coverSnapshotSignatureRef.current === signature) {
return activeCoverSnapshotPersistenceRef.current?.signature ===
signature
? activeCoverSnapshotPersistenceRef.current.promise
: Promise.resolve();
}
coverSnapshotSignatureRef.current = signature;
coverSnapshotUploadRequestRef.current += 1;
const requestId = coverSnapshotUploadRequestRef.current;
void createProjectCoverSnapshotBlob({
const persistencePromise = createProjectCoverSnapshotBlob({
layers: coverLayers,
viewport: coverViewport,
viewportSize,
backgroundColor,
})
.then(async (blob) => {
if (!blob) {
@@ -627,14 +658,18 @@ export function useImageCanvasProjectPersistence({
if (coverSnapshotUploadRequestRef.current !== requestId) {
return;
}
void putEditorProjectCoverCache({
await putEditorProjectCoverCache({
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',
{
@@ -671,9 +706,22 @@ export function useImageCanvasProjectPersistence({
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
})
.finally(() => {
if (
activeCoverSnapshotPersistenceRef.current?.promise ===
persistencePromise
) {
activeCoverSnapshotPersistenceRef.current = null;
}
});
activeCoverSnapshotPersistenceRef.current = {
signature,
promise: persistencePromise,
};
return persistencePromise;
},
[canAccessProtectedData, openEditorLoginModal],
[canAccessProtectedData, openEditorLoginModal, refs],
);
const queueProjectLayoutSave = useCallback(
@@ -683,7 +731,7 @@ export function useImageCanvasProjectPersistence({
Parameters<typeof saveEditorProjectLayout>[1],
'expectedRevision'
>,
options: { delayMs?: number } = {},
options: { delayMs?: number; persistCover?: boolean } = {},
) => {
const revision = projectRevisionRef.current;
if (
@@ -717,24 +765,29 @@ export function useImageCanvasProjectPersistence({
}
const delayMs = options.delayMs ?? 0;
const shouldPersistCover = options.persistCover !== false;
if (delayMs > 0) {
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
if (shouldPersistCover) {
void persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
}
runPendingProjectLayoutSave();
}, delayMs);
return;
}
persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
if (shouldPersistCover) {
void persistProjectCoverSnapshot(
nextProjectId,
input.viewport,
refs.layersRef.current,
);
}
runPendingProjectLayoutSave();
},
[
@@ -745,6 +798,69 @@ export function useImageCanvasProjectPersistence({
],
);
const flushProjectPersistence = useCallback(async () => {
while (activeProjectLayoutSavePromiseRef.current) {
await activeProjectLayoutSavePromiseRef.current;
}
const nextProjectId = projectIdRef.current;
if (
!nextProjectId ||
!canAccessProtectedDataRef.current ||
!hasAuthoritativeProjectSnapshotRef.current ||
authoritativeProjectIdRef.current !== nextProjectId ||
projectRevisionRef.current === null
) {
return;
}
const coverDisplayViewport = viewportToCanvasDisplayViewport(
refs.viewportRef.current,
);
queueProjectLayoutSave(
nextProjectId,
{
viewport: coverDisplayViewport,
layers: serializeCanvasLayout({
layers: refs.layersRef.current,
canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current,
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
}),
},
{ persistCover: false },
);
const coverSave = persistProjectCoverSnapshot(
nextProjectId,
coverDisplayViewport,
refs.layersRef.current,
);
while (
activeProjectLayoutSavePromiseRef.current ||
pendingProjectLayoutSaveRef.current
) {
const activeLayoutSave = activeProjectLayoutSavePromiseRef.current;
if (activeLayoutSave) {
await activeLayoutSave;
continue;
}
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
runPendingProjectLayoutSave();
if (!activeProjectLayoutSavePromiseRef.current) {
break;
}
}
await coverSave;
}, [
persistProjectCoverSnapshot,
queueProjectLayoutSave,
refs,
runPendingProjectLayoutSave,
]);
const applyCreatedProjectResourceLayer = useCallback(
(pendingLayer: PendingCreatedProjectResourceLayer) => {
if (
@@ -839,8 +955,7 @@ export function useImageCanvasProjectPersistence({
const pendingLayer: PendingCreatedProjectResourceLayer = {
projectId: readyProjectId,
ownerUserId: requestOwnerUserId,
authoritativeSnapshotSequence:
requestAuthoritativeSnapshotSequence,
authoritativeSnapshotSequence: requestAuthoritativeSnapshotSequence,
layer,
options,
resourceId: resource.resourceId,
@@ -1093,7 +1208,8 @@ export function useImageCanvasProjectPersistence({
allowProjectSwitch: true,
});
if (!projectIsAuthoritative) {
const activeAuthoritativeProjectId = authoritativeProjectIdRef.current;
const activeAuthoritativeProjectId =
authoritativeProjectIdRef.current;
if (
hasAuthoritativeProjectSnapshotRef.current &&
activeAuthoritativeProjectId !== null &&
@@ -1210,5 +1326,6 @@ export function useImageCanvasProjectPersistence({
createProjectResourceForLayer,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
};
}
@@ -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<UploadedEditorMediaAsset> {
): Promise<UploadedEditorMediaAssetObject> {
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<UploadedEditorMediaAsset> {
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,
};
}