Spine序列帧动画多模态生成

This commit is contained in:
2026-08-10 20:14:09 +08:00
parent 3c457feb4c
commit 1575afc92c
55 changed files with 7398 additions and 795 deletions
@@ -73,6 +73,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['上传工具', 'upload'],
['生成图片', 'generate'],
['生成视频', 'video'],
['Spine序列帧动画', 'character-animation'],
['生成音乐', 'music'],
['生成规范', 'spec'],
['生成角色形象', 'character'],
@@ -90,6 +91,42 @@ describe('ImageCanvasBottomToolbarView', () => {
expect(switchTool).toHaveBeenCalledTimes(toolExpectations.length);
});
it('keeps the sequence animation entry right after the video tool', () => {
const { toolbar } = renderToolbar();
const sequenceEntry = within(toolbar).getByRole('button', {
name: 'Spine序列帧动画',
});
const videoEntry = within(toolbar).getByRole('button', {
name: '生成视频',
});
const musicEntry = within(toolbar).getByRole('button', {
name: '生成音乐',
});
const toolbarButtons = within(toolbar).getAllByRole('button');
expect(toolbarButtons.indexOf(sequenceEntry)).toBe(
toolbarButtons.indexOf(videoEntry) + 1,
);
expect(toolbarButtons.indexOf(musicEntry)).toBe(
toolbarButtons.indexOf(sequenceEntry) + 1,
);
expect(
sequenceEntry.parentElement?.querySelector(
'.image-canvas-editor__bottom-toolbar-divider',
),
).toBeNull();
expect(
sequenceEntry.querySelector(
'.image-canvas-editor__sequence-animation-icon',
),
).toBeTruthy();
expect(
sequenceEntry.classList.contains(
'image-canvas-editor__sequence-animation-entry',
),
).toBe(true);
});
it('only exposes persistent pressed state for navigation tools', () => {
const { toolbar, rerender, refs } = renderToolbar({
effectiveTool: 'select',
@@ -12,10 +12,11 @@ import {
Upload,
UserRound,
} from 'lucide-react';
import type { RefObject } from 'react';
import type { ComponentType, RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
import { ImageCanvasSequenceAnimationIcon } from './ImageCanvasSequenceAnimationIcon';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
@@ -33,7 +34,7 @@ type ImageCanvasBottomToolbarViewProps = {
const canvasTools: Array<{
id: CanvasTool;
label: string;
icon: typeof MousePointer2;
icon: ComponentType<{ className?: string }>;
separatorBefore?: boolean;
}> = [
{ id: 'select', label: '选择工具', icon: MousePointer2 },
@@ -46,6 +47,11 @@ const canvasTools: Array<{
separatorBefore: true,
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{
id: 'character-animation',
label: 'Spine序列帧动画',
icon: ImageCanvasSequenceAnimationIcon,
},
{ id: 'music', label: '生成音乐', icon: Music },
{
id: 'spec',
@@ -126,7 +132,9 @@ export function ImageCanvasBottomToolbarView({
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
pressed={
hasPersistentPressedState(id) && effectiveTool === id
}
onClick={() => onSwitchTool(id)}
/>
</span>
@@ -143,6 +151,11 @@ export function ImageCanvasBottomToolbarView({
/>
) : null}
<EditorIconButton
className={
id === 'character-animation'
? 'image-canvas-editor__sequence-animation-entry'
: undefined
}
label={label}
title={label}
icon={Icon}
@@ -3387,8 +3387,8 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const animationFrames = Array.from({ length: 48 }, (_, index) => ({
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
width: 720,
height: 405,
}));
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
@@ -3403,8 +3403,8 @@ describe('ImageCanvasEditorView generation integration', () => {
resourceId: 'resource-character-animation-final',
projectId: 'editor-project-character-animation',
imageSrc: animationFrames[0]!.imageSrc,
width: 1024,
height: 1024,
width: 720,
height: 405,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
@@ -3455,7 +3455,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: '角色动画生成面板' }),
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
).toBeNull();
fireEvent.contextMenu(characterLayer.closest('button')!, {
clientX: 260,
@@ -3470,19 +3470,36 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByRole('button', { name: '角色动作生成占位图' }),
).toBeTruthy();
expect(screen.getByText('Action Generator')).toBeTruthy();
expect(screen.getByText('动作')).toBeTruthy();
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
expect(screen.getByText('Spine序列帧动画')).toBeTruthy();
const panel = screen.getByRole('dialog', {
name: 'Spine序列帧动画生成',
});
expect(panel.className).toContain(
'image-canvas-editor__generation-composer--character-animation',
);
expect(
fireEvent.click(
within(panel).getByRole('button', {
name: '动画参数 同图尺寸 · 4秒 · 480p',
name: '序列帧参数 同图尺寸 · 4秒 · 480p',
}),
);
const animationParameterMenu = screen.getByRole('group', {
name: '序列帧参数选项',
});
expect(
within(animationParameterMenu).getByRole('button', { name: '同图尺寸' }),
).toBeTruthy();
expect(
within(animationParameterMenu).getByRole('button', {
name: '32帧·4秒',
}),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '生成40泥点' }),
within(animationParameterMenu).getByRole('button', { name: '480p' }),
).toBeTruthy();
const submitAnimationButton = within(panel).getByRole('button', {
name: '生成Spine序列帧动画',
});
expect(submitAnimationButton.textContent).toBe('生成40泥点');
for (const actionLabel of [
'待机',
'行走',
@@ -3514,31 +3531,39 @@ describe('ImageCanvasEditorView generation integration', () => {
within(panel).getByLabelText('动画描述'),
precisePrompt,
);
expect(
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
).toBeTruthy();
expect(within(panel).getByLabelText('动画描述').textContent).toBe(
precisePrompt,
);
fireEvent.click(
within(panel).getByRole('button', {
name: '动画参数 同图尺寸 · 4秒 · 480p',
name: '序列帧参数 同图尺寸 · 4秒 · 480p',
}),
);
const animationMenu = screen.getByRole('menu', { name: '动画参数选项' });
fireEvent.click(
within(animationMenu).getByRole('button', { name: '清晰度 720p' }),
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', { name: '720p' }),
);
fireEvent.click(
within(animationMenu).getByRole('button', { name: '比例 16:9' }),
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', { name: '16:9' }),
);
fireEvent.click(
within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }),
);
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
expect(
within(panel).getByRole('button', {
name: '动画参数 16:9 · 6秒 · 720p',
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', {
name: '48帧·6秒',
}),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
);
const resizedPlaceholder = screen.getByRole('button', {
name: '角色动作生成占位图',
});
expect(resizedPlaceholder.style.width).toBe('720px');
expect(resizedPlaceholder.style.height).toBe('405px');
expect(within(resizedPlaceholder).getByText('720 x 405')).toBeTruthy();
expect(submitAnimationButton.textContent).toBe('生成120泥点');
fireEvent.click(submitAnimationButton);
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
@@ -3553,6 +3578,14 @@ describe('ImageCanvasEditorView generation integration', () => {
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
canvasCompletion: expect.objectContaining({
placeholder: expect.objectContaining({
width: 720,
height: 405,
originalWidth: 720,
originalHeight: 405,
}),
}),
}),
);
});
@@ -3564,6 +3597,11 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const actionFrame = screen.getByAltText('画布序列帧:角色动作');
expect(actionFrame.tagName.toLowerCase()).toBe('img');
const actionLayerButton = screen.getByRole('button', {
name: '选择角色动作',
});
expect(actionLayerButton.style.width).toBe('720px');
expect(actionLayerButton.style.height).toBe('405px');
await waitFor(() => {
expect(actionFrame.getAttribute('src')).toBe(
'https://assets.test/generated-character-drafts/editor/frame1.png',
@@ -3575,13 +3613,13 @@ describe('ImageCanvasEditorView generation integration', () => {
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
expect(screen.getByText('动作')).toBeTruthy();
expect(screen.getByText('Spine序列帧动画')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '选择角色动作' }));
fireEvent.click(actionLayerButton);
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const remodelPanel = screen.getByRole('dialog', {
name: '角色动画生成面板',
name: 'Spine序列帧动画生成',
});
expect(within(remodelPanel).getByLabelText('动画描述').textContent).toBe(
precisePrompt,
@@ -1546,6 +1546,8 @@ describe('ImageCanvasEditorModel', () => {
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-reference-1',
width: 600,
height: 800,
},
],
};
@@ -1605,6 +1607,8 @@ describe('ImageCanvasEditorModel', () => {
src: '/read/reference-1.png',
resourceId: 'resource-reference-1',
objectKey: 'generated/reference-1.png',
width: 600,
height: 800,
});
});
@@ -677,6 +677,8 @@ function serializeGenerationReference(
mimeType: reference.mimeType,
sizeBytes: reference.sizeBytes,
durationSeconds: reference.durationSeconds,
width: reference.width,
height: reference.height,
resourceId: pointer.resourceId,
sourceAssetId: pointer.sourceAssetId,
};
@@ -2328,6 +2330,10 @@ function hydrateCharacterReference(
const assetObjectId =
stringOrUndefined(snapshot.assetObjectId) ??
stringOrUndefined(resource?.assetObjectId);
const width =
numberFromSnapshot(snapshot.width, 0) || resource?.width || undefined;
const height =
numberFromSnapshot(snapshot.height, 0) || resource?.height || undefined;
const resolvedSrc = resource?.imageSrc || objectKey || src;
return id && label && resolvedSrc
? {
@@ -2338,6 +2344,8 @@ function hydrateCharacterReference(
mimeType,
sizeBytes,
durationSeconds,
width,
height,
objectKey,
assetObjectId,
resourceId,
@@ -220,6 +220,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
onSplitIconSpritesheet: vi.fn(),
onSplitCharacterAnimation: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionModelChange: vi.fn(),
@@ -6,6 +6,7 @@ import type {
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationBackgroundColor,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
@@ -183,6 +184,7 @@ export type CanvasTool =
| 'upload'
| 'generate'
| 'video'
| 'character-animation'
| 'music'
| 'spec'
| 'character'
@@ -219,6 +221,8 @@ export type CharacterReferenceImage = {
mimeType?: string;
sizeBytes?: number;
durationSeconds?: number;
width?: number;
height?: number;
objectKey?: string | null;
assetObjectId?: string | null;
resourceId?: string | null;
@@ -295,6 +299,7 @@ export type GenerateDialogState = {
characterAnimationRatio?: EditorCharacterAnimationRatio;
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
characterAnimationDurationSeconds?: 4 | 5 | 6;
characterAnimationBackgroundColor?: EditorCharacterAnimationBackgroundColor;
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
soundModel?: EditorSoundEffectModel | 'audio1.0';
soundDurationMode?: EditorSoundEffectDurationMode;
@@ -488,9 +493,11 @@ export type CropExpandResizeHandle =
| 'bottom-right';
export type CharacterAnimationPanelState = {
sourceLayerId: string;
sourceLayerId?: string;
references?: CharacterReferenceImage[];
promptText: string;
assetLabel?: string;
backgroundColor?: EditorCharacterAnimationBackgroundColor;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;
@@ -106,6 +106,7 @@ import { useImageCanvasStageController } from './useImageCanvasStageController';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import {
type ImageReferenceUploadCapacity,
resolveGenerationMediaReferenceUploadCapacity,
useImageCanvasUploadWorkflow,
} from './useImageCanvasUploadWorkflow';
import {
@@ -230,10 +231,13 @@ function findSourceAnimationLayer(
if (!sourceResourceId) {
return null;
}
// 与 useImageCanvasGenerationWorkflow 的同一实现保持一致——匹配
// resourceId 或历史落库的 layer id,且仅认定角色图层为动画来源。
return (
layers.find(
(layer) =>
layer.resourceId === sourceResourceId || layer.id === sourceResourceId,
(layer.resourceId === sourceResourceId || layer.id === sourceResourceId) &&
layer.assetKind === 'character',
) ?? null
);
}
@@ -1404,6 +1408,7 @@ export function ImageCanvasEditorView({
canvasSize,
viewport,
activeTool,
generateDialog,
allocateUploadIndex: () => {
layerCounterRef.current += 1;
return layerCounterRef.current;
@@ -1596,6 +1601,8 @@ export function ImageCanvasEditorView({
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
splitSelectedCharacterAnimationFrames,
splittingCharacterAnimationLayerIds,
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
@@ -1658,6 +1665,21 @@ export function ImageCanvasEditorView({
references.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
const generationMediaCapacity =
resolveGenerationMediaReferenceUploadCapacity({
target,
dialog: generateDialog,
});
if (generationMediaCapacity) {
return generationMediaCapacity;
}
if (
target === 'video-reference-image' ||
target === 'video-reference-video' ||
target === 'video-reference-audio'
) {
return null;
}
const generateDialogContextId = generateDialog
? (generateDialog.id ??
`${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`)
@@ -1874,6 +1896,9 @@ export function ImageCanvasEditorView({
availableLayers: layersRef.current,
});
if (!restoredDraft) {
// 与 openRedrawPanel 的失败分支保持一致,避免从元信息面板改造失败时
// 静默无反馈。
showGenerationWarning('该生成配方当前无法恢复,无法改造。');
return false;
}
openCanvasGenerationDialog(restoredDraft);
@@ -2572,6 +2597,7 @@ export function ImageCanvasEditorView({
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splittingIconSpritesheetLayerIds,
splittingCharacterAnimationLayerIds,
uploadDropTarget,
contextMenu,
canvasClipboard,
@@ -2633,6 +2659,11 @@ export function ImageCanvasEditorView({
splitSelectedIconSpritesheet(layer),
);
},
onSplitCharacterAnimation: (layer: CanvasLayer) => {
void flushProjectPersistence().then(() =>
splitSelectedCharacterAnimationFrames(layer),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
@@ -122,6 +122,68 @@ function createComposerProps(
};
}
function CharacterAnimationReferenceHarness() {
const [dialog, setDialog] = useState<GenerateDialogState | null>({
id: 'dialog-character-animation-reference',
mode: 'character-animation',
prompt: '原地挥手',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-character',
generationReferences: [
{
id: 'canvas-layer-character',
label: '角色图片',
src: 'generated/character.png',
mediaType: 'image',
resourceId: 'resource-character',
},
],
characterAnimationRatio: 'same',
characterAnimationResolution: '480p',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
});
if (!dialog) {
return null;
}
return (
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, { setGenerateDialog: setDialog })}
/>
);
}
function CharacterAnimationParametersHarness({
backgroundColor,
}: {
backgroundColor?: GenerateDialogState['characterAnimationBackgroundColor'];
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>({
id: 'dialog-character-animation-parameters',
mode: 'character-animation',
prompt: '原地奔跑',
status: 'idle',
composerOpen: true,
generationReferences: [],
characterAnimationBackgroundColor: backgroundColor,
characterAnimationRatio: '1:1',
characterAnimationResolution: '480p',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
});
if (!dialog) {
return null;
}
return (
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, { setGenerateDialog: setDialog })}
/>
);
}
function SceneValidationHarness({
initialDialog,
}: {
@@ -1889,7 +1951,9 @@ describe('ImageCanvasGenerationComposerView', () => {
try {
fireEvent.keyDown(automaticDuration, { key: 'Escape' });
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
expect(
screen.queryByRole('group', { name: '音效时长选项' }),
).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(globalEscape).not.toHaveBeenCalled();
@@ -1927,6 +1991,347 @@ describe('ImageCanvasGenerationComposerView', () => {
});
});
it('序列帧动作预设每行八个且更多动作按八个逐行展开', () => {
renderComposer({
id: 'dialog-character-animation-action-presets',
mode: 'character-animation',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
});
const actionPresets = within(screen.getByLabelText('动作预设'));
const getActionButtons = () => actionPresets.getAllByRole('button');
const getMoreButton = () =>
actionPresets.getByRole('button', { name: '更多' });
expect(screen.getByLabelText('动作预设').style.gridTemplateColumns).toBe(
'repeat(8, minmax(0, 1fr))',
);
expect(getActionButtons()).toHaveLength(8);
expect(
getActionButtons()
.slice(0, 7)
.map((button) => button.textContent),
).toEqual(['行走', '待机', '奔跑', '跳跃', '攻击', '受击', '倒下']);
expect(getActionButtons()[7]).toBe(getMoreButton());
expect(getMoreButton().getAttribute('aria-expanded')).toBe('false');
expect(actionPresets.queryByRole('button', { name: '冲刺' })).toBeNull();
fireEvent.click(getMoreButton());
expect(getActionButtons()).toHaveLength(16);
expect(getActionButtons().at(-1)).toBe(getMoreButton());
expect(
getActionButtons()
.slice(7, 15)
.map((button) => button.textContent),
).toEqual(['冲刺', '飞行', '悬浮', '爬行', '劈砍', '突刺', '砸击', '拳击']);
expect(actionPresets.queryByRole('button', { name: '踢击' })).toBeNull();
expect(document.activeElement).toBe(
actionPresets.getByRole('button', { name: '冲刺' }),
);
expect(getMoreButton().getAttribute('aria-expanded')).toBe('true');
expect(screen.getByRole('status').textContent).toBe('已显示8个更多动作');
fireEvent.click(getMoreButton());
expect(getActionButtons()).toHaveLength(24);
expect(getActionButtons().at(-1)).toBe(getMoreButton());
expect(
getActionButtons()
.slice(15, 23)
.map((button) => button.textContent),
).toEqual(['踢击', '射击', '施法', '格挡', '闪避', '招架', '举盾', '蹲下']);
expect(actionPresets.queryByRole('button', { name: '呼吸' })).toBeNull();
expect(document.activeElement).toBe(
actionPresets.getByRole('button', { name: '踢击' }),
);
expect(screen.getByRole('status').textContent).toBe('已显示16个更多动作');
fireEvent.click(getMoreButton());
expect(getActionButtons()).toHaveLength(32);
expect(actionPresets.getByRole('button', { name: '伸展' })).toBeTruthy();
expect(actionPresets.getByRole('button', { name: '速度线' })).toBeTruthy();
expect(
actionPresets.queryByRole('button', { name: '冲击波' }),
).toBeTruthy();
expect(
actionPresets.queryByRole('button', { name: '粒子爆发' }),
).toBeNull();
expect(actionPresets.queryByRole('button', { name: '更多' })).toBeTruthy();
fireEvent.click(getMoreButton());
expect(getActionButtons()).toHaveLength(33);
expect(
actionPresets.getByRole('button', { name: '粒子爆发' }),
).toBeTruthy();
expect(actionPresets.queryByRole('button', { name: '更多' })).toBeNull();
const collapseButton = actionPresets.getByRole('button', { name: '收起' });
expect(getActionButtons().at(-1)).toBe(collapseButton);
expect(collapseButton.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByRole('status').textContent).toBe(
'已显示全部25个更多动作',
);
fireEvent.click(collapseButton);
expect(getActionButtons()).toHaveLength(8);
expect(actionPresets.queryByRole('button', { name: '冲刺' })).toBeNull();
expect(getMoreButton().getAttribute('aria-expanded')).toBe('false');
expect(screen.getByRole('status').textContent).toBe('更多动作已收起');
});
it('序列帧画布选参考提示角色图或视频,其他模式保持原提示', () => {
renderComposer(
{
id: 'dialog-character-animation-picking',
mode: 'character-animation',
prompt: '',
status: 'idle',
composerOpen: true,
},
{ isPickingGenerationReferenceFromCanvas: true },
);
expect(
screen.getByText('请选择画布中的角色图或视频作为参考,按 Esc 退出'),
).toBeTruthy();
});
it('普通生成画布选参考仍显示图片提示', () => {
renderComposer(
{
id: 'dialog-image-picking',
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
},
{ isPickingGenerationReferenceFromCanvas: true },
);
expect(
screen.getByText('请选择画布中的图片作为参考图,按 Esc 退出'),
).toBeTruthy();
});
it('序列帧背景、比例、帧数时长和清晰度可独立选中并保留其他参数', () => {
render(<CharacterAnimationParametersHarness backgroundColor="green" />);
const panel = screen.getByRole('dialog', {
name: 'Spine序列帧动画生成',
});
const backgroundOptions = within(panel).getByRole('group', {
name: '背景颜色',
});
const parameterTrigger = within(panel).getByRole('button', {
name: '序列帧参数 1:1 · 4秒 · 480p',
});
expect(screen.queryByRole('group', { name: '画面比例' })).toBeNull();
fireEvent.click(
within(backgroundOptions).getByRole('button', {
name: '背景颜色 蓝色',
}),
);
fireEvent.click(parameterTrigger);
const parameterMenu = screen.getByRole('group', {
name: '序列帧参数选项',
});
const ratioOptions = within(parameterMenu).getByRole('group', {
name: '画面比例',
});
const durationOptions = within(parameterMenu).getByRole('group', {
name: '帧数和时长',
});
const resolutionOptions = within(parameterMenu).getByRole('group', {
name: '清晰度',
});
const greenButton = within(backgroundOptions).getByRole('button', {
name: '背景颜色 绿色',
});
const blueButton = within(backgroundOptions).getByRole('button', {
name: '背景颜色 蓝色',
});
const squareButton = within(ratioOptions).getByRole('button', {
name: '1:1',
});
const landscapeButton = within(ratioOptions).getByRole('button', {
name: '16:9',
});
const defaultDurationButton = within(durationOptions).getByRole('button', {
name: '32帧·4秒',
});
const extendedDurationButton = within(durationOptions).getByRole('button', {
name: '40帧·5秒',
});
const defaultResolutionButton = within(resolutionOptions).getByRole(
'button',
{ name: '480p' },
);
const highResolutionButton = within(resolutionOptions).getByRole('button', {
name: '720p',
});
expect(greenButton.getAttribute('aria-pressed')).toBe('false');
expect(blueButton.getAttribute('aria-pressed')).toBe('true');
expect(squareButton.getAttribute('aria-pressed')).toBe('true');
expect(defaultDurationButton.getAttribute('aria-pressed')).toBe('true');
expect(defaultResolutionButton.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(landscapeButton);
fireEvent.click(extendedDurationButton);
fireEvent.click(highResolutionButton);
expect(
within(backgroundOptions)
.getByRole('button', { name: '背景颜色 蓝色' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
within(backgroundOptions)
.getByRole('button', { name: '背景颜色 绿色' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
screen.getByRole('button', { name: '16:9' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '1:1' }).getAttribute('aria-pressed'),
).toBe('false');
expect(
screen
.getByRole('button', { name: '40帧·5秒' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '32帧·4秒' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
screen.getByRole('button', { name: '720p' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '480p' }).getAttribute('aria-pressed'),
).toBe('false');
expect(
within(panel).getByRole('button', {
name: '序列帧参数 16:9 · 5秒 · 720p',
}),
).toBeTruthy();
expect(
within(panel)
.getByRole('button', { name: '生成Spine序列帧动画' })
.classList.contains('image-canvas-editor__character-animation-submit'),
).toBe(true);
});
it('旧角色图入口未显式选键色时不伪装成绿色已选', () => {
render(<CharacterAnimationParametersHarness backgroundColor={undefined} />);
const backgroundOptions = screen.getByRole('group', { name: '背景颜色' });
expect(
within(backgroundOptions)
.getAllByRole('button')
.every((button) => button.getAttribute('aria-pressed') === 'false'),
).toBe(true);
});
it('序列帧生成中禁用动作预设和更多按钮', () => {
renderComposer({
id: 'dialog-character-animation-generating-actions',
mode: 'character-animation',
prompt: '原地奔跑',
status: 'generating',
composerOpen: true,
generationReferences: [],
});
const actionPresets = within(screen.getByLabelText('动作预设'));
const actionButtons = actionPresets.getAllByRole('button');
expect(actionButtons).toHaveLength(8);
expect(
actionButtons.every((button) => (button as HTMLButtonElement).disabled),
).toBe(true);
fireEvent.click(actionPresets.getByRole('button', { name: '更多' }));
expect(actionPresets.getAllByRole('button')).toHaveLength(8);
expect(actionPresets.queryByRole('button', { name: '冲刺' })).toBeNull();
expect(
within(screen.getByRole('group', { name: '背景颜色' }))
.getAllByRole('button')
.every((button) => (button as HTMLButtonElement).disabled),
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '序列帧参数 1:1 · 4秒 · 480p',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.queryByRole('group', { name: '画面比例' })).toBeNull();
expect(
(
screen.getByRole('button', {
name: '生成Spine序列帧动画',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
it('移除序列帧参考素材后回到文本模式并清除同图尺寸', () => {
render(<CharacterAnimationReferenceHarness />);
const panel = screen.getByRole('dialog', {
name: 'Spine序列帧动画生成',
});
fireEvent.click(
within(panel).getByRole('button', {
name: '序列帧参数 同图尺寸 · 4秒 · 480p',
}),
);
expect(
within(screen.getByRole('group', { name: '画面比例' })).getByRole(
'button',
{ name: '同图尺寸' },
),
).toBeTruthy();
fireEvent.click(
within(panel).getByRole('button', { name: '删除角色图片' }),
);
expect(
within(screen.getByRole('group', { name: '画面比例' })).queryByRole(
'button',
{ name: '同图尺寸' },
),
).toBeNull();
const ratioOptions = within(
screen.getByRole('group', { name: '画面比例' }),
);
const squareRatioButton = ratioOptions.getByRole('button', {
name: '1:1',
});
const landscapeRatioButton = ratioOptions.getByRole('button', {
name: '16:9',
});
expect(squareRatioButton.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(landscapeRatioButton);
expect(landscapeRatioButton.getAttribute('aria-pressed')).toBe('true');
expect(squareRatioButton.getAttribute('aria-pressed')).toBe('false');
});
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
@@ -68,9 +68,17 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_BACKGROUND_OPTIONS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
CHARACTER_ANIMATION_MORE_ACTION_PROMPTS,
CHARACTER_ANIMATION_RATIO_OPTIONS,
CHARACTER_ANIMATION_RESOLUTION_OPTIONS,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
@@ -81,6 +89,7 @@ import {
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
EDITOR_VIDEO_RESOLUTION_OPTIONS,
resizeGenerationPlaceholderToCharacterAnimationSelection,
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
VIDEO_MODEL_SEEDANCE_2_FAST,
@@ -371,6 +380,432 @@ function VideoOptionChoice({
);
}
const CHARACTER_ANIMATION_ACTIONS_PER_ROW = 8;
function ImageCanvasSequenceAnimationComposerView({
dialog,
style,
generationReferenceButtonRef,
isGenerationReferenceMenuOpen,
setGenerateDialog,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
renderEditorPortal,
buildPortalMenuStyle,
onRequestUpload,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
isGenerationReferenceMenuOpen: boolean;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onSubmit: () => void;
}) {
const [visibleMoreActionCount, setVisibleMoreActionCount] = useState(0);
const [actionPresetAnnouncement, setActionPresetAnnouncement] = useState('');
const [isParameterMenuOpen, setIsParameterMenuOpen] = useState(false);
const parameterMenuButtonRef = useRef<HTMLButtonElement | null>(null);
const pendingActionFocusIndexRef = useRef<number | null>(null);
const pendingActionFocusTargetRef = useRef<HTMLButtonElement | null>(null);
const reference = dialog.generationReferences?.[0];
const isGenerating = dialog.status === 'generating';
const resolution = dialog.characterAnimationResolution ?? '480p';
const ratio = dialog.characterAnimationRatio ?? '1:1';
const frameCount = dialog.characterAnimationFrameCount ?? 32;
const durationSeconds = dialog.characterAnimationDurationSeconds ?? 4;
const backgroundColor = dialog.characterAnimationBackgroundColor;
const hasExpandedActions = visibleMoreActionCount > 0;
const hasExpandedAllActions =
visibleMoreActionCount >= CHARACTER_ANIMATION_MORE_ACTION_PROMPTS.length;
const visibleMoreActions = CHARACTER_ANIMATION_MORE_ACTION_PROMPTS.slice(
0,
visibleMoreActionCount,
);
const closeParameterMenu = useCallback(() => {
setIsParameterMenuOpen(false);
}, []);
useImageCanvasFloatingOptionDismiss({
isOpen: isParameterMenuOpen,
boundaryRefs: [parameterMenuButtonRef],
onDismiss: closeParameterMenu,
restoreFocusRef: parameterMenuButtonRef,
});
useEffect(() => {
if (pendingActionFocusIndexRef.current === null) {
return;
}
pendingActionFocusTargetRef.current?.focus();
pendingActionFocusIndexRef.current = null;
}, [visibleMoreActionCount]);
const price = calculateCharacterAnimationPrice(
CHARACTER_ANIMATION_MODEL,
resolution,
durationSeconds,
);
const updateDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character-animation'
? resizeGenerationPlaceholderToCharacterAnimationSelection({
...currentDialog,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
...patch,
})
: currentDialog,
);
};
const applyActionPrompt = (label: string, text?: string) => {
updateDialog({ prompt: text ?? `${label}动作,角色完整且动作连贯。` });
};
return (
<>
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--character-animation"
style={style}
role="dialog"
aria-label="Spine序列帧动画生成"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__reference-strip">
{reference ? (
<VideoReferenceChip
reference={reference}
index={0}
onRemove={() => {
// 仅当比例是“同图尺寸”时才回落到 1:1;用户显式选过 4:3/16:9 等
// 比例时移除参考应保留原选择。
const isSameRatio = dialog.characterAnimationRatio === 'same';
updateDialog({
sourceLayerId: undefined,
generationReferences: [],
...(isSameRatio ? { characterAnimationRatio: '1:1' } : {}),
});
}}
/>
) : null}
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="character"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
label="参考"
ariaLabel="添加序列帧参考素材"
disabled={isGenerating}
isAdd
onClick={() => {
closeParameterMenu();
setIsGenerationReferenceMenuOpen((open) => !open);
}}
/>
</div>
<AutoGrowTextArea
aria-label="动画描述"
value={dialog.prompt}
maxLength={4000}
disabled={isGenerating}
placeholder="请描述人物、朝向、做什么、如何做。比如:让人物朝侧面原地不断快速奔跑。"
className="max-h-64 image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
onValueChange={(prompt) => updateDialog({ prompt })}
/>
<div
className="image-canvas-editor__character-animation-backgrounds"
role="group"
aria-label="背景颜色"
>
{CHARACTER_ANIMATION_BACKGROUND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`image-canvas-editor__character-animation-color-button image-canvas-editor__character-animation-color-button--${option.value}`}
aria-label={`背景颜色 ${option.label}`}
aria-pressed={backgroundColor === option.value}
disabled={isGenerating}
onClick={() =>
updateDialog({
characterAnimationBackgroundColor: option.value,
})
}
>
{option.label}
</button>
))}
</div>
<div
className="image-canvas-editor__character-animation-presets"
aria-label="动作预设"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${CHARACTER_ANIMATION_ACTIONS_PER_ROW}, minmax(0, 1fr))`,
}}
>
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
<button
key={preset.label}
type="button"
className="image-canvas-editor__character-animation-preset image-canvas-editor__character-animation-preset--action"
disabled={isGenerating}
onClick={() => applyActionPrompt(preset.label, preset.text)}
>
{preset.label}
</button>
))}
{visibleMoreActions.map((label, index) => (
<button
key={label}
ref={(button) => {
if (index === pendingActionFocusIndexRef.current) {
pendingActionFocusTargetRef.current = button;
}
}}
type="button"
className="image-canvas-editor__character-animation-preset image-canvas-editor__character-animation-preset--action"
disabled={isGenerating}
onClick={() => applyActionPrompt(label)}
>
{label}
</button>
))}
<button
type="button"
className="image-canvas-editor__character-animation-preset image-canvas-editor__character-animation-preset--action"
disabled={isGenerating}
aria-expanded={hasExpandedActions}
onClick={() => {
const nextCount = hasExpandedAllActions
? 0
: Math.min(
visibleMoreActionCount +
CHARACTER_ANIMATION_ACTIONS_PER_ROW,
CHARACTER_ANIMATION_MORE_ACTION_PROMPTS.length,
);
pendingActionFocusIndexRef.current = hasExpandedAllActions
? null
: visibleMoreActionCount;
setVisibleMoreActionCount(nextCount);
setActionPresetAnnouncement(
nextCount === 0
? '更多动作已收起'
: nextCount >= CHARACTER_ANIMATION_MORE_ACTION_PROMPTS.length
? `已显示全部${nextCount}个更多动作`
: `已显示${nextCount}个更多动作`,
);
}}
>
{hasExpandedAllActions ? '收起' : '更多'}
</button>
<span
className="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{actionPresetAnnouncement}
</span>
</div>
<div className="image-canvas-editor__character-animation-footer">
<PlatformInlineOptionButton
ref={parameterMenuButtonRef}
className="image-canvas-editor__character-animation-parameter-trigger"
aria-label={`序列帧参数 ${ratio === 'same' ? '同图尺寸' : ratio} · ${durationSeconds}秒 · ${resolution}`}
aria-expanded={isParameterMenuOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsParameterMenuOpen((open) => !open);
}}
>
{ratio === 'same' ? '同图尺寸' : ratio} · {durationSeconds}秒 ·{' '}
{resolution}
</PlatformInlineOptionButton>
<PlatformActionButton
className="image-canvas-editor__character-animation-submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
disabled={isGenerating || !dialog.prompt.trim()}
aria-label="生成Spine序列帧动画"
onClick={() => {
if (!isGenerating) {
onSubmit();
}
}}
>
{isGenerating ? '生成中' : `生成${price}泥点`}
</PlatformActionButton>
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage tone="error" surface="platform" size="xs">
{dialog.errorMessage ?? '生成Spine序列帧动画失败'}
</PlatformStatusMessage>
) : null}
</div>
{isParameterMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-animation-parameter-menu image-canvas-editor__portal-menu"
label="序列帧参数选项"
variant="options"
placement="top-start"
style={buildPortalMenuStyle(
parameterMenuButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<section className="image-canvas-editor__character-animation-menu-section">
<span className="image-canvas-editor__character-animation-menu-title">
比例
</span>
<div
className="image-canvas-editor__character-animation-ratio-grid"
role="group"
aria-label="画面比例"
>
{CHARACTER_ANIMATION_RATIO_OPTIONS.filter(
(option) => reference || option.value !== 'same',
).map((option) => (
<button
key={option.value}
type="button"
className="image-canvas-editor__character-animation-ratio-choice"
aria-label={
option.value === 'same' ? '同图尺寸' : option.value
}
aria-pressed={ratio === option.value}
disabled={isGenerating}
onClick={() =>
updateDialog({ characterAnimationRatio: option.value })
}
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={option.value}
aria-hidden="true"
/>
<span>
{option.value === 'same' ? '同图尺寸' : option.value}
</span>
</button>
))}
</div>
</section>
<section className="image-canvas-editor__character-animation-menu-section">
<span className="image-canvas-editor__character-animation-menu-title">
时长
</span>
<div
className="image-canvas-editor__character-animation-menu-options"
role="group"
aria-label="帧数和时长"
>
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
<button
key={option.frameCount}
type="button"
className="image-canvas-editor__character-animation-menu-pill"
aria-pressed={frameCount === option.frameCount}
disabled={isGenerating}
onClick={() =>
updateDialog({
characterAnimationFrameCount: option.frameCount,
characterAnimationDurationSeconds:
option.durationSeconds,
})
}
>
{option.label}
</button>
))}
</div>
</section>
<section className="image-canvas-editor__character-animation-menu-section">
<span className="image-canvas-editor__character-animation-menu-title">
清晰度
</span>
<div
className="image-canvas-editor__character-animation-menu-options"
role="group"
aria-label="清晰度"
>
{CHARACTER_ANIMATION_RESOLUTION_OPTIONS.map((value) => (
<button
key={value}
type="button"
className="image-canvas-editor__character-animation-menu-pill"
aria-pressed={resolution === value}
disabled={isGenerating}
onClick={() =>
updateDialog({ characterAnimationResolution: value })
}
>
{value}
</button>
))}
</div>
</section>
</PlatformFloatingMenu>,
)
: null}
{isGenerationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="序列帧参考素材来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(true);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('video-reference-image');
}}
>
上传图片
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('video-reference-video');
}}
>
上传视频
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}
function ImageCanvasVideoGenerationComposerView({
dialog,
style,
@@ -1689,6 +2124,26 @@ export function ImageCanvasGenerationComposerView({
/>
) : null}
{generateDialog?.mode === 'character-animation' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSequenceAnimationComposerView
dialog={generateDialog}
style={generationComposerStyle}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitCharacterAnimation}
/>
) : null}
{(generateDialog?.mode === 'audio-sound-effect' ||
generateDialog?.mode === 'audio-background-music') &&
generateDialog.composerOpen !== false &&
@@ -1768,7 +2223,9 @@ export function ImageCanvasGenerationComposerView({
) : null}
{isPickingGenerationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为参考图,按 Esc 退出
{generateDialog?.mode === 'character-animation'
? '请选择画布中的角色图或视频作为参考,按 Esc 退出'
: '请选择画布中的图片作为参考图,按 Esc 退出'}
</div>
) : null}
{isPickingQuickEditReferenceFromCanvas ? (
@@ -1824,6 +2281,7 @@ export function ImageCanvasGenerationComposerView({
{characterAnimationPanel &&
characterAnimationSourceLayer &&
generateDialog?.mode !== 'character-animation' &&
characterAnimationPanelStyle ? (
<ImageCanvasCharacterAnimationPanelView
panel={characterAnimationPanel}
@@ -487,6 +487,19 @@ describe('ImageCanvasGenerationDialogModel', () => {
).toMatchObject({ mode: 'generate', prompt: '原图提示' });
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
sourceLayerId: 'layer-source',
references: [
{
id: 'canvas-layer-source',
label: '源图',
src: 'data:image/png;base64,source',
objectKey: undefined,
assetObjectId: undefined,
resourceId: 'resource-source',
sourceAssetId: undefined,
width: 1024,
height: 768,
},
],
promptText: '',
resolution: '480p',
ratio: 'same',
@@ -1374,6 +1387,122 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores an uploaded video reference from the stable generation snapshot', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-animation-result',
sourceType: 'generated',
assetKind: 'character-animation',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [
{ id: 'inputMode', title: '输入模式', value: 'video' },
{ id: 'prompt', title: '动作描述', value: '复刻奔跑节奏' },
{
id: 'referenceSrc',
title: '参考素材',
value: 'users/user-1/action.mp4',
},
{
id: 'referenceMediaType',
title: '参考类型',
value: 'video',
},
{ id: 'referenceWidth', title: '参考宽度', value: 1920 },
{ id: 'referenceHeight', title: '参考高度', value: 1080 },
{ id: 'ratio', title: '比例', value: 'same' },
],
references: [
{
id: 'source',
title: '参考视频',
label: '奔跑动作.mp4',
refType: 'asset',
refId: 'asset-video-reference',
},
],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
availableLayers: [],
}),
).toMatchObject({
mode: 'character-animation',
prompt: '复刻奔跑节奏',
sourceLayerId: undefined,
characterAnimationRatio: 'same',
generationReferences: [
{
label: '奔跑动作.mp4',
src: 'users/user-1/action.mp4',
objectKey: 'users/user-1/action.mp4',
sourceAssetId: 'asset-video-reference',
mediaType: 'video',
width: 1920,
height: 1080,
},
],
});
});
it('does not restore a project resource id that is unavailable in the current canvas', () => {
const draft = createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-animation-result',
sourceType: 'generated',
assetKind: 'character-animation',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [
{ id: 'inputMode', title: '输入模式', value: 'image' },
{ id: 'prompt', title: '动作描述', value: '循环待机' },
{
id: 'referenceSrc',
title: '参考素材',
value: 'users/user-1/hero.png',
},
{
id: 'referenceMediaType',
title: '参考类型',
value: 'image',
},
],
references: [
{
id: 'source',
title: '角色图片',
label: '旧项目角色',
refType: 'project-resource',
refId: 'resource-from-another-project',
},
],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
availableLayers: [],
});
expect(draft).toMatchObject({
mode: 'character-animation',
generationReferences: [
{
label: '旧项目角色',
src: 'users/user-1/hero.png',
objectKey: 'users/user-1/hero.png',
mediaType: 'image',
},
],
});
expect(draft?.generationReferences?.[0]?.resourceId).toBeUndefined();
});
it('inherits and normalizes quick edit model parameters from the source', () => {
expect(
createQuickEditPanelDraft(
@@ -1416,13 +1545,12 @@ describe('ImageCanvasGenerationDialogModel', () => {
assetKind: 'character',
});
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: characterLayer,
}),
).toMatchObject({
const characterLayerDraft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: characterLayer,
});
expect(characterLayerDraft).toMatchObject({
mode: 'character-animation',
sourceLayerId: 'layer-character',
status: 'idle',
@@ -1432,14 +1560,21 @@ describe('ImageCanvasGenerationDialogModel', () => {
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
x: 240,
y: 180,
width: 480,
height: 360,
originalWidth: 480,
originalHeight: 360,
},
});
expect(
characterLayerDraft?.characterAnimationBackgroundColor,
).toBeUndefined();
expect(
createCharacterAnimationGenerationDialogDraft({ canvasSize, viewport })
?.characterAnimationBackgroundColor,
).toBe('green');
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
@@ -48,6 +48,7 @@ import {
isNormalizedCanvasGenerationInputs,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToCharacterAnimationSelection,
resizeGenerationPlaceholderToImageSelection,
resizeGenerationPlaceholderToVideoSelection,
resolveDialogExtraImageReferenceLimit,
@@ -1264,31 +1265,104 @@ function createNormalizedGenerationDialogDraft({
}
if (inputs.action === 'character-animation.generate') {
const animationSource = resolveNormalizedReferences(
const canvasReferences = resolveNormalizedReferences(
sourceLayer,
'source',
availableLayers,
)
.map((reference) =>
(availableLayers ?? []).find(
(layer) => createCanvasLayerReference(layer).id === reference.id,
),
)
.find((layer): layer is CanvasLayer => Boolean(layer));
if (!animationSource) {
);
const inputMode = getNormalizedString(sourceLayer, 'inputMode', 'image');
const storedReferenceSrc = getNormalizedString(
sourceLayer,
'referenceSrc',
).trim();
const storedReferenceMediaType = getNormalizedString(
sourceLayer,
'referenceMediaType',
inputMode === 'video' ? 'video' : 'image',
);
const storedReferenceInput = sourceLayer.generationInputs?.references.find(
(reference) => reference.id === 'source',
);
const storedReferenceWidth = getNormalizedNumber(
sourceLayer,
'referenceWidth',
0,
);
const storedReferenceHeight = getNormalizedNumber(
sourceLayer,
'referenceHeight',
0,
);
const restoredReference =
canvasReferences[0] ??
(storedReferenceSrc
? {
id: `restored-character-animation-reference:${sourceLayer.id}`,
label:
storedReferenceInput?.label ??
(storedReferenceMediaType === 'video' ? '参考视频' : '角色图片'),
src: storedReferenceSrc,
objectKey: storedReferenceSrc,
...(storedReferenceInput?.refType === 'asset'
? { sourceAssetId: storedReferenceInput.refId }
: {}),
...(storedReferenceWidth > 0
? { width: storedReferenceWidth }
: {}),
...(storedReferenceHeight > 0
? { height: storedReferenceHeight }
: {}),
mediaType:
storedReferenceMediaType === 'video'
? ('video' as const)
: ('image' as const),
}
: undefined);
const animationSource = restoredReference
? (availableLayers ?? []).find(
(layer) =>
createCanvasLayerReference(layer).id === restoredReference.id,
)
: undefined;
if (inputMode !== 'text' && !restoredReference) {
return null;
}
const draft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: animationSource,
...(animationSource?.assetKind === 'character'
? { layer: animationSource }
: {}),
});
// 帧数/时长必须匹配合法配对(32/4、40/5、48/6),否则 UI 会同时高亮
// 冲突的选项而提交被后端拒绝。这里按合法配对校验,损坏数据回退到默认 32/4。
const restoredFrameCount = getNormalizedNumber(sourceLayer, 'frameCount', 32);
const restoredDurationSeconds = getNormalizedNumber(
sourceLayer,
'durationSeconds',
4,
);
const durationPairValid = CHARACTER_ANIMATION_DURATION_OPTIONS.some(
(option) =>
option.frameCount === restoredFrameCount &&
option.durationSeconds === restoredDurationSeconds,
);
const pairedFrameCount = durationPairValid ? restoredFrameCount : 32;
const pairedDurationSeconds = durationPairValid
? restoredDurationSeconds
: 4;
return draft
? placeDraftBesideSourceLayer(
{
resizeGenerationPlaceholderToCharacterAnimationSelection({
...draft,
prompt,
sourceLayerId: animationSource.id,
sourceLayerId: animationSource?.id,
generationReferences: restoredReference ? [restoredReference] : [],
characterAnimationBackgroundColor: (getNormalizedString(
sourceLayer,
'backgroundColor',
).trim() ||
undefined) as GenerateDialogState['characterAnimationBackgroundColor'],
characterAnimationResolution: getNormalizedString(
sourceLayer,
'resolution',
@@ -1297,19 +1371,11 @@ function createNormalizedGenerationDialogDraft({
characterAnimationRatio: getNormalizedString(
sourceLayer,
'ratio',
'same',
animationSource ? 'same' : '1:1',
) as GenerateDialogState['characterAnimationRatio'],
characterAnimationFrameCount: getNormalizedNumber(
sourceLayer,
'frameCount',
32,
) as GenerateDialogState['characterAnimationFrameCount'],
characterAnimationDurationSeconds: getNormalizedNumber(
sourceLayer,
'durationSeconds',
4,
) as GenerateDialogState['characterAnimationDurationSeconds'],
},
characterAnimationFrameCount: pairedFrameCount as GenerateDialogState['characterAnimationFrameCount'],
characterAnimationDurationSeconds: pairedDurationSeconds as GenerateDialogState['characterAnimationDurationSeconds'],
}),
sourceLayer,
)
: null;
@@ -2019,6 +2085,7 @@ export function createCharacterAnimationPanelDraft(
}
return {
sourceLayerId: layer.id,
references: [createCanvasLayerReference(layer)],
promptText: '',
resolution: '480p',
ratio: 'same',
@@ -2035,20 +2102,22 @@ export function createCharacterAnimationGenerationDialogDraft({
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
layer: CanvasLayer;
layer?: CanvasLayer;
}): Omit<CanvasGenerationDialogState, 'id'> | null {
if (layer.assetKind !== 'character') {
if (layer && layer.assetKind !== 'character') {
return null;
}
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
const draft: Omit<CanvasGenerationDialogState, 'id'> = {
mode: 'character-animation',
sourceLayerId: layer.id,
...(layer ? { sourceLayerId: layer.id } : {}),
generationReferences: layer ? [createCanvasLayerReference(layer)] : [],
prompt: '',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationRatio: layer ? 'same' : '1:1',
...(layer ? {} : { characterAnimationBackgroundColor: 'green' as const }),
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
@@ -2060,6 +2129,7 @@ export function createCharacterAnimationGenerationDialogDraft({
originalHeight: CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE.height,
},
};
return resizeGenerationPlaceholderToCharacterAnimationSelection(draft);
}
export function assignCharacterSpecReference(
@@ -2111,6 +2181,23 @@ export function appendGenerationReference(
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
}
if (dialog?.mode === 'character-animation') {
const mediaType = getReferenceMediaType(layer);
if (
mediaType === 'audio' ||
(mediaType === 'image' &&
layer.assetKind !== 'character' &&
layer.assetKind !== 'character-animation')
) {
return dialog;
}
return resizeGenerationPlaceholderToCharacterAnimationSelection({
...resetFailedGenerationDialog(dialog),
sourceLayerId: layer.id,
generationReferences: [createCanvasLayerReference(layer)],
composerOpen: true,
});
}
if (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
@@ -8,6 +8,7 @@ import type {
import {
applyEditorGenerationPricingConfig,
BACKGROUND_MUSIC_MODEL_SUNO,
buildCharacterAnimationGenerationInputs,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
@@ -52,6 +53,7 @@ import {
isNormalizedCanvasGenerationInputs,
isQuickEditUnsupportedAssetKind,
resolveCharacterAnimationSourceImageSrc,
resolveEditorCharacterAnimationFramePixelSize,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -62,6 +64,59 @@ import {
} from './ImageCanvasGenerationModel';
describe('ImageCanvasGenerationModel', () => {
it('resolves character animation pixel and display sizes from ratio and resolution', () => {
expect(
resolveEditorCharacterAnimationFramePixelSize({
ratio: '16:9',
resolution: '480p',
}),
).toEqual({ width: 480, height: 270 });
expect(
resolveEditorCharacterAnimationFramePixelSize({
ratio: '16:9',
resolution: '720p',
}),
).toEqual({ width: 720, height: 405 });
const sameRatioSize = resolveEditorCharacterAnimationFramePixelSize({
ratio: 'same',
resolution: '720p',
referenceWidth: 768,
referenceHeight: 1024,
});
expect(sameRatioSize).toEqual({ width: 540, height: 720 });
});
it('persists character animation reference dimensions for same-ratio redraws', () => {
const inputs = buildCharacterAnimationGenerationInputs(
'原地待机',
null,
{
id: 'uploaded-character',
label: '竖版角色.png',
src: '/uploads/character.png',
mediaType: 'image',
width: 768,
height: 1024,
},
{
referenceSrc: 'users/user-1/character.png',
referenceMediaType: 'image',
ratio: 'same',
},
);
expect(inputs.fields).toEqual(
expect.arrayContaining([
{ id: 'referenceWidth', title: '参考宽度', value: 768 },
{ id: 'referenceHeight', title: '参考高度', value: 1024 },
{ id: 'ratio', title: '比例', value: 'same' },
]),
);
expect(
inputs.fields.find((field) => field.id === 'backgroundColor'),
).toBeUndefined();
});
afterEach(() => {
applyEditorGenerationPricingConfig({
models: EDITOR_MODEL_MUD_POINT_CONFIG,
@@ -60,12 +60,12 @@ export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
export const CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE = {
width: 1024,
height: 1024,
width: 480,
height: 480,
};
export const CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE = {
width: 420,
height: 420,
width: 480,
height: 480,
};
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
@@ -323,6 +323,80 @@ export function resizeGenerationPlaceholderToVideoSelection(
},
};
}
export function resolveEditorCharacterAnimationFramePixelSize({
ratio,
resolution,
referenceWidth,
referenceHeight,
}: {
ratio: EditorCharacterAnimationRatio | null | undefined;
resolution: EditorCharacterAnimationResolution | null | undefined;
referenceWidth?: number | null;
referenceHeight?: number | null;
}): EditorImageFrameSize {
const hasReferenceSize =
typeof referenceWidth === 'number' &&
Number.isFinite(referenceWidth) &&
referenceWidth > 0 &&
typeof referenceHeight === 'number' &&
Number.isFinite(referenceHeight) &&
referenceHeight > 0;
const resolvedRatio =
ratio === 'same' && hasReferenceSize
? `${referenceWidth}:${referenceHeight}`
: ratio === 'same'
? '1:1'
: ratio;
const ratioNumbers = resolveAspectRatioNumbers(resolvedRatio ?? '1:1');
const longEdge = resolution === '720p' ? 720 : 480;
if (ratioNumbers.width >= ratioNumbers.height) {
return {
width: longEdge,
height: Math.max(
1,
Math.round((longEdge * ratioNumbers.height) / ratioNumbers.width),
),
};
}
return {
width: Math.max(
1,
Math.round((longEdge * ratioNumbers.width) / ratioNumbers.height),
),
height: longEdge,
};
}
export function resizeGenerationPlaceholderToCharacterAnimationSelection<
T extends GenerateDialogState,
>(dialog: T): T {
if (dialog.mode !== 'character-animation' || !dialog.placeholder) {
return dialog;
}
const reference = dialog.generationReferences?.[0];
const pixelSize = resolveEditorCharacterAnimationFramePixelSize({
ratio: dialog.characterAnimationRatio,
resolution: dialog.characterAnimationResolution,
referenceWidth: reference?.width,
referenceHeight: reference?.height,
});
const centerX = dialog.placeholder.x + dialog.placeholder.width / 2;
const centerY = dialog.placeholder.y + dialog.placeholder.height / 2;
return {
...dialog,
placeholder: {
...dialog.placeholder,
x: centerX - pixelSize.width / 2,
y: centerY - pixelSize.height / 2,
width: pixelSize.width,
height: pixelSize.height,
originalWidth: pixelSize.width,
originalHeight: pixelSize.height,
},
} as T;
}
export const VIDEO_MODEL_SEEDANCE_2 = 'seedance2.0';
export const VIDEO_MODEL_SEEDANCE_2_FAST = 'seedance2.0-fast';
export const VIDEO_MODEL_KLING_3 = 'kling3.0';
@@ -512,14 +586,47 @@ export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
}
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '行走', text: '循环行走动作,步伐稳定。' },
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '奔跑', text: '循环奔跑动作,动作清晰有力。' },
{ label: '跳跃', text: '起跳、滞空、落地动作。' },
{ label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' },
{ label: '受击', text: '受击后短暂后仰并恢复站姿。' },
{ label: '倒下', text: '倒下动作,重心下落自然。' },
] as const;
export const CHARACTER_ANIMATION_MORE_ACTION_PROMPTS = [
'冲刺',
'飞行',
'悬浮',
'爬行',
'劈砍',
'突刺',
'砸击',
'拳击',
'踢击',
'射击',
'施法',
'格挡',
'闪避',
'招架',
'举盾',
'蹲下',
'呼吸',
'眨眼',
'环顾',
'伸展',
'速度线',
'运动模糊',
'命中帧',
'冲击波',
'粒子爆发',
] as const;
export const CHARACTER_ANIMATION_BACKGROUND_OPTIONS = [
{ label: '白色', value: 'white', hex: '#FFFFFF' },
{ label: '绿色', value: 'green', hex: '#00FF00' },
{ label: '蓝色', value: 'blue', hex: '#0000FF' },
{ label: '紫色', value: 'purple', hex: '#8000FF' },
] as const;
export const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{
label: string;
value: EditorCharacterAnimationRatio;
@@ -536,6 +643,10 @@ export const CHARACTER_ANIMATION_DURATION_OPTIONS = [
{ label: '40帧·5秒', frameCount: 40, durationSeconds: 5 },
{ label: '48帧·6秒', frameCount: 48, durationSeconds: 6 },
] as const;
// 序列帧清晰度唯一来源。模型 seedance2.0-fast 不支持 1080p(与视频编辑器对该
// 模型的 1080p 禁用一致),因此这里只有 480p/720p。新增档位需同时改
// resolveEditorCharacterAnimationFramePixelSize 的 longEdge 与后端 normalize 校验。
export const CHARACTER_ANIMATION_RESOLUTION_OPTIONS = ['480p', '720p'] as const;
export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 854, height: 480 };
export const VIDEO_FRAME_DISPLAY_SIZE = { width: 854, height: 480 };
export const AUDIO_FRAME_ORIGINAL_SIZE = { width: 420, height: 120 };
@@ -710,7 +821,9 @@ export function buildQuickEditModelOptions(currentModel: string) {
}
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
return layer.assetKind === 'icon';
return (
layer.assetKind === 'icon' || layer.assetKind === 'character-animation'
);
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
@@ -750,7 +863,7 @@ export function getLayerKindLabel(layer: CanvasLayer) {
return '角色';
}
if (layer.assetKind === 'character-animation') {
return '动作';
return 'Spine序列帧动画';
}
if (layer.assetKind === 'icon') {
return '图标';
@@ -790,19 +903,78 @@ export function getLayerKindLabel(layer: CanvasLayer) {
export function buildCharacterAnimationGenerationInputs(
promptText: string,
sourceLayer: CanvasLayer,
sourceLayer: CanvasLayer | null,
reference: CharacterReferenceImage | null,
options: {
resolution?: string;
ratio?: string;
frameCount?: number;
durationSeconds?: number;
backgroundColor?: string;
referenceSrc?: string;
referenceMediaType?: 'image' | 'video';
} = {},
): CanvasGenerationInputs {
const inputMode =
reference?.mediaType === 'video'
? 'video'
: reference || sourceLayer
? 'image'
: 'text';
const referenceWidth =
typeof reference?.width === 'number' &&
Number.isFinite(reference.width) &&
reference.width > 0
? Math.round(reference.width)
: sourceLayer?.originalWidth;
const referenceHeight =
typeof reference?.height === 'number' &&
Number.isFinite(reference.height) &&
reference.height > 0
? Math.round(reference.height)
: sourceLayer?.originalHeight;
return {
version: 2,
action: 'character-animation.generate',
fields: [
...createGenerationInputField('输入模式', inputMode, 'inputMode'),
...(options.referenceSrc
? createGenerationInputField(
'参考素材',
options.referenceSrc,
'referenceSrc',
)
: []),
...(options.referenceSrc
? createGenerationInputField(
'参考类型',
options.referenceMediaType ??
(inputMode === 'video' ? 'video' : 'image'),
'referenceMediaType',
)
: []),
...(options.referenceSrc && referenceWidth
? createGenerationInputField(
'参考宽度',
referenceWidth,
'referenceWidth',
)
: []),
...(options.referenceSrc && referenceHeight
? createGenerationInputField(
'参考高度',
referenceHeight,
'referenceHeight',
)
: []),
...createGenerationInputField('动作描述', promptText, 'prompt'),
...(options.backgroundColor
? createGenerationInputField(
'背景颜色',
options.backgroundColor,
'backgroundColor',
)
: []),
...createGenerationInputField(
'清晰度',
options.resolution ?? '480p',
@@ -820,12 +992,43 @@ export function buildCharacterAnimationGenerationInputs(
'durationSeconds',
),
],
references: createLayerGenerationInputReference('角色图片', sourceLayer, {
id: 'source',
}),
references: sourceLayer
? createLayerGenerationInputReference(
reference?.mediaType === 'video' ? '参考视频' : '角色图片',
sourceLayer,
{ id: 'source' },
)
: reference
? createGenerationInputReference(
reference.mediaType === 'video' ? '参考视频' : '角色图片',
reference,
{ id: 'source' },
)
: [],
};
}
export function canSplitCharacterAnimationFrames(layer: CanvasLayer) {
const resourceId = resolveRegisteredProjectResourceId(layer.resourceId);
return (
layer.assetKind === 'character-animation' &&
resourceId !== undefined &&
(layer.resourcePersistenceState === undefined ||
layer.resourcePersistenceState === 'registered')
);
}
export function resolveRegisteredProjectResourceId(
value: string | null | undefined,
) {
const resourceId = value?.trim();
return resourceId &&
!resourceId.startsWith('local-') &&
!resourceId.startsWith('generation-dialog:')
? resourceId
: undefined;
}
export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'spec') {
return '规范图片';
@@ -834,7 +1037,7 @@ export function formatLayerImageType(layer: CanvasLayer) {
return '角色图片';
}
if (layer.assetKind === 'character-animation') {
return '角色动作序列帧';
return 'Spine序列帧动画';
}
if (layer.assetKind === 'icon') {
return '图标素材图片';
@@ -1075,6 +1278,8 @@ export function createCanvasLayerReference(
assetObjectId: layer.assetObjectId ?? undefined,
resourceId: layer.resourceId,
sourceAssetId: layer.sourceAssetId ?? undefined,
width: layer.originalWidth,
height: layer.originalHeight,
};
}
@@ -1088,12 +1293,8 @@ export function createGenerationInputReference(
id?: string;
} = {},
): CanvasGenerationInputReference[] {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
!resourceId.startsWith('local-') &&
!resourceId.startsWith('generation-dialog:')
) {
const resourceId = resolveRegisteredProjectResourceId(reference.resourceId);
if (resourceId) {
return [
{
...(options.id ? { id: options.id } : {}),
@@ -1533,7 +1734,30 @@ export function decodeCanvasGenerationInputs(
} else if (action === 'audio.background-music.generate') {
normalizeStringField('prompt', 'gpt_description_prompt');
} else if (action === 'character-animation.generate') {
const inputMode = normalizeStringOption(
'inputMode',
'输入模式',
['text', 'image', 'video'],
'image',
);
normalizeStringField('prompt', '动作描述');
const referenceSrc = normalizeStringField('referenceSrc', '参考素材');
if (referenceSrc || inputMode !== 'text') {
normalizeStringOption(
'referenceMediaType',
'参考类型',
['image', 'video'],
inputMode === 'video' ? 'video' : 'image',
);
}
if (findFieldIndex('backgroundColor') >= 0) {
normalizeStringOption(
'backgroundColor',
'背景颜色',
CHARACTER_ANIMATION_BACKGROUND_OPTIONS.map((option) => option.value),
'green',
);
}
normalizeStringOption('resolution', '清晰度', ['480p', '720p'], '480p');
normalizeStringOption(
'ratio',
@@ -1091,12 +1091,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
promptText: '循环奔跑动作',
resultTitle: '勇者奔跑',
input: {
inputMode: 'image',
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
sourceWidth: 960,
sourceHeight: 1280,
referenceMediaType: 'image',
referenceSrc: 'generated/character.png',
promptText: '循环奔跑动作',
screenColor: 'auto',
resolution: '720p',
ratio: 'same',
frameCount: 48,
@@ -1107,6 +1109,96 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('keeps uploaded reference dimensions for same-ratio animation requests', () => {
const plan = buildCharacterAnimationSubmissionPlan({
panel: {
promptText: '保持角色比例待机',
backgroundColor: 'blue',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
},
sourceLayer: null,
reference: {
id: 'uploaded-character',
label: '竖版角色.png',
src: '/uploads/character.png',
objectKey: 'users/user-1/character.png',
mediaType: 'image',
width: 768,
height: 1024,
},
});
expect(plan.input).toMatchObject({
inputMode: 'image',
sourceWidth: 768,
sourceHeight: 1024,
ratio: 'same',
backgroundColor: 'blue',
});
});
it('builds text and video character animation plans', () => {
expect(
buildCharacterAnimationSubmissionPlan({
panel: {
promptText: '原地挥手',
backgroundColor: 'purple',
resolution: '480p',
ratio: '1:1',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
},
sourceLayer: null,
}),
).toMatchObject({
input: {
inputMode: 'text',
promptText: '原地挥手',
backgroundColor: 'purple',
},
});
expect(
buildCharacterAnimationSubmissionPlan({
panel: {
references: [],
promptText: '参考视频中的奔跑节奏',
backgroundColor: 'green',
resolution: '720p',
ratio: '16:9',
frameCount: 40,
durationSeconds: 5,
status: 'idle',
},
sourceLayer: null,
reference: {
id: 'video-reference',
label: '动作参考.mp4',
src: '/uploads/action.mp4',
objectKey: 'users/user-1/action.mp4',
mediaType: 'video',
width: 1920,
height: 1080,
},
}),
).toMatchObject({
input: {
inputMode: 'video',
referenceMediaType: 'video',
referenceSrc: 'users/user-1/action.mp4',
sourceWidth: 1920,
sourceHeight: 1080,
promptText: '参考视频中的奔跑节奏',
backgroundColor: 'green',
},
});
});
it('builds seedance video plans with multimodal references', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -20,6 +20,7 @@ import type {
CanvasGenerationInputs,
CanvasLayer,
CharacterAnimationPanelState,
CharacterReferenceImage,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
@@ -1146,23 +1147,65 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
export function buildCharacterAnimationSubmissionPlan({
panel,
sourceLayer,
reference = null,
}: {
panel: CharacterAnimationPanelState;
sourceLayer: CanvasLayer;
sourceLayer: CanvasLayer | null;
reference?: CharacterReferenceImage | null;
}): CharacterAnimationSubmissionPlan {
const promptText = panel.promptText.trim();
const resultTitle = resolveGenerationAssetLabel(panel.assetLabel, '角色动作');
if (!promptText) {
throw new Error('动画描述不能为空。');
}
const inputMode =
reference?.mediaType === 'video'
? ('video' as const)
: reference || sourceLayer
? ('image' as const)
: ('text' as const);
const referenceSrc = reference
? reference.objectKey?.trim() || reference.src
: sourceLayer
? resolveCharacterAnimationSourceImageSrc(sourceLayer)
: undefined;
const referenceWidth =
typeof reference?.width === 'number' &&
Number.isFinite(reference.width) &&
reference.width > 0
? Math.round(reference.width)
: sourceLayer?.originalWidth;
const referenceHeight =
typeof reference?.height === 'number' &&
Number.isFinite(reference.height) &&
reference.height > 0
? Math.round(reference.height)
: sourceLayer?.originalHeight;
return {
promptText,
resultTitle,
input: {
sourceLayerId: sourceLayer.id,
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(sourceLayer),
sourceWidth: sourceLayer.originalWidth,
sourceHeight: sourceLayer.originalHeight,
inputMode,
...(sourceLayer ? { sourceLayerId: sourceLayer.id } : {}),
...(inputMode === 'image' && referenceSrc
? { sourceImageSrc: referenceSrc }
: {}),
...(referenceWidth && referenceHeight
? {
sourceWidth: referenceWidth,
sourceHeight: referenceHeight,
}
: {}),
...(referenceSrc
? {
referenceMediaType: inputMode === 'video' ? 'video' : 'image',
referenceSrc,
}
: {}),
promptText,
// 与生图入口一致:默认 auto,由后端做背景色自动决策。
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
...(panel.backgroundColor
? { backgroundColor: panel.backgroundColor }
: {}),
resolution: panel.resolution,
ratio: panel.ratio,
frameCount: panel.frameCount,
@@ -53,8 +53,10 @@ function renderSelectedToolbar(
isPerfectPixelProcessing: false,
isPerfectPixelPendingConfirmation: false,
isSplittingIconSpritesheet: false,
isSplittingCharacterAnimation: false,
isPersistingAssetKind: false,
onSplitIconSpritesheet: vi.fn(),
onSplitCharacterAnimation: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
@@ -344,7 +346,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
});
it('hides redraw for non-generative derived and public display-only layers', () => {
renderSelectedToolbar({
const props = renderSelectedToolbar({
selectedLayer: createLayer({
title: '手动裁扩结果',
generationInputs: null,
@@ -447,7 +449,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
).toEqual(['快速编辑', '改造', '下载按钮']);
cleanup();
renderSelectedToolbar({
const props = renderSelectedToolbar({
selectedLayer: createLayer({
title: '角色动作',
src: '/generated/action/frame-01.png',
@@ -476,7 +478,28 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
).toEqual(['拆帧', '改造', '下载按钮']);
fireEvent.click(within(actionToolbar).getByRole('button', { name: '拆帧' }));
expect(props.onSplitCharacterAnimation).toHaveBeenCalledWith(
expect.objectContaining({ assetKind: 'character-animation' }),
);
});
it('disables frame splitting while a character animation resource is pending registration', () => {
const props = renderSelectedToolbar({
selectedLayer: createLayer({
resourceId: 'resource-character-animation-pending',
resourcePersistenceState: 'pending',
mediaType: 'image-sequence',
assetKind: 'character-animation',
}),
});
const splitButton = screen.getByRole('button', { name: '资源保存中' });
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
expect(splitButton.getAttribute('aria-busy')).toBe('true');
fireEvent.click(splitButton);
expect(props.onSplitCharacterAnimation).not.toHaveBeenCalled();
});
it('keeps redraw visible for supported actions with a missing non-replaceable source', () => {
@@ -594,8 +617,10 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isSplittingCharacterAnimation={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
onSplitCharacterAnimation={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
@@ -616,8 +641,10 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isSplittingCharacterAnimation={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
onSplitCharacterAnimation={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
@@ -16,6 +16,7 @@ import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
canOpenRedrawPanel,
canSplitCharacterAnimationFrames,
isQuickEditUnsupportedAssetKind,
} from './ImageCanvasGenerationModel';
@@ -30,8 +31,10 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
isPerfectPixelProcessing: boolean;
isPerfectPixelPendingConfirmation: boolean;
isSplittingIconSpritesheet: boolean;
isSplittingCharacterAnimation: boolean;
isPersistingAssetKind: boolean;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
onSplitCharacterAnimation: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
onDownloadLayer: (layer: CanvasLayer) => void;
@@ -48,8 +51,10 @@ export function ImageCanvasSelectedLayerToolbarView({
isPerfectPixelProcessing,
isPerfectPixelPendingConfirmation,
isSplittingIconSpritesheet,
isSplittingCharacterAnimation,
isPersistingAssetKind,
onSplitIconSpritesheet,
onSplitCharacterAnimation,
onExtractUiDesignAssets,
onOpenCharacterAnimationPanel,
onDownloadLayer,
@@ -58,6 +63,14 @@ export function ImageCanvasSelectedLayerToolbarView({
return null;
}
const canRedraw = canOpenRedrawPanel(selectedLayer);
const isCharacterAnimationResourcePending =
selectedLayer.assetKind === 'character-animation' &&
!canSplitCharacterAnimationFrames(selectedLayer);
const characterAnimationSplitLabel = isCharacterAnimationResourcePending
? '资源保存中'
: isSplittingCharacterAnimation
? '拆帧中'
: '拆帧';
if (selectedLayer.mediaType === 'audio') {
return (
@@ -234,6 +247,30 @@ export function ImageCanvasSelectedLayerToolbarView({
<span>提取素材</span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'character-animation' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label={characterAnimationSplitLabel}
title={characterAnimationSplitLabel}
icon={
isCharacterAnimationResourcePending ||
isSplittingCharacterAnimation ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={
isCharacterAnimationResourcePending || isSplittingCharacterAnimation
}
aria-busy={
isCharacterAnimationResourcePending || isSplittingCharacterAnimation
}
onClick={() => onSplitCharacterAnimation(selectedLayer)}
>
<span>{characterAnimationSplitLabel}</span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'character' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -0,0 +1,34 @@
type ImageCanvasSequenceAnimationIconProps = {
className?: string;
};
export function ImageCanvasSequenceAnimationIcon({
className,
}: ImageCanvasSequenceAnimationIconProps) {
return (
<svg
aria-hidden="true"
className={[
className,
'image-canvas-editor__sequence-animation-icon',
]
.filter(Boolean)
.join(' ')}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.65"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="2.75" y="3.25" width="18.5" height="17.5" rx="2.5" />
<path d="M2.75 15.25H21.25" />
<path d="M7.25 15.25V20.75M12 15.25V20.75M16.75 15.25V20.75" />
<path
className="image-canvas-editor__sequence-animation-icon-play"
d="M9.25 7.15L15 10.15L9.25 13.15V7.15Z"
/>
</svg>
);
}
@@ -80,6 +80,7 @@ export type ImageCanvasStageViewProps = {
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
splittingCharacterAnimationLayerIds?: ReadonlySet<string>;
perfectPixelLayerIds?: ReadonlySet<string>;
pendingPerfectPixelLayerIds?: ReadonlySet<string>;
persistingAssetKindLayerIds?: ReadonlySet<string>;
@@ -155,6 +156,7 @@ export type ImageCanvasStageViewProps = {
onRemoveBackground: (layer: CanvasLayer) => void;
onPerfectPixel: (layer: CanvasLayer) => void;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
onSplitCharacterAnimation: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
onUiAssetExtractionModelChange: (model: string) => void;
@@ -242,6 +244,7 @@ export function ImageCanvasStageView({
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
splittingCharacterAnimationLayerIds = EMPTY_LAYER_ID_SET,
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET,
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
@@ -296,6 +299,7 @@ export function ImageCanvasStageView({
onRemoveBackground,
onPerfectPixel,
onSplitIconSpritesheet,
onSplitCharacterAnimation,
onExtractUiDesignAssets,
onUiAssetExtractionToolChange,
onUiAssetExtractionModelChange,
@@ -416,6 +420,10 @@ export function ImageCanvasStageView({
selectedLayer &&
splittingIconSpritesheetLayerIds.has(selectedLayer.id),
)}
isSplittingCharacterAnimation={Boolean(
selectedLayer &&
splittingCharacterAnimationLayerIds.has(selectedLayer.id),
)}
isPersistingAssetKind={Boolean(
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
)}
@@ -431,6 +439,7 @@ export function ImageCanvasStageView({
onRemoveBackground={onRemoveBackground}
onPerfectPixel={onPerfectPixel}
onSplitIconSpritesheet={onSplitIconSpritesheet}
onSplitCharacterAnimation={onSplitCharacterAnimation}
onExtractUiDesignAssets={onExtractUiDesignAssets}
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
onDownloadLayer={onDownloadLayer}
@@ -482,6 +482,48 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('resizes a same-ratio character animation placeholder for an uploaded reference', () => {
const dialog = createDialog({
mode: 'character-animation',
characterAnimationRatio: 'same',
characterAnimationResolution: '720p',
placeholder: {
x: 100,
y: 80,
width: 420,
height: 420,
originalWidth: 480,
originalHeight: 480,
},
});
expect(
applyGenerationReferenceUpload({
dialog,
target: 'video-reference-image',
references: [
{
id: 'uploaded-wide-character',
label: '横版角色',
src: 'users/user-1/wide-character.png',
width: 1600,
height: 900,
},
],
}),
).toMatchObject({
generationReferences: [{ id: 'uploaded-wide-character' }],
placeholder: {
x: -50,
y: 87.5,
width: 720,
height: 405,
originalWidth: 720,
originalHeight: 405,
},
});
});
it('appends all uploaded scene references', () => {
const firstReference = {
id: 'scene-ref-first',
@@ -14,6 +14,7 @@ import type {
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
resizeGenerationPlaceholderToCharacterAnimationSelection,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
} from './ImageCanvasGenerationModel';
@@ -109,6 +110,8 @@ export function createUploadedGenerationReference({
mimeType,
sizeBytes,
durationSeconds,
width,
height,
objectKey,
assetObjectId,
resourceId,
@@ -123,6 +126,8 @@ export function createUploadedGenerationReference({
mimeType?: string;
sizeBytes?: number;
durationSeconds?: number;
width?: number;
height?: number;
objectKey?: string | null;
assetObjectId?: string | null;
resourceId?: string | null;
@@ -139,6 +144,8 @@ export function createUploadedGenerationReference({
mimeType,
sizeBytes,
durationSeconds,
width,
height,
objectKey,
assetObjectId,
resourceId,
@@ -254,16 +261,25 @@ export function applyGenerationReferenceUpload({
: target === 'video-reference-audio'
? 'audio'
: 'image';
return dialog?.mode === 'video'
? {
...setFailedGenerationIdle(dialog),
generationReferences: appendLimitedVideoReferences(
dialog.generationReferences ?? [],
references,
mediaType,
),
composerOpen: true,
}
return dialog?.mode === 'video' ||
(dialog?.mode === 'character-animation' && mediaType !== 'audio')
? dialog.mode === 'character-animation'
? resizeGenerationPlaceholderToCharacterAnimationSelection({
...setFailedGenerationIdle(dialog),
sourceLayerId: undefined,
generationReferences: [firstReference],
composerOpen: true,
})
: {
...setFailedGenerationIdle(dialog),
sourceLayerId: dialog.sourceLayerId,
generationReferences: appendLimitedVideoReferences(
dialog.generationReferences ?? [],
references,
mediaType,
),
composerOpen: true,
}
: dialog;
}
return dialog?.mode === 'character'
@@ -503,7 +503,7 @@ describe('ImageCanvasWorldView', () => {
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
badgeLabel: 'Spine序列帧动画',
frameClassName:
'image-canvas-editor__generation-frame--character-animation',
},
@@ -588,6 +588,29 @@ describe('ImageCanvasWorldView', () => {
},
);
it('uses the bottom toolbar sequence icon for the character animation placeholder', () => {
const dialog = createGenerationDialog({
id: 'dialog-character-animation-icon',
mode: 'character-animation',
});
renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: dialog,
});
const frame = screen.getByRole('button', { name: '角色动作生成占位图' });
const sequenceIcons = frame.querySelectorAll(
'.image-canvas-editor__sequence-animation-icon',
);
expect(sequenceIcons).toHaveLength(2);
expect(
frame.querySelector('.image-canvas-editor__generation-frame-icon svg'),
).toBe(sequenceIcons[1]);
expect(frame.querySelector('.lucide-person-standing')).toBeNull();
});
it.each([
{
mode: 'character',
@@ -599,7 +622,7 @@ describe('ImageCanvasWorldView', () => {
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
badgeLabel: 'Spine序列帧动画',
},
{
mode: 'publication',
@@ -677,7 +700,7 @@ describe('ImageCanvasWorldView', () => {
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
badgeLabel: 'Spine序列帧动画',
},
{
mode: 'publication',
@@ -1101,7 +1124,7 @@ describe('ImageCanvasWorldView', () => {
.getByRole('menuitem', { name: '图标' })
.hasAttribute('disabled'),
).toBe(false);
for (const label of ['动作', '视频', '音效', '背景音乐']) {
for (const label of ['Spine序列帧动画', '视频', '音效', '背景音乐']) {
const item = within(menu).getByRole('menuitem', { name: label });
expect(item.hasAttribute('disabled')).toBe(true);
expect(item.title).toBe('该标签与素材的媒体类型不兼容');
@@ -1256,7 +1279,7 @@ describe('ImageCanvasWorldView', () => {
expect(frame.style.opacity).toBe('1');
expect(frame.style.transition).toBe('none');
expect(within(layerButton).getByText('1/2')).toBeTruthy();
expect(within(layerButton).getByText('动作')).toBeTruthy();
expect(within(layerButton).getByText('Spine序列帧动画')).toBeTruthy();
fireEvent.click(toggle);
expect(
@@ -9,7 +9,6 @@ import {
Mountain,
Music,
Pause,
PersonStanding,
Play,
Sparkles,
UserRound,
@@ -57,6 +56,7 @@ import {
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
import { ImageCanvasSequenceAnimationIcon } from './ImageCanvasSequenceAnimationIcon';
function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
switch (dialog.mode) {
@@ -73,9 +73,9 @@ function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
frameClassName:
'image-canvas-editor__generation-frame--character-animation',
badgeClassName: 'character-animation',
badgeLabel: '动作',
icon: <PersonStanding className="h-8 w-8" />,
labelIcon: <PersonStanding className="h-4 w-4" />,
badgeLabel: 'Spine序列帧动画',
icon: <ImageCanvasSequenceAnimationIcon className="h-8 w-8" />,
labelIcon: <ImageCanvasSequenceAnimationIcon className="h-4 w-4" />,
};
case 'spec':
return {
@@ -212,7 +212,7 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{
{ value: 'character', label: '角色', className: 'character' },
{
value: 'character-animation',
label: '动作',
label: 'Spine序列帧动画',
className: 'character-animation',
},
{ value: 'icon', label: '图标', className: 'icon' },
@@ -839,14 +839,14 @@ describe('useImageCanvasAssetExportWorkflow', () => {
expect(metadata.layers[5].file).toBe(
'sequences/005-角色动作/manifest.txt',
);
expect(metadata.layers[5].visible.type).toBe('角色动作序列帧');
expect(metadata.layers[5].visible.type).toBe('Spine序列帧动画');
const sequenceMetadata = JSON.parse(
await readZipText(
zip,
'导出项目-画布素材/sequences/005-角色动作/metadata.json',
),
);
expect(sequenceMetadata.visible.type).toBe('角色动作序列帧');
expect(sequenceMetadata.visible.type).toBe('Spine序列帧动画');
expect(sequenceMetadata.taskId).toBeUndefined();
expect(sequenceMetadata.previewVideoPath).toBeUndefined();
expect(sequenceMetadata.failedFrames).toEqual([]);
@@ -1069,7 +1069,7 @@ describe('useImageCanvasAssetExportWorkflow', () => {
});
const metadata = JSON.parse(await readZipText(zip, 'metadata.json'));
expect(metadata.title).toBe('角色动作/跑步');
expect(metadata.visible.type).toBe('角色动作序列帧');
expect(metadata.visible.type).toBe('Spine序列帧动画');
expect(metadata.visible.resolution).toBe('100 x 80 px');
expect(metadata.taskId).toBeUndefined();
expect(metadata.previewVideoPath).toBeUndefined();

Some files were not shown because too many files have changed in this diff Show More