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>();