Spine序列帧动画多模态生成
This commit is contained in:
@@ -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
Reference in New Issue
Block a user