diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index b9df12ad5..6bc6a21b3 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-07-28` +更新时间:`2026-08-07` ## 背景 @@ -187,6 +187,7 @@ - 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`;此时图层的 `sourceResourceId` 继续使用正式账号素材记录的直接来源,不回退成请求中的原角色资源。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。 - 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。 - 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。 +- 集合 ZIP 对角色动作去重时,稳定身份依次取层级 `taskId`、`sourceResourceId`,再按帧顺序取每帧 `assetObjectId -> objectKey -> imageSrc`;帧数组必须使用有边界编码,不得只拼接可为空的 `imageSrc`。两个仅含不同私有 `objectKey` 的序列必须同时进入 ZIP,根 `metadata.json` 分别指向各自的序列目录。 - 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。 - 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;迁移按权威对象类型排除同 task 的预览视频、只接受唯一最终图片序列并逐帧补齐登记对象身份,且不把 layout 复制的 `sourceResourceId` 当作历史血缘证据。CLI tuple 与 HTTP object 两种 procedure 返回都必须先归一为脚本内部统一的 `batch_sha256`,分页还必须拒绝未推进或重复出现的 cursor,避免安全校验误拒绝或生产迁移无限循环。该宽容只属于迁移;新生成仍严格使用“原角色资源 → 预览视频资源 → 最终序列资源”直接来源链。上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。 - `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。 diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 76643d69b..9436622dc 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -110,7 +110,26 @@ describe('ImageCanvasExportModel', () => { }, ], }), - ).toBe('image-sequence:sequence-task'); + ).toBe('image-sequence:task:sequence-task'); + expect( + getLayerExportKey({ + ...buildLayer(), + mediaType: 'image-sequence', + taskId: null, + sourceResourceId: null, + src: '', + imageSequenceFrames: [ + { + imageSrc: '', + objectKey: 'private/animation-a/frame-01.png', + width: 1024, + height: 1024, + }, + ], + }), + ).toBe( + 'image-sequence:frames:["object-key:private/animation-a/frame-01.png"]', + ); }); it('detects image extensions from content type before falling back to src', () => { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 86ab886f9..443e9a241 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -34,12 +34,22 @@ export function formatExportDate(date: Date) { export function getLayerExportKey(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { - return `image-sequence:${ - layer.taskId || - layer.sourceResourceId || - layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') || - layer.src - }`; + if (layer.taskId) { + return `image-sequence:task:${layer.taskId}`; + } + if (layer.sourceResourceId) { + return `image-sequence:resource:${layer.sourceResourceId}`; + } + const frameIdentities = layer.imageSequenceFrames?.map((frame) => + frame.assetObjectId + ? `asset-object:${frame.assetObjectId}` + : frame.objectKey + ? `object-key:${frame.objectKey}` + : `source:${frame.imageSrc}`, + ); + return frameIdentities?.length + ? `image-sequence:frames:${JSON.stringify(frameIdentities)}` + : `image-sequence:source:${layer.src}`; } return ( layer.assetObjectId || diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index d2a2b1969..3d714997c 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -419,6 +419,127 @@ describe('useImageCanvasAssetExportWorkflow', () => { } }); + it('exports distinct object-key-only private image sequences with separate metadata', async () => { + const originalFetch = globalThis.fetch; + const frameContents = new Map([ + ['private/animation-a/frame-01.png', 'animation-a-frame'], + ['private/animation-b/frame-01.png', 'animation-b-frame'], + ]); + const fetchMock = vi.fn(async (url: string) => { + const requestUrl = new URL(url, 'http://localhost'); + if (requestUrl.pathname === '/api/assets/read-url') { + return new Response(null, { status: 404 }); + } + if (requestUrl.pathname === '/api/assets/read-bytes') { + const objectKey = requestUrl.searchParams.get('objectKey') ?? ''; + const content = frameContents.get(objectKey); + return content + ? new Response(content, { + headers: { 'Content-Type': 'image/png' }, + }) + : new Response(null, { status: 404 }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:private-sequences'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出画布素材' })); + + await waitFor(() => expect(exportedBlob).toBeTruthy()); + const zip = await JSZip.loadAsync(exportedBlob!); + const firstFramePath = + '导出项目-画布素材/sequences/001-私有动作 A/frames/frame-01.png'; + const secondFramePath = + '导出项目-画布素材/sequences/002-私有动作 B/frames/frame-01.png'; + expect(await zip.file(firstFramePath)!.async('text')).toBe( + 'animation-a-frame', + ); + expect(await zip.file(secondFramePath)!.async('text')).toBe( + 'animation-b-frame', + ); + const metadata = JSON.parse( + await readZipText(zip, '导出项目-画布素材/metadata.json'), + ); + expect(metadata.layers).toHaveLength(2); + expect( + metadata.layers.map((layer: { file: string }) => layer.file), + ).toEqual([ + 'sequences/001-私有动作 A/manifest.txt', + 'sequences/002-私有动作 B/manifest.txt', + ]); + expect( + await readZipText(zip, '导出项目-画布素材/manifest.txt'), + ).toContain('素材数量:2'); + for (const objectKey of frameContents.keys()) { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + `/api/assets/read-bytes?objectKey=${encodeURIComponent(objectKey)}`, + ), + expect.any(Object), + ); + } + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + it('reads layers and sequence frames with bounded concurrency and stable zip order', async () => { const originalFetch = globalThis.fetch; const pendingResponses = new Map void>();