角色动作素材改为序列帧

将角色动作结果图层主类型改为 image-sequence,并持久化完整帧列表与预览视频来源

在画布中增加序列帧播放器,展示透明 PNG 帧而不是预览视频

将角色动作单素材下载和画布素材导出改为序列帧 ZIP/frames 目录

更新角色动作、导出方案和项目记忆文档,补齐相关前端测试
This commit is contained in:
2026-06-22 20:49:51 +08:00
parent d9cd2edf92
commit 1176c71f72
22 changed files with 1134 additions and 322 deletions
@@ -16,6 +16,14 @@
---
## 2026-06-22 图片画布角色动作主媒体改为透明序列帧
- 背景:角色动作生成后端已经在视频生成后抽取透明 PNG 帧并完成绿幕去背;画板继续把 `previewVideoPath` 当主媒体会让用户看到未扣绿幕视频,下载也拿不到可直接用于游戏素材的帧序列。
- 决策:`/api/editor/character-animations/generations` 的上游预览视频继续保留为来源信息,但画板落层主类型固定为 `mediaType="image-sequence"``assetKind="character-animation"`;图层 `src` / `thumbnailSrc` 使用首帧,完整 `frames` 保存到 `imageSequenceFrames`,画布展示使用序列帧播放器循环播放。单图层下载生成序列帧 ZIP,画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`,不再把预览视频作为角色动作下载产物。
- 影响范围:图片画布角色动作生成、画布图层快照、序列帧播放器、素材导出、角色动作设计文档和排障记忆。
- 验证方式:运行角色动作图层工厂、画布展示、画布持久化、生成提交和素材导出相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 2026-06-21 图片画布生成完成态由后端写入画布布局
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
@@ -23,12 +23,12 @@
- 验证:运行 `npm run check:production-ops``npm run check:production-api-release``npm run check:production-api-deploy`,确认构建包、Jenkins 归档链路和 deploy fail-fast 检查口径一致。
- 关联:`jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-api-deploy``scripts/deploy/production-api-deploy.sh``scripts/check-production-ops-guardrails.mjs`
## 图片画布角色动作结果不能把首帧当主媒体
## 图片画布角色动作结果主类型是序列帧
- 现象:画板 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频
- 原因:后端已经生成 `previewVideoPath`,并继续执行抽帧、绿幕去背和帧素材 OSS 落盘;前端落层时却把 `frames[0].imageSrc` 当作图层主 `src`,且没有设置 `mediaType: "video"`,导致预览和导出都按图片处理
- 处理:角色动作结果图层主 `src` 必须使用 `previewVideoPath``mediaType` 固定为 `video``assetKind` 固定为 `character-animation`;首帧透明 PNG 只写入 `thumbnailSrc`,用于 `<video poster>` 和后续动作素材二次生成源。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src`预览视频且 `thumbnailSrc` 为首帧;画布集成测试应出现 `<video controls poster=...>`;导出模型应按 `mediaType="video"` 输出 mp4/webm/mov 等真实视频扩展名
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理
- 原因:后端仍需要先生成 `previewVideoPath`抽帧、绿幕去背和 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只作为来源信息保留。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src`首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts``server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## Vidu 文生音频线上网关可能要求 sound 字段
@@ -22,6 +22,7 @@
- 导出当前画布中有效图层引用的图片。
- 包含上传图、生成图、修改生成结果。
- 角色动作 `image-sequence` 图层导出完整透明 PNG 序列帧。
- 默认包含隐藏图层。
- 跳过已被素材库删除且已判定无效的上传图层。
- 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。
@@ -50,6 +51,13 @@
│ ├─ 001-拼图素材.png
│ ├─ 002-生成图片.png
│ └─ 003-修改结果.png
├─ sequences/
│ └─ 004-角色动作/
│ ├─ frames/
│ │ ├─ frame-01.png
│ │ └─ frame-02.png
│ ├─ metadata.json
│ └─ manifest.txt
├─ metadata.json
└─ manifest.txt
```
@@ -120,10 +128,11 @@ assetObjectId > objectKey > sourceAssetId > src
1. 从当前 `layers` 取目标图层。
2. 过滤无效图层,保留隐藏图层。
3. 按去重 key 合并图片源。
4. 对每个图片源读取 Blob
3. 按去重 key 合并图片源或序列帧源
4. 对每个素材源读取 Blob
- `data:image/...` 直接转换为 Blob。
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`
- 拉取失败时记录失败项,不中断整个导出。
5. 使用 `JSZip` 写入 `images/``metadata.json``manifest.txt`
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
@@ -163,6 +163,7 @@
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须执行绿幕去背,输出透明背景 PNG。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `previewVideoPath` 创建 `mediaType: "video"``assetKind: "character-animation"` 图层,复用视频素材的 `<video controls preload="metadata" playsInline>` 预览和视频下载 / 导出口径;不得把 `frames[0].imageSrc` 作为图层主 `src`
- `frames[0].imageSrc` 只作为动作视频图层的 `thumbnailSrc` / poster,以及后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程
- 画板前端不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程。
File diff suppressed because it is too large Load Diff
@@ -317,14 +317,29 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('serializes and hydrates video thumbnails for character animation layers', () => {
it('serializes and hydrates image sequence frames for character animation layers', () => {
const layer: CanvasLayer = {
id: 'layer-action',
resourceId: 'resource-action',
title: '角色动作',
src: '/generated-character-drafts/editor/preview.mp4',
mediaType: 'video',
src: '/generated-character-drafts/editor/frame01.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: '/generated-character-drafts/editor/frame02.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: 10,
y: 20,
width: 420,
@@ -341,6 +356,8 @@ describe('ImageCanvasEditorModel', () => {
expect(snapshot.thumbnailSrc).toBe(
'/generated-character-drafts/editor/frame01.png',
);
expect(snapshot.mediaType).toBe('image-sequence');
expect(snapshot.imageSequenceFrames).toHaveLength(2);
const hydrated = hydrateLayer(
snapshot,
@@ -356,8 +373,10 @@ describe('ImageCanvasEditorModel', () => {
);
expect(hydrated).toMatchObject({
mediaType: 'video',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
assetKind: 'character-animation',
durationSeconds: 4,
});
@@ -202,6 +202,8 @@ export function serializeLayer(
sourceType: layer.sourceType,
mediaType: layer.mediaType,
thumbnailSrc: layer.thumbnailSrc,
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: layer.previewVideoPath,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
@@ -531,7 +533,11 @@ export function hydrateLayer(
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
const resource = resourcesById.get(resourceId);
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
const src = snapshotSrc || resource?.imageSrc || '';
const imageSequenceFrames = hydrateImageSequenceFrames(
snapshot.imageSequenceFrames,
);
const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? '';
const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
@@ -561,11 +567,12 @@ export function hydrateLayer(
sourceType: isCanvasSourceType(snapshot.sourceType)
? snapshot.sourceType
: 'uploaded',
mediaType:
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
? snapshot.mediaType
: 'image',
mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames),
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
imageSequenceFrames: imageSequenceFrames.length
? imageSequenceFrames
: undefined,
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
@@ -655,6 +662,25 @@ export function inferEditorAssetMediaType(
return 'image';
}
function resolveHydratedLayerMediaType(
snapshot: EditorProjectLayerSnapshot,
imageSequenceFrames: CanvasLayer['imageSequenceFrames'],
): CanvasMediaType {
if (snapshot.mediaType === 'image-sequence') {
return 'image-sequence';
}
if (
snapshot.assetKind === 'character-animation' &&
(imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc))
) {
return 'image-sequence';
}
if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') {
return snapshot.mediaType;
}
return 'image';
}
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
return label.includes('背景音乐') || label.toLowerCase().includes('music')
? 'background-music'
@@ -788,6 +814,33 @@ function hydrateCharacterAnimationResult(
return result as EditorCharacterAnimationGenerationResult;
}
function hydrateImageSequenceFrames(
value: unknown,
): NonNullable<CanvasLayer['imageSequenceFrames']> {
if (!Array.isArray(value)) {
return [];
}
const frames = value.flatMap((frame, index) => {
if (!frame || typeof frame !== 'object') {
return [];
}
const snapshot = frame as Record<string, unknown>;
const imageSrc = stringOrNull(snapshot.imageSrc);
if (!imageSrc) {
return [];
}
return [
{
frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1),
imageSrc,
width: numberFromSnapshot(snapshot.width, 0),
height: numberFromSnapshot(snapshot.height, 0),
},
];
});
return frames;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
@@ -1,6 +1,7 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationFrameResult,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
@@ -25,7 +26,7 @@ export type CanvasAssetKind =
| 'sound-effect'
| 'background-music';
export type CanvasMediaType = 'image' | 'video' | 'audio';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
export type EditorAsset = {
id: string;
@@ -100,6 +101,8 @@ export type CanvasLayer = {
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
previewVideoPath?: string | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
@@ -41,6 +41,21 @@ describe('ImageCanvasExportModel', () => {
src: 'data:image/png;base64,one',
}),
).toBe('object-key');
expect(
getLayerExportKey({
...buildLayer(),
mediaType: 'image-sequence',
taskId: 'sequence-task',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe('image-sequence:sequence-task');
});
it('detects image extensions from content type before falling back to src', () => {
@@ -70,6 +85,13 @@ describe('ImageCanvasExportModel', () => {
'/generated-audios/effect.wav?token=1',
),
).toBe('wav');
expect(
getLayerAssetExtensionFromTypeOrSrc(
'image-sequence',
'',
'/generated-character-drafts/editor/frame01.png',
),
).toBe('zip');
});
it('converts data URLs and builds layer metadata for manifest files', async () => {
@@ -25,6 +25,14 @@ 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
}`;
}
return (
layer.assetObjectId ||
layer.objectKey ||
@@ -49,7 +57,9 @@ function pickKnownExtension(
allowedExtensions: string[],
fallback: string,
) {
return extension && allowedExtensions.includes(extension) ? extension : fallback;
return extension && allowedExtensions.includes(extension)
? extension
: fallback;
}
export function getLayerAssetExtensionFromTypeOrSrc(
@@ -59,6 +69,9 @@ export function getLayerAssetExtensionFromTypeOrSrc(
) {
const normalizedType = type.toLowerCase();
const extensionFromSrc = getExtensionFromSrc(src);
if (mediaType === 'image-sequence') {
return 'zip';
}
if (mediaType === 'audio') {
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) {
return 'mp3';
@@ -129,26 +142,77 @@ export function dataUrlToBlob(dataUrl: string) {
return new Blob([bytes], { type });
}
export async function readLayerAssetBlob(layer: CanvasLayer) {
if (layer.src.startsWith('data:')) {
return dataUrlToBlob(layer.src);
export async function readAssetSourceBlob({
source,
objectKey,
refreshKey,
}: {
source: string;
objectKey?: string | null;
refreshKey?: string | number | null;
}) {
if (source.startsWith('data:')) {
return dataUrlToBlob(source);
}
const source = layer.objectKey
? await getSignedAssetReadUrl(
{ objectKey: layer.objectKey },
undefined,
{ cacheVersion: layer.taskId ?? layer.resourceId },
)
: await resolveAssetReadUrl(layer.src);
const response = await fetch(source);
const resolvedSource = objectKey
? await getSignedAssetReadUrl({ objectKey }, undefined, {
cacheVersion: refreshKey,
})
: await resolveAssetReadUrl(source, { refreshKey });
const response = await fetch(resolvedSource);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
}
export async function readLayerAssetBlob(layer: CanvasLayer) {
return readAssetSourceBlob({
source: layer.src,
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
}
export const readLayerImageBlob = readLayerAssetBlob;
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
if (layer.imageSequenceFrames?.length) {
return layer.imageSequenceFrames;
}
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
return fallbackSrc
? [
{
frameIndex: 1,
imageSrc: fallbackSrc,
width: layer.originalWidth,
height: layer.originalHeight,
},
]
: [];
}
export async function readLayerImageSequenceFrameBlob(
layer: CanvasLayer,
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
) {
return readAssetSourceBlob({
source: frame.imageSrc,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
});
}
export function getImageSequenceFrameFileName(
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
index: number,
blobType = '',
) {
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
return `frame-${frameNumber}.${extension}`;
}
export async function blobToUint8Array(blob: Blob) {
if (typeof blob.arrayBuffer === 'function') {
return new Uint8Array(await blob.arrayBuffer());
@@ -466,7 +466,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('creates character animation result layers with playable video preview and frame metadata', () => {
it('creates character animation result layers as playable image sequences', () => {
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-1',
@@ -508,9 +508,18 @@ describe('ImageCanvasGenerationLayerModel', () => {
id: 'layer-character-animation-15',
resourceId: 'local-resource-character-animation-15',
title: '角色动作',
src: '/generated-character-drafts/editor/preview.mp4',
mediaType: 'video',
src: '/generated-character-drafts/editor/frame1.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: -222,
y: -242,
width: 1024,
@@ -494,9 +494,11 @@ export function createCharacterAnimationResultLayer({
id: `layer-character-animation-${generatedIndex}`,
resourceId: `local-resource-character-animation-${generatedIndex}`,
title,
src: generated.previewVideoPath,
mediaType: 'video',
src: previewFrame.imageSrc,
mediaType: 'image-sequence',
thumbnailSrc: previewFrame.imageSrc,
imageSequenceFrames: generated.frames,
previewVideoPath: generated.previewVideoPath,
x: frameX ?? worldCenter.x - width / 2,
y: frameY ?? worldCenter.y - height / 2,
width,
@@ -395,13 +395,13 @@ describe('ImageCanvasGenerationModel', () => {
);
});
it('uses the extracted transparent frame when reusing a playable character animation layer', () => {
it('uses the extracted transparent frame when reusing a character animation sequence layer', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
assetKind: 'character-animation',
mediaType: 'video',
src: '/generated-character-drafts/editor/preview.mp4',
mediaType: 'image-sequence',
src: '/generated-character-drafts/editor/frame01.png',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
}),
).toBe('/generated-character-drafts/editor/frame01.png');
@@ -628,6 +628,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.mediaType === 'audio') {
return '音频';
}
if (layer.mediaType === 'image-sequence') {
return '序列帧';
}
return null;
}
@@ -649,7 +652,7 @@ export function formatLayerImageType(layer: CanvasLayer) {
return '角色图片';
}
if (layer.assetKind === 'character-animation') {
return '角色动作视频';
return '角色动作序列帧';
}
if (layer.assetKind === 'icon') {
return '图标素材图片';
@@ -678,6 +681,9 @@ export function formatLayerImageType(layer: CanvasLayer) {
if (layer.mediaType === 'audio') {
return '生成音频';
}
if (layer.mediaType === 'image-sequence') {
return '序列帧';
}
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
}
@@ -981,7 +987,10 @@ export function buildVideoGenerationInputs(
return {
fields: createGenerationInputField('视频描述', prompt),
references: (references ?? []).flatMap((reference) => {
const mediaType = reference.mediaType ?? 'image';
const mediaType =
reference.mediaType === 'video' || reference.mediaType === 'audio'
? reference.mediaType
: 'image';
mediaIndexes[mediaType] += 1;
const title =
mediaType === 'video'
@@ -29,6 +29,8 @@ export function ImageCanvasMetadataModalView({
? '音频'
: layer?.mediaType === 'video'
? '视频'
: layer?.mediaType === 'image-sequence'
? '序列帧'
: '图片';
return (
<UnifiedModal
@@ -44,6 +44,7 @@ const VIDEO_REFERENCE_LIMITS = {
video: 3,
audio: 3,
} as const;
type VideoReferenceMediaType = keyof typeof VIDEO_REFERENCE_LIMITS;
export const UPLOAD_LAYER_FALLBACK_SIZE = {
width: 420,
@@ -213,7 +214,7 @@ export function applyGenerationReferenceUpload({
target === 'video-reference-video' ||
target === 'video-reference-audio'
) {
const mediaType: CanvasMediaType =
const mediaType: VideoReferenceMediaType =
target === 'video-reference-video'
? 'video'
: target === 'video-reference-audio'
@@ -266,7 +267,7 @@ export function applyQuickEditReferenceUpload({
function appendLimitedVideoReferences(
currentReferences: CharacterReferenceImage[],
nextReferences: CharacterReferenceImage[],
mediaType: CanvasMediaType,
mediaType: VideoReferenceMediaType,
) {
const limit = VIDEO_REFERENCE_LIMITS[mediaType];
const currentCount = currentReferences.filter(
@@ -941,6 +941,60 @@ describe('ImageCanvasWorldView', () => {
);
});
it('renders character animation layers as an image sequence player', () => {
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
resolvedUrl:
source === '/generated-character-drafts/editor/frame01.png'
? 'https://oss.example.com/frame01.png?signature=1'
: source,
isResolving: false,
shouldResolve: true,
}));
const layer = createLayer({
title: '角色动作',
src: '/generated-character-drafts/editor/frame01.png',
mediaType: 'image-sequence',
assetKind: 'character-animation',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: '/generated-character-drafts/editor/frame02.png',
width: 1024,
height: 1024,
},
],
durationSeconds: 4,
});
renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
const frame = within(layerButton).getByAltText('画布序列帧:角色动作');
const toggle = within(layerButton).getByRole('button', {
name: '暂停角色动作序列帧播放',
});
expect(frame.getAttribute('src')).toBe(
'https://oss.example.com/frame01.png?signature=1',
);
expect(within(layerButton).getByText('1/2')).toBeTruthy();
expect(within(layerButton).getByText('动作')).toBeTruthy();
fireEvent.click(toggle);
expect(
within(layerButton).getByRole('button', {
name: '播放角色动作序列帧',
}),
).toBeTruthy();
});
it('keeps saved generator placeholders hidden after a result layer exists', () => {
renderWorldView({
layers: [createLayer({ id: 'layer-generated', title: '生成图片' })],
@@ -168,6 +168,27 @@ function buildInverseScaleStyle(
};
}
function isCanvasMediaContainerLayer(layer: CanvasLayer) {
return (
layer.mediaType === 'audio' ||
layer.mediaType === 'video' ||
layer.mediaType === 'image-sequence'
);
}
function getLayerMetadataMediaLabel(layer: CanvasLayer) {
if (layer.mediaType === 'audio') {
return '音频';
}
if (layer.mediaType === 'video') {
return '视频';
}
if (layer.mediaType === 'image-sequence') {
return '序列帧';
}
return '图片';
}
const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{
value: CanvasAssetKind | null;
label: string;
@@ -622,6 +643,101 @@ function ImageCanvasImageLayer({
);
}
function getLayerImageSequenceFrames(layer: CanvasLayer) {
if (layer.imageSequenceFrames?.length) {
return layer.imageSequenceFrames;
}
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
return fallbackSrc
? [
{
frameIndex: 1,
imageSrc: fallbackSrc,
width: layer.originalWidth,
height: layer.originalHeight,
},
]
: [];
}
function ImageCanvasImageSequenceLayer({
layer,
mediaTransform,
}: {
layer: CanvasLayer;
mediaTransform?: string;
}) {
const frames = getLayerImageSequenceFrames(layer);
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(frames.length > 1);
const currentFrame = frames[Math.min(frameIndex, frames.length - 1)];
const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, {
refreshKey: `${layer.taskId ?? layer.resourceId}:${
currentFrame?.frameIndex ?? frameIndex + 1
}`,
});
useEffect(() => {
setFrameIndex(0);
setIsPlaying(frames.length > 1);
}, [layer.taskId, layer.resourceId, frames.length]);
useEffect(() => {
if (!isPlaying || frames.length <= 1) {
return undefined;
}
const durationSeconds =
typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0
? layer.durationSeconds
: frames.length / 8;
const intervalMs = Math.max(
40,
Math.round((durationSeconds * 1000) / frames.length),
);
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, intervalMs);
return () => window.clearInterval(timer);
}, [frames.length, isPlaying, layer.durationSeconds]);
return (
<>
<img
src={resolvedUrl || undefined}
alt={`画布序列帧:${layer.title}`}
className="image-canvas-editor__layer-image-sequence-frame"
style={{ transform: mediaTransform }}
/>
<div
className="image-canvas-editor__image-sequence-controls"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="image-canvas-editor__image-sequence-toggle"
aria-label={
isPlaying
? `暂停${layer.title}序列帧播放`
: `播放${layer.title}序列帧`
}
disabled={frames.length <= 1}
onClick={() => setIsPlaying((currentValue) => !currentValue)}
>
{isPlaying ? (
<Pause className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Play className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
<span className="image-canvas-editor__image-sequence-count">
{frames.length ? frameIndex + 1 : 0}/{frames.length || 0}
</span>
</div>
</>
);
}
export function ImageCanvasWorldView({
viewport,
snapGuide,
@@ -730,14 +846,13 @@ export function ImageCanvasWorldView({
generateDialog.sourceLayerId === layer.id
? '修改中'
: null;
const LayerElement =
layer.mediaType === 'audio' || layer.mediaType === 'video'
? 'div'
: 'button';
const isMediaContainer = isCanvasMediaContainerLayer(layer);
const LayerElement = isMediaContainer ? 'div' : 'button';
const metadataMediaLabel = getLayerMetadataMediaLabel(layer);
return (
<div key={layer.id}>
<LayerElement
{...(layer.mediaType === 'audio' || layer.mediaType === 'video'
{...(isMediaContainer
? { role: 'button', tabIndex: 0 }
: { type: 'button' })}
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
@@ -759,9 +874,7 @@ export function ImageCanvasWorldView({
onLayerContextMenu(event, layer);
}}
onKeyDown={
layer.mediaType === 'audio' || layer.mediaType === 'video'
? handleLayerKeyboardActivation
: undefined
isMediaContainer ? handleLayerKeyboardActivation : undefined
}
onMouseEnter={() => onLayerMouseEnter(layer.id)}
onMouseLeave={() => onLayerMouseLeave(layer.id)}
@@ -778,6 +891,11 @@ export function ImageCanvasWorldView({
layer={layer}
mediaTransform={mediaTransform}
/>
) : layer.mediaType === 'image-sequence' ? (
<ImageCanvasImageSequenceLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : (
<ImageCanvasImageLayer
layer={layer}
@@ -812,13 +930,7 @@ export function ImageCanvasWorldView({
asChild="spanButton"
variant="darkMini"
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${
layer.mediaType === 'audio'
? '音频'
: layer.mediaType === 'video'
? '视频'
: '图片'
}信息`}
label={`查看${layer.title}${metadataMediaLabel}信息`}
icon={<Info className="h-3 w-3" />}
style={inverseScaleStyle}
onClick={(event) => {
@@ -77,10 +77,11 @@ describe('useImageCanvasAssetExportWorkflow', () => {
it('exports canvas assets into a zip with metadata and failed image records', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url === '/generated-ok.png') {
const pathname = new URL(url, 'http://localhost').pathname;
if (pathname === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
if (url === '/generated-video.mp4') {
if (pathname === '/generated-video.mp4') {
return new Response(new Blob(['video'], { type: 'video/mp4' }));
}
return new Response(null, { status: 404 });
@@ -144,10 +145,35 @@ describe('useImageCanvasAssetExportWorkflow', () => {
sourceType: 'generated',
zIndex: 5,
}),
createLayer('sequence', {
title: '角色动作',
src: 'data:image/png;base64,c2VxMQ==',
mediaType: 'image-sequence',
assetKind: 'character-animation',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
durationSeconds: 4,
previewVideoPath:
'/generated-character-drafts/editor/preview.mp4',
sourceType: 'generated',
zIndex: 6,
}),
createLayer('failed', {
title: '失败图',
src: '/missing.png',
zIndex: 6,
zIndex: 7,
}),
]}
/>,
@@ -165,13 +191,26 @@ describe('useImageCanvasAssetExportWorkflow', () => {
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/media/003-生成视频.mp4')).toBeTruthy();
expect(zip.file('导出项目-画布素材/media/004-胜利音效.wav')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/005-失败图.png')).toBeNull();
expect(
zip.file(
'导出项目-画布素材/sequences/005-角色动作/frames/frame-01.png',
),
).toBeTruthy();
expect(
zip.file(
'导出项目-画布素材/sequences/005-角色动作/frames/frame-02.png',
),
).toBeTruthy();
expect(
zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'),
).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull();
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('project-export');
expect(metadata.layers).toHaveLength(6);
expect(metadata.layers).toHaveLength(7);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[2].mediaType).toBe('image');
@@ -184,8 +223,12 @@ describe('useImageCanvasAssetExportWorkflow', () => {
expect(metadata.layers[3].mediaType).toBe('video');
expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav');
expect(metadata.layers[4].mediaType).toBe('audio');
expect(metadata.layers[5].file).toBeNull();
expect(metadata.layers[5].exportError).toContain('404');
expect(metadata.layers[5].file).toBe(
'sequences/005-角色动作/manifest.txt',
);
expect(metadata.layers[5].mediaType).toBe('image-sequence');
expect(metadata.layers[6].file).toBeNull();
expect(metadata.layers[6].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
@@ -228,7 +271,9 @@ describe('useImageCanvasAssetExportWorkflow', () => {
]}
/>,
);
fireEvent.click(screen.getAllByRole('button', { name: '导出单图' }).at(-1)!);
fireEvent.click(
screen.getAllByRole('button', { name: '导出单图' }).at(-1)!,
);
expect(downloadName).toBe('视频导出.webm');
@@ -243,4 +288,69 @@ describe('useImageCanvasAssetExportWorkflow', () => {
);
});
});
it('downloads character animation layers as a zipped image sequence', async () => {
let exportedBlob: Blob | null = null;
let downloadName = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:sequence-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('sequence-export', {
title: '角色动作/跑步',
src: 'data:image/png;base64,c2VxMQ==',
mediaType: 'image-sequence',
assetKind: 'character-animation',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: 'data:image/png;base64,c2VxMQ==',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: 'data:image/png;base64,c2VxMg==',
width: 1024,
height: 1024,
},
],
durationSeconds: 4,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toBe('角色动作 跑步-序列帧.zip');
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧');
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
});
@@ -10,9 +10,12 @@ import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageSequenceFrameFileName,
getLayerAssetExtensionFromTypeOrSrc,
getLayerExportKey,
getLayerImageSequenceFrames,
readLayerAssetBlob,
readLayerImageSequenceFrameBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
@@ -27,6 +30,92 @@ type UseImageCanvasAssetExportWorkflowOptions = {
projectTitle: string;
};
function triggerBrowserDownload(blob: Blob, downloadName: string) {
if (
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
return false;
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = downloadName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
return true;
}
async function buildImageSequenceZip(layer: CanvasLayer) {
const frames = getLayerImageSequenceFrames(layer);
if (!frames.length) {
throw new Error('序列帧为空');
}
const zip = new JSZip();
const framesFolder = zip.folder('frames') ?? zip;
const failedFrames: Array<{
frameIndex: number;
imageSrc: string;
error: string;
}> = [];
let successCount = 0;
for (const [index, frame] of frames.entries()) {
try {
const blob = await readLayerImageSequenceFrameBlob(layer, frame);
framesFolder.file(
getImageSequenceFrameFileName(frame, index, blob.type),
await blobToUint8Array(blob),
);
successCount += 1;
} catch (error) {
failedFrames.push({
frameIndex: frame.frameIndex || index + 1,
imageSrc: frame.imageSrc,
error: error instanceof Error ? error.message : '序列帧读取失败',
});
}
}
if (successCount === 0) {
throw new Error('序列帧读取失败');
}
zip.file(
'metadata.json',
JSON.stringify(
{
title: layer.title,
taskId: layer.taskId ?? null,
mediaType: 'image-sequence',
frameCount: frames.length,
exportedFrameCount: successCount,
durationSeconds: layer.durationSeconds ?? null,
previewVideoPath: layer.previewVideoPath ?? null,
failedFrames,
},
null,
2,
),
);
zip.file(
'manifest.txt',
[
`素材:${layer.title}`,
`类型:序列帧`,
`帧数:${successCount}/${frames.length}`,
layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null,
failedFrames.length ? `失败帧数:${failedFrames.length}` : null,
]
.filter(Boolean)
.join('\n'),
);
return zip.generateAsync({ type: 'blob' });
}
export function useImageCanvasAssetExportWorkflow({
layers,
projectId,
@@ -40,6 +129,28 @@ export function useImageCanvasAssetExportWorkflow({
if (!layer) {
return;
}
if (layer.mediaType === 'image-sequence') {
void buildImageSequenceZip(layer)
.then((zipBlob) => {
const downloaded = triggerBrowserDownload(
zipBlob,
`${sanitizeExportFilePart(layer.title, '角色动作')}-序列帧.zip`,
);
if (!downloaded) {
setAssetExportStatus({
tone: 'error',
message: '当前浏览器不支持素材下载',
});
}
})
.catch(() => {
setAssetExportStatus({
tone: 'error',
message: '序列帧导出失败',
});
});
return;
}
const link = document.createElement('a');
link.href = layer.src;
const extension = getLayerAssetExtensionFromTypeOrSrc(
@@ -101,6 +212,93 @@ export function useImageCanvasAssetExportWorkflow({
: baseFileName;
try {
if (layer.mediaType === 'image-sequence') {
const frames = getLayerImageSequenceFrames(layer);
if (!frames.length) {
throw new Error('序列帧为空');
}
const sequenceFolderName = `sequences/${indexedFileName}`;
const sequenceFolder =
rootFolder.folder(sequenceFolderName) ?? rootFolder;
const framesFolder =
sequenceFolder.folder('frames') ?? sequenceFolder;
const failedFrames: Array<{
frameIndex: number;
imageSrc: string;
error: string;
}> = [];
let successCount = 0;
for (const [frameArrayIndex, frame] of frames.entries()) {
try {
const frameBlob = await readLayerImageSequenceFrameBlob(
layer,
frame,
);
framesFolder.file(
getImageSequenceFrameFileName(
frame,
frameArrayIndex,
frameBlob.type,
),
await blobToUint8Array(frameBlob),
);
successCount += 1;
} catch (error) {
failedFrames.push({
frameIndex: frame.frameIndex || frameArrayIndex + 1,
imageSrc: frame.imageSrc,
error:
error instanceof Error ? error.message : '序列帧读取失败',
});
}
}
if (successCount === 0) {
throw new Error('序列帧读取失败');
}
sequenceFolder.file(
'metadata.json',
JSON.stringify(
{
layerId: layer.id,
title: layer.title,
mediaType: 'image-sequence',
taskId: layer.taskId ?? null,
frameCount: frames.length,
exportedFrameCount: successCount,
durationSeconds: layer.durationSeconds ?? null,
previewVideoPath: layer.previewVideoPath ?? null,
failedFrames,
},
null,
2,
),
);
sequenceFolder.file(
'manifest.txt',
[
`素材:${layer.title}`,
`类型:序列帧`,
`帧数:${successCount}/${frames.length}`,
layer.durationSeconds
? `时长:${layer.durationSeconds}s`
: null,
failedFrames.length ? `失败帧数:${failedFrames.length}` : null,
]
.filter(Boolean)
.join('\n'),
);
imageByKey.set(key, {
key,
file: `${sequenceFolderName}/manifest.txt`,
layer,
blob: new Blob([], { type: 'application/zip' }),
});
continue;
}
const blob = await readLayerAssetBlob(layer);
const mediaType = layer.mediaType ?? 'image';
const extension = getLayerAssetExtensionFromTypeOrSrc(
@@ -109,7 +307,8 @@ export function useImageCanvasAssetExportWorkflow({
layer.objectKey ?? layer.src,
);
const folderName = mediaType === 'image' ? 'images' : 'media';
const targetFolder = mediaType === 'image' ? imagesFolder : mediaFolder;
const targetFolder =
mediaType === 'image' ? imagesFolder : mediaFolder;
const file = `${folderName}/${indexedFileName}.${extension}`;
imageByKey.set(key, {
key,
@@ -650,10 +650,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
initialLayers={[
createLayer({
title: '角色动作',
src: 'https://assets.example.test/action-preview.mp4',
mediaType: 'video',
src: 'generated-character-drafts/editor/actions/frame.png',
mediaType: 'image-sequence',
thumbnailSrc:
'generated-character-drafts/editor/actions/frame.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: 'generated-character-drafts/editor/actions/frame.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: 'https://assets.example.test/action-preview.mp4',
assetKind: 'character-animation',
model: 'seedance2.0',
originalWidth: 1024,
@@ -694,7 +703,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(generateEditorVideoMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:video:/generated-character-drafts/editor/action-preview.mp4:data:image/png;base64,action-frame-edited:4',
'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4',
);
});
});
@@ -1499,7 +1508,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-character-animation-1:角色动作:resource-source:character-animation:video:/generated-character-drafts/editor/preview.mp4:data:image/png;base64,action-frame:4',
'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
+50
View File
@@ -5098,6 +5098,56 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
z-index: 1;
}
.image-canvas-editor__layer-image-sequence-frame {
width: 100%;
height: 100%;
object-fit: contain;
}
.image-canvas-editor__image-sequence-controls {
position: absolute;
left: 0.45rem;
bottom: 0.45rem;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 0.35rem;
max-width: calc(100% - 0.9rem);
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
padding: 0.18rem 0.45rem 0.18rem 0.2rem;
color: #fff;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.24);
backdrop-filter: blur(10px);
}
.image-canvas-editor__image-sequence-toggle {
display: inline-flex;
width: 1.35rem;
height: 1.35rem;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #1f2937;
cursor: pointer;
}
.image-canvas-editor__image-sequence-toggle:disabled {
opacity: 0.58;
cursor: default;
}
.image-canvas-editor__image-sequence-count {
min-width: 2.75rem;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.image-canvas-editor__audio-card {
position: relative;
display: flex;