角色动作素材改为序列帧
将角色动作结果图层主类型改为 image-sequence,并持久化完整帧列表与预览视频来源 在画布中增加序列帧播放器,展示透明 PNG 帧而不是预览视频 将角色动作单素材下载和画布素材导出改为序列帧 ZIP/frames 目录 更新角色动作、导出方案和项目记忆文档,补齐相关前端测试
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user