Spine序列帧修复与功能收紧
This commit is contained in:
@@ -227,6 +227,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
|
||||
it('renders completed frame count and failed error state', () => {
|
||||
const result = {
|
||||
resultType: 'ai' as const,
|
||||
taskId: 'task-a',
|
||||
model: 'seedance2.0-fast' as const,
|
||||
prompt: '行走动作',
|
||||
|
||||
@@ -93,9 +93,6 @@ function CharacterGenerationHarness({
|
||||
<output aria-label="当前多方向角色图">
|
||||
{String(dialog.characterSheetEnabled === true)}
|
||||
</output>
|
||||
<output aria-label="当前多方向图视角数">
|
||||
{dialog.characterSheetViewSet ?? '4'}
|
||||
</output>
|
||||
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
|
||||
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
@@ -135,57 +132,21 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: '多方向角色图' });
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
const toggle = screen.getByRole('checkbox', { name: '多方向图' });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向角色图' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
(screen.getByRole('checkbox', { name: '多方向图' }) as HTMLInputElement)
|
||||
.checked,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '多方向图' }));
|
||||
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('false');
|
||||
});
|
||||
|
||||
it('shows the view-set dropdown only while the sheet is enabled', () => {
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({ characterSheetEnabled: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /多方向图视角/ })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向图视角 四方向' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches the character sheet view set between four and eight', () => {
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
characterSheetEnabled: true,
|
||||
characterSheetViewSet: '4',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '多方向图视角 四方向' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '八方向' }));
|
||||
|
||||
expect(screen.getByLabelText('当前多方向图视角数').textContent).toBe('8');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '多方向图视角 八方向' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ChevronDown, ClipboardList, ImagePlus, LayoutGrid } from 'lucide-react';
|
||||
import { ClipboardList, ImagePlus } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
@@ -104,8 +101,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
const [isViewSetMenuOpen, setIsViewSetMenuOpen] = useState(false);
|
||||
const viewSetButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
|
||||
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
|
||||
@@ -114,25 +109,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
},
|
||||
});
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isViewSetMenuOpen,
|
||||
boundaryRefs: [viewSetButtonRef],
|
||||
onDismiss: () => setIsViewSetMenuOpen(false),
|
||||
restoreFocusRef: viewSetButtonRef,
|
||||
});
|
||||
const setViewSet = (value: '4' | '8') => {
|
||||
setIsViewSetMenuOpen(false);
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetViewSet: value,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
};
|
||||
const selectedViewSetLabel =
|
||||
(dialog.characterSheetViewSet ?? '4') === '8' ? '八方向' : '四方向';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -240,30 +216,6 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
isAdd
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
/>
|
||||
<ImageCanvasReferenceSlot
|
||||
tone="character-sheet"
|
||||
icon={<LayoutGrid className="h-4 w-4" aria-hidden="true" />}
|
||||
label="多方向图"
|
||||
ariaLabel="多方向角色图"
|
||||
title={
|
||||
dialog.characterSheetEnabled === true
|
||||
? '多方向角色图(开启)'
|
||||
: '多方向角色图'
|
||||
}
|
||||
disabled={dialog.status === 'generating'}
|
||||
pressed={dialog.characterSheetEnabled === true}
|
||||
onClick={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetEnabled:
|
||||
currentDialog.characterSheetEnabled !== true,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -351,64 +303,28 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
extraToggles={
|
||||
<label className="image-canvas-editor__image-style-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dialog.characterSheetEnabled === true}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSheetEnabled: event.target.checked,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>多方向图</span>
|
||||
</label>
|
||||
}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
styleControl={
|
||||
dialog.characterSheetEnabled === true ? (
|
||||
<div className="image-canvas-editor__option-popover-anchor">
|
||||
<PlatformInlineOptionButton
|
||||
ref={viewSetButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--viewset"
|
||||
aria-label={`多方向图视角 ${selectedViewSetLabel}`}
|
||||
aria-expanded={isViewSetMenuOpen}
|
||||
disabled={dialog.status === 'generating'}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsViewSetMenuOpen((open) => !open)}
|
||||
>
|
||||
{selectedViewSetLabel}
|
||||
</PlatformInlineOptionButton>
|
||||
{isViewSetMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label="多方向图视角数"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
viewSetButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
视角数
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{(['4', '8'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="image-canvas-editor__option-popover-choice"
|
||||
aria-pressed={
|
||||
(dialog.characterSheetViewSet ?? '4') ===
|
||||
value
|
||||
}
|
||||
onClick={() => setViewSet(value)}
|
||||
>
|
||||
{value === '4' ? '四方向' : '八方向'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,6 @@ function renderContextMenus(
|
||||
onFitLayers: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -160,7 +159,6 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1);
|
||||
@@ -179,9 +177,8 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect.objectContaining({ mode: 'spine-json' }),
|
||||
);
|
||||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -319,19 +316,6 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the standalone image menu after opening character animation', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the image context menu when no canvas context menu is open', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
|
||||
@@ -53,7 +53,6 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onFitLayers: () => void;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
onOpenLayerMetadata: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
type MeasuredMenuLayout = {
|
||||
@@ -94,7 +93,6 @@ export function ImageCanvasContextMenusView({
|
||||
onFitLayers,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenLayerMetadata,
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const layerCopyBlockedTitle =
|
||||
@@ -525,19 +523,6 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</button>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</button>
|
||||
) : null}
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
@@ -585,17 +570,6 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</PlatformFloatingMenuItem>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onDeleteLayerById(imageContextMenuLayer.id)}
|
||||
|
||||
@@ -3452,20 +3452,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
|
||||
fireEvent.click(characterLayer.closest('button')!);
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
|
||||
).toBeNull();
|
||||
fireEvent.contextMenu(characterLayer.closest('button')!, {
|
||||
clientX: 260,
|
||||
clientY: 220,
|
||||
});
|
||||
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spine序列帧动画' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: '角色动作生成占位图' }),
|
||||
).toBeTruthy();
|
||||
@@ -3540,21 +3534,24 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', { name: '720p' }),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{ name: '720p' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', { name: '16:9' }),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{ name: '16:9' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '序列帧参数选项' }),
|
||||
).getByRole('button', {
|
||||
name: '48帧·6秒',
|
||||
}),
|
||||
within(screen.getByRole('group', { name: '序列帧参数选项' })).getByRole(
|
||||
'button',
|
||||
{
|
||||
name: '48帧·6秒',
|
||||
},
|
||||
),
|
||||
);
|
||||
const resizedPlaceholder = screen.getByRole('button', {
|
||||
name: '角色动作生成占位图',
|
||||
|
||||
@@ -1339,6 +1339,103 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates a character-animation preview video through its source resource', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated/sequence/frame-1.png',
|
||||
objectKey: 'generated/sequence/frame-1.png',
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated/sequence/frame-2.png',
|
||||
objectKey: 'generated/sequence/frame-2.png',
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-sequence',
|
||||
resourceId: 'resource-sequence',
|
||||
title: '角色动作',
|
||||
sourceType: 'generated',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-sequence',
|
||||
{
|
||||
resourceId: 'resource-sequence',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
objectKey: frames[0]!.objectKey,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
taskId: 'animation-task-1',
|
||||
sourceResourceId: 'resource-preview-video',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-preview-video',
|
||||
{
|
||||
resourceId: 'resource-preview-video',
|
||||
imageSrc: '/generated/video/preview.mp4',
|
||||
objectKey: 'generated/video/preview.mp4',
|
||||
assetKind: 'video',
|
||||
taskId: 'animation-task-1',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'image-sequence',
|
||||
previewVideoPath: 'generated/video/preview.mp4',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hydrate an older action reference as a generated preview video', () => {
|
||||
const frames = [
|
||||
{ imageSrc: '/generated/sequence/frame-1.png', width: 480, height: 480 },
|
||||
{ imageSrc: '/generated/sequence/frame-2.png', width: 480, height: 480 },
|
||||
];
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-sequence',
|
||||
resourceId: 'resource-sequence',
|
||||
title: '角色动作',
|
||||
sourceType: 'generated',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-sequence',
|
||||
{
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
taskId: 'animation-task-2',
|
||||
sourceResourceId: 'resource-action-video',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-action-video',
|
||||
{
|
||||
imageSrc: '/generated/source/action.mp4',
|
||||
objectKey: 'generated/source/action.mp4',
|
||||
assetKind: 'video',
|
||||
taskId: 'source-task-1',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated?.previewVideoPath).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an action layer when its project resource lacks formal sequence fields', () => {
|
||||
const frames = [
|
||||
{
|
||||
@@ -1637,6 +1734,100 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips independent character animation AI and conversion drafts', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'dialog-character-animation-drafts',
|
||||
mode: 'character-animation',
|
||||
prompt: '保留 AI 提示词',
|
||||
status: 'idle',
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationBackgroundColor: 'green',
|
||||
characterAnimationResolution: '480p',
|
||||
characterAnimationConversionResolution: '720p',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion',
|
||||
label: '动作视频',
|
||||
src: 'users/user-1/motion.mp4',
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
mediaType: 'video',
|
||||
resourceId: 'resource-motion',
|
||||
},
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
id: 'appearance',
|
||||
label: '角色外观',
|
||||
src: 'users/user-1/appearance.png',
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
resourceId: 'resource-appearance',
|
||||
},
|
||||
],
|
||||
characterAnimationConversionVideoReference: {
|
||||
id: 'conversion-source',
|
||||
label: '直接转换源视频',
|
||||
src: 'users/user-1/source.mov',
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
mediaType: 'video',
|
||||
resourceId: 'resource-conversion-source',
|
||||
},
|
||||
};
|
||||
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
});
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
layout,
|
||||
new Map([
|
||||
[
|
||||
'resource-motion',
|
||||
{
|
||||
imageSrc: 'users/user-1/motion.mp4',
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-appearance',
|
||||
{
|
||||
imageSrc: 'users/user-1/appearance.png',
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'resource-conversion-source',
|
||||
{
|
||||
imageSrc: 'users/user-1/source.mov',
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
prompt: '保留 AI 提示词',
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationBackgroundColor: 'green',
|
||||
characterAnimationResolution: '480p',
|
||||
characterAnimationConversionResolution: '720p',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
objectKey: 'users/user-1/motion.mp4',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
expect.objectContaining({
|
||||
objectKey: 'users/user-1/appearance.png',
|
||||
}),
|
||||
],
|
||||
characterAnimationConversionVideoReference: expect.objectContaining({
|
||||
objectKey: 'users/user-1/source.mov',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
|
||||
const dialogId = 'dialog-perfect-pixel-round-trip';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts
|
||||
import type {
|
||||
EditorAssetGenerationInputs,
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
@@ -722,6 +722,12 @@ function serializeDialogReferences(
|
||||
generationReferences: serializeGenerationReferences(
|
||||
dialog.generationReferences,
|
||||
),
|
||||
characterAnimationAppearanceReferences: serializeGenerationReferences(
|
||||
dialog.characterAnimationAppearanceReferences,
|
||||
),
|
||||
characterAnimationConversionVideoReference: serializeGenerationReference(
|
||||
dialog.characterAnimationConversionVideoReference,
|
||||
),
|
||||
characterSpecReference: serializeGenerationReference(
|
||||
dialog.characterSpecReference,
|
||||
),
|
||||
@@ -1274,6 +1280,34 @@ export function hydrateCanvasGenerationDialog(
|
||||
typeof snapshot.videoWebSearchEnabled === 'boolean'
|
||||
? snapshot.videoWebSearchEnabled
|
||||
: undefined,
|
||||
characterAnimationWorkflow:
|
||||
snapshot.characterAnimationWorkflow === 'video-conversion'
|
||||
? 'video-conversion'
|
||||
: snapshot.characterAnimationWorkflow === 'ai'
|
||||
? 'ai'
|
||||
: undefined,
|
||||
characterAnimationAppearanceReferences: hydrateCharacterReferences(
|
||||
snapshot.characterAnimationAppearanceReferences,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterAnimationConversionVideoReference: hydrateCharacterReference(
|
||||
snapshot.characterAnimationConversionVideoReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterAnimationConversionResolution:
|
||||
snapshot.characterAnimationConversionResolution === '480p' ||
|
||||
snapshot.characterAnimationConversionResolution === '720p'
|
||||
? snapshot.characterAnimationConversionResolution
|
||||
: undefined,
|
||||
characterAnimationBackgroundColor:
|
||||
snapshot.characterAnimationBackgroundColor === 'white' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'green' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'blue' ||
|
||||
snapshot.characterAnimationBackgroundColor === 'purple'
|
||||
? snapshot.characterAnimationBackgroundColor
|
||||
: undefined,
|
||||
characterAnimationResolution:
|
||||
snapshot.characterAnimationResolution === '480p' ||
|
||||
snapshot.characterAnimationResolution === '720p'
|
||||
@@ -1409,6 +1443,33 @@ export function hydrateLayer(
|
||||
const src = isFormalCharacterAnimation
|
||||
? firstSequenceFrame || resource?.imageSrc || ''
|
||||
: snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
|
||||
const sequencePreviewVideoPath = isFormalCharacterAnimation
|
||||
? (() => {
|
||||
const previewResourceId = stringOrNull(resource?.sourceResourceId);
|
||||
const previewResource = previewResourceId
|
||||
? resourcesById.get(previewResourceId)
|
||||
: undefined;
|
||||
if (previewResource?.assetKind !== 'video') {
|
||||
return undefined;
|
||||
}
|
||||
// 正式序列帧的 sourceResourceId 应指向同一次操作登记的预览视频。
|
||||
// 如果它实际指向更早的动作参考视频,不能把参考视频当成生成结果播放;
|
||||
// 这也避免历史错误 lineage 在刷新后悄悄改变“底层视频”。
|
||||
const animationTaskId = stringOrNull(resource?.taskId);
|
||||
const previewTaskId = stringOrNull(previewResource.taskId);
|
||||
if (
|
||||
animationTaskId &&
|
||||
previewTaskId &&
|
||||
animationTaskId !== previewTaskId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
stringOrNull(previewResource.objectKey) ??
|
||||
stringOrNull(previewResource.imageSrc)
|
||||
);
|
||||
})()
|
||||
: undefined;
|
||||
const title =
|
||||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||||
if (!resourceId || !layerId || !src) {
|
||||
@@ -1503,7 +1564,7 @@ export function hydrateLayer(
|
||||
? imageSequenceFrames
|
||||
: undefined,
|
||||
previewVideoPath: isFormalCharacterAnimation
|
||||
? null
|
||||
? (sequencePreviewVideoPath ?? null)
|
||||
: stringOrNull(snapshot.previewVideoPath),
|
||||
prompt: isFormalCharacterAnimation
|
||||
? stringOrNull(resource?.prompt)
|
||||
@@ -2023,25 +2084,43 @@ function videoAspectRatioOrUndefined(value: unknown) {
|
||||
|
||||
function hydrateCharacterAnimationResult(
|
||||
value: unknown,
|
||||
): EditorCharacterAnimationGenerationResult | undefined {
|
||||
): EditorCharacterAnimationResult | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const result = value as Partial<EditorCharacterAnimationGenerationResult>;
|
||||
const result = value as Record<string, unknown> &
|
||||
Partial<EditorCharacterAnimationResult>;
|
||||
if (
|
||||
typeof result.taskId !== 'string' ||
|
||||
result.model !== 'seedance2.0-fast' ||
|
||||
typeof result.prompt !== 'string' ||
|
||||
typeof result.previewVideoPath !== 'string' ||
|
||||
!Array.isArray(result.frames) ||
|
||||
typeof result.frameCount !== 'number' ||
|
||||
typeof result.durationSeconds !== 'number' ||
|
||||
typeof result.fps !== 'number' ||
|
||||
typeof result.priceMudPoints !== 'number'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return result as EditorCharacterAnimationGenerationResult;
|
||||
if (result.resultType === 'video-conversion') {
|
||||
return result.processingMethod === '视频直接转换' &&
|
||||
typeof result.durationMs === 'number' &&
|
||||
typeof result.frameWidth === 'number' &&
|
||||
typeof result.frameHeight === 'number' &&
|
||||
typeof result.sourceFps === 'number' &&
|
||||
typeof result.samplingFps === 'number' &&
|
||||
result.priceMudPoints === 0
|
||||
? (result as EditorCharacterAnimationResult)
|
||||
: undefined;
|
||||
}
|
||||
// 旧项目在判别字段引入前只持久化 AI 结果;按原契约恢复并补上判别字段。
|
||||
if (
|
||||
(result.resultType === undefined || result.resultType === 'ai') &&
|
||||
result.model === 'seedance2.0-fast' &&
|
||||
typeof result.prompt === 'string' &&
|
||||
typeof result.previewVideoPath === 'string' &&
|
||||
typeof result.durationSeconds === 'number' &&
|
||||
typeof result.fps === 'number'
|
||||
) {
|
||||
return { ...result, resultType: 'ai' } as EditorCharacterAnimationResult;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hydrateImageSequenceFrames(
|
||||
|
||||
@@ -221,6 +221,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onPerfectPixel: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onSplitCharacterAnimation: vi.fn(),
|
||||
onRemoveCharacterAnimationBackground: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
onUiAssetExtractionModelChange: vi.fn(),
|
||||
@@ -234,7 +235,6 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onQuickEditSelectionPointerStart: vi.fn(),
|
||||
onQuickEditSelectionPointerMove: vi.fn(),
|
||||
onQuickEditSelectionPointerEnd: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
onDownloadLayer: vi.fn(),
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
onCopyContextLayers: vi.fn(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationBackgroundColor,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
@@ -90,6 +90,8 @@ export type CanvasGenerationAction =
|
||||
| 'audio.sound-effect.generate'
|
||||
| 'audio.background-music.generate'
|
||||
| 'character-animation.generate'
|
||||
| 'character-animation.convert'
|
||||
| 'character-animation.remove-background'
|
||||
| 'image.edit'
|
||||
| 'ui-design.extract-assets'
|
||||
| 'image.perfect-pixel'
|
||||
@@ -219,6 +221,8 @@ export type CharacterReferenceImage = {
|
||||
label: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
/** 参考来源的画布资产类型;用于区分序列帧底层视频与独立源视频。 */
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
@@ -279,11 +283,9 @@ export type GenerateDialogState = {
|
||||
generationReferences?: CharacterReferenceImage[];
|
||||
characterSpecReference?: CharacterReferenceImage | null;
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
// 中文注释:角色面板的"多方向角色图"开关——开启时生成一张四方向角色图集
|
||||
// 中文注释:角色面板的"多方向角色图"开关——开启时生成一张八方向角色图集
|
||||
// (后端 kind=character-sheet,产物按 icon-spritesheet 落库,可走既有拆分图集)。
|
||||
characterSheetEnabled?: boolean;
|
||||
// 中文注释:多方向角色图的视角数(4 或 8),仅 characterSheetEnabled 时生效。
|
||||
characterSheetViewSet?: '4' | '8';
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
iconDescriptions?: string[];
|
||||
publicationWorkflowId?: PublicationMaterialsWorkflowId;
|
||||
@@ -306,7 +308,11 @@ export type GenerateDialogState = {
|
||||
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
|
||||
characterAnimationDurationSeconds?: 4 | 5 | 6;
|
||||
characterAnimationBackgroundColor?: EditorCharacterAnimationBackgroundColor;
|
||||
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
|
||||
characterAnimationWorkflow?: 'ai' | 'video-conversion';
|
||||
characterAnimationAppearanceReferences?: CharacterReferenceImage[];
|
||||
characterAnimationConversionVideoReference?: CharacterReferenceImage | null;
|
||||
characterAnimationConversionResolution?: EditorCharacterAnimationResolution;
|
||||
characterAnimationResult?: EditorCharacterAnimationResult;
|
||||
soundModel?: EditorSoundEffectModel | 'audio1.0';
|
||||
soundDurationMode?: EditorSoundEffectDurationMode;
|
||||
soundDurationSeconds?: number;
|
||||
@@ -501,6 +507,10 @@ export type CropExpandResizeHandle =
|
||||
export type CharacterAnimationPanelState = {
|
||||
sourceLayerId?: string;
|
||||
references?: CharacterReferenceImage[];
|
||||
workflow?: 'ai' | 'video-conversion';
|
||||
appearanceReferences?: CharacterReferenceImage[];
|
||||
conversionVideoReference?: CharacterReferenceImage | null;
|
||||
conversionResolution?: EditorCharacterAnimationResolution;
|
||||
promptText: string;
|
||||
assetLabel?: string;
|
||||
backgroundColor?: EditorCharacterAnimationBackgroundColor;
|
||||
@@ -510,7 +520,7 @@ export type CharacterAnimationPanelState = {
|
||||
durationSeconds: 4 | 5 | 6;
|
||||
status: 'idle' | 'generating' | 'completed' | 'failed';
|
||||
errorMessage?: string;
|
||||
result?: EditorCharacterAnimationGenerationResult;
|
||||
result?: EditorCharacterAnimationResult;
|
||||
};
|
||||
|
||||
export type UploadTarget =
|
||||
|
||||
@@ -236,7 +236,8 @@ function findSourceAnimationLayer(
|
||||
return (
|
||||
layers.find(
|
||||
(layer) =>
|
||||
(layer.resourceId === sourceResourceId || layer.id === sourceResourceId) &&
|
||||
(layer.resourceId === sourceResourceId ||
|
||||
layer.id === sourceResourceId) &&
|
||||
layer.assetKind === 'character',
|
||||
) ?? null
|
||||
);
|
||||
@@ -1431,6 +1432,9 @@ export function ImageCanvasEditorView({
|
||||
pendingImageReferenceUploadCount;
|
||||
const generationSurface = useImageCanvasGenerationSurface({
|
||||
layers,
|
||||
selectedLayer: selectedLayerId
|
||||
? (layers.find((layer) => layer.id === selectedLayerId) ?? null)
|
||||
: null,
|
||||
canvasSize,
|
||||
viewport,
|
||||
setViewport,
|
||||
@@ -1603,6 +1607,8 @@ export function ImageCanvasEditorView({
|
||||
splittingIconSpritesheetLayerIds,
|
||||
splitSelectedCharacterAnimationFrames,
|
||||
splittingCharacterAnimationLayerIds,
|
||||
removeSelectedCharacterAnimationBackground,
|
||||
removingBackgroundCharacterAnimationLayerIds,
|
||||
extractUiDesignAssets,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
@@ -2598,6 +2604,7 @@ export function ImageCanvasEditorView({
|
||||
pendingPerfectPixelLayerIds,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
splittingCharacterAnimationLayerIds,
|
||||
removingBackgroundCharacterAnimationLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -2664,6 +2671,11 @@ export function ImageCanvasEditorView({
|
||||
splitSelectedCharacterAnimationFrames(layer),
|
||||
);
|
||||
},
|
||||
onRemoveCharacterAnimationBackground: (layer: CanvasLayer) => {
|
||||
void flushProjectPersistence().then(() =>
|
||||
removeSelectedCharacterAnimationBackground(layer),
|
||||
);
|
||||
},
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
|
||||
@@ -2679,7 +2691,6 @@ export function ImageCanvasEditorView({
|
||||
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
|
||||
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
|
||||
onDownloadLayer: exportLayerImage,
|
||||
onPasteCanvasClipboard: pasteCanvasOrSystemClipboard,
|
||||
onCopyContextLayers: copyContextLayers,
|
||||
|
||||
@@ -456,7 +456,7 @@ export function buildSpineImageSequenceJson({
|
||||
skeleton: {
|
||||
spine: DEFAULT_SPINE_VERSION,
|
||||
images: './frames/',
|
||||
fps: Math.max(1, Math.round(frames.length / durationSeconds)),
|
||||
fps: Number((frames.length / durationSeconds).toFixed(6)),
|
||||
width: skeletonWidth,
|
||||
height: skeletonHeight,
|
||||
},
|
||||
|
||||
@@ -2119,6 +2119,190 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('AI 视频从画布选中外观图后展示已选参考并支持移除', () => {
|
||||
renderComposer({
|
||||
id: 'dialog-character-animation-video-appearance',
|
||||
mode: 'character-animation',
|
||||
prompt: '原地奔跑',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion-video',
|
||||
label: '动作视频',
|
||||
src: 'generated/motion.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
id: 'appearance-image',
|
||||
label: '骑士角色',
|
||||
src: 'generated/knight.png',
|
||||
mediaType: 'image',
|
||||
},
|
||||
],
|
||||
characterAnimationWorkflow: 'ai',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', {
|
||||
name: 'Spine序列帧动画生成',
|
||||
});
|
||||
expect(within(panel).getByText('骑士角色')).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '删除骑士角色' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('删除动作视频后清空动作并保留外观图片参考', () => {
|
||||
const dialog: GenerateDialogState = {
|
||||
id: 'dialog-character-animation-video-remove-motion',
|
||||
mode: 'character-animation',
|
||||
prompt: '原地奔跑',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion-video',
|
||||
label: '动作视频',
|
||||
src: 'generated/motion.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
id: 'appearance-image',
|
||||
label: '骑士角色',
|
||||
src: 'generated/knight.png',
|
||||
mediaType: 'image',
|
||||
},
|
||||
],
|
||||
characterAnimationWorkflow: 'ai',
|
||||
};
|
||||
const setGenerateDialog = vi.fn();
|
||||
renderComposer(dialog, { setGenerateDialog });
|
||||
|
||||
const panel = screen.getByRole('dialog', {
|
||||
name: 'Spine序列帧动画生成',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '删除动作视频' }),
|
||||
);
|
||||
|
||||
const updater = setGenerateDialog.mock.calls[0]?.[0] as (
|
||||
currentDialog: GenerateDialogState | null,
|
||||
) => GenerateDialogState | null;
|
||||
const nextDialog = updater(dialog);
|
||||
expect(nextDialog?.generationReferences).toEqual([]);
|
||||
expect(nextDialog?.characterAnimationAppearanceReferences).toEqual([
|
||||
expect.objectContaining({ id: 'appearance-image', mediaType: 'image' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('视频直接转换标签不展示 AI 外观图草稿', () => {
|
||||
renderComposer({
|
||||
id: 'dialog-character-animation-conversion-hidden-appearance',
|
||||
mode: 'character-animation',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationConversionVideoReference: {
|
||||
id: 'conversion-video',
|
||||
label: '源视频',
|
||||
src: 'generated/source.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion-video',
|
||||
label: '动作视频',
|
||||
src: 'generated/motion.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
id: 'appearance-image',
|
||||
label: '骑士角色',
|
||||
src: 'generated/knight.png',
|
||||
mediaType: 'image',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', {
|
||||
name: 'Spine序列帧动画生成',
|
||||
});
|
||||
expect(within(panel).queryByText('骑士角色')).toBeNull();
|
||||
expect(within(panel).getAllByText('源视频')).toHaveLength(2);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '删除源视频' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('视频作为 AI 参考时不展示角色外观占位符,但仍保留统一参考入口', () => {
|
||||
renderComposer({
|
||||
id: 'dialog-character-animation-video-reference',
|
||||
mode: 'character-animation',
|
||||
prompt: '原地奔跑',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion-video',
|
||||
label: '动作视频',
|
||||
src: 'generated/motion.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
],
|
||||
characterAnimationWorkflow: 'ai',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', {
|
||||
name: 'Spine序列帧动画生成',
|
||||
});
|
||||
expect(within(panel).queryByText('角色外观')).toBeNull();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '添加序列帧参考素材' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to AI workflow and keeps independent drafts while switching tabs', () => {
|
||||
render(<CharacterAnimationParametersHarness backgroundColor="green" />);
|
||||
|
||||
const panel = screen.getByRole('dialog', {
|
||||
name: 'Spine序列帧动画生成',
|
||||
});
|
||||
const aiTab = within(panel).getByRole('tab', { name: 'AI生成/改造' });
|
||||
const conversionTab = within(panel).getByRole('tab', {
|
||||
name: '视频直接转换',
|
||||
});
|
||||
expect(aiTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '动画描述' }).textContent,
|
||||
).toBe('原地奔跑');
|
||||
|
||||
fireEvent.click(conversionTab);
|
||||
expect(conversionTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(
|
||||
within(panel).queryByRole('textbox', { name: '动画描述' }),
|
||||
).toBeNull();
|
||||
expect(within(panel).getByText('免费转换')).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(panel).getByRole('button', {
|
||||
name: '免费转换为Spine序列帧动画',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(aiTab);
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '动画描述' }).textContent,
|
||||
).toBe('原地奔跑');
|
||||
});
|
||||
|
||||
it('序列帧背景、比例、帧数时长和清晰度可独立选中并保留其他参数', () => {
|
||||
render(<CharacterAnimationParametersHarness backgroundColor="green" />);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1242,7 +1242,6 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
fields: [
|
||||
{ id: 'prompt', title: '角色设定', value: '日系魔法少女' },
|
||||
{ id: 'model', title: '模型', value: 'nanobanana2' },
|
||||
{ id: 'viewSet', title: '视角集合', value: '八视图' },
|
||||
{ id: 'aspectRatio', title: '比例', value: '1:1' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '1K' },
|
||||
],
|
||||
@@ -1259,7 +1258,6 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
sourceLayerId: 'layer-sheet-result',
|
||||
style: 'none',
|
||||
characterSheetEnabled: true,
|
||||
characterSheetViewSet: '8',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1484,6 +1482,136 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores video AI motion and appearance from named reference slots', () => {
|
||||
const motionLayer = createLayer({
|
||||
id: 'layer-motion',
|
||||
resourceId: 'resource-motion',
|
||||
title: '奔跑动作',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
src: '/media/motion.mp4',
|
||||
});
|
||||
const appearanceLayer = createLayer({
|
||||
id: 'layer-appearance',
|
||||
resourceId: 'resource-appearance',
|
||||
title: '骑士外观',
|
||||
assetKind: 'character',
|
||||
src: '/images/appearance.png',
|
||||
});
|
||||
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: 'video' },
|
||||
{ id: 'prompt', title: '动作描述', value: '复刻奔跑节奏' },
|
||||
{
|
||||
id: 'referenceSrc',
|
||||
title: '参考素材',
|
||||
value: 'users/user-1/motion.mp4',
|
||||
},
|
||||
{
|
||||
id: 'referenceMediaType',
|
||||
title: '参考类型',
|
||||
value: 'video',
|
||||
},
|
||||
{
|
||||
id: 'appearanceReferenceSrc',
|
||||
title: '角色外观',
|
||||
value: 'users/user-1/appearance.png',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'motion',
|
||||
title: '动作视频',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-motion',
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
title: '角色外观',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-appearance',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
availableLayers: [motionLayer, appearanceLayer],
|
||||
});
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
mode: 'character-animation',
|
||||
characterAnimationWorkflow: 'ai',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-motion',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-appearance',
|
||||
label: '骑士外观',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('restores video conversion results to the conversion workflow', () => {
|
||||
const draft = createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
id: 'layer-converted-animation',
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.convert',
|
||||
fields: [
|
||||
{
|
||||
id: 'sourceVideoSrc',
|
||||
title: '源视频',
|
||||
value: 'users/user-1/source.mov',
|
||||
},
|
||||
{ id: 'resolution', title: '最大长边', value: '720p' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '源视频',
|
||||
label: '源动作.mov',
|
||||
refType: 'asset',
|
||||
refId: 'asset-source-video',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
availableLayers: [],
|
||||
});
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
mode: 'character-animation',
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationConversionResolution: '720p',
|
||||
characterAnimationConversionVideoReference: {
|
||||
label: '源动作.mov',
|
||||
src: 'users/user-1/source.mov',
|
||||
sourceAssetId: 'asset-source-video',
|
||||
mediaType: 'video',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not restore a project resource id that is unavailable in the current canvas', () => {
|
||||
const draft = createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
@@ -1875,6 +2003,40 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an ordinary canvas image as appearance after a video motion reference', () => {
|
||||
const dialog: GenerateDialogState = {
|
||||
mode: 'character-animation',
|
||||
prompt: '原地奔跑',
|
||||
status: 'idle',
|
||||
characterAnimationWorkflow: 'ai',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'motion',
|
||||
label: '动作视频',
|
||||
src: 'generated/motion.mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ordinaryImage = createLayer({
|
||||
id: 'asset-image',
|
||||
title: '素材1',
|
||||
src: 'generated/asset-1.png',
|
||||
});
|
||||
|
||||
expect(appendGenerationReference(dialog, ordinaryImage)).toMatchObject({
|
||||
generationReferences: [
|
||||
expect.objectContaining({ label: '动作视频', mediaType: 'video' }),
|
||||
],
|
||||
characterAnimationAppearanceReferences: [
|
||||
{
|
||||
label: '素材1',
|
||||
src: 'generated/asset-1.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('caps image references before a sixth reference can enter submission state', () => {
|
||||
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index}`,
|
||||
|
||||
@@ -116,6 +116,12 @@ function getReferenceMediaType(layer: CanvasLayer) {
|
||||
: 'image';
|
||||
}
|
||||
|
||||
function hasCharacterAnimationVideoReference(layer: CanvasLayer) {
|
||||
return Boolean(
|
||||
layer.assetKind === 'character-animation' && layer.previewVideoPath?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function isIconSpecReferenceLayer(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return true;
|
||||
@@ -317,7 +323,6 @@ export function createCharacterGenerationDialogDraft({
|
||||
characterSpecReference: null,
|
||||
characterReferences: [],
|
||||
characterSheetEnabled: false,
|
||||
characterSheetViewSet: '4',
|
||||
imageModel: normalizedImageModel,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
@@ -861,6 +866,14 @@ function getNormalizedString(
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function getNormalizedStringArray(sourceLayer: CanvasLayer, id: string) {
|
||||
return (sourceLayer.generationInputs?.fields ?? [])
|
||||
.filter((field) => field.id === id)
|
||||
.flatMap((field) =>
|
||||
typeof field.value === 'string' ? [field.value] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function getNormalizedNumber(
|
||||
sourceLayer: CanvasLayer,
|
||||
id: string,
|
||||
@@ -1064,7 +1077,7 @@ function createNormalizedGenerationDialogDraft({
|
||||
}
|
||||
|
||||
if (inputs.action === 'character-sheet.generate') {
|
||||
// 多方向角色图回弹到角色面板并恢复图集模式与视角数。
|
||||
// 多方向角色图回弹到角色面板并恢复图集模式(固定八方向)。
|
||||
return placeDraftBesideSourceLayer(
|
||||
resizeGenerationPlaceholderToImageSelection({
|
||||
...createCharacterGenerationDialogDraft({
|
||||
@@ -1082,10 +1095,6 @@ function createNormalizedGenerationDialogDraft({
|
||||
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
|
||||
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
|
||||
characterSheetEnabled: true,
|
||||
characterSheetViewSet:
|
||||
getNormalizedString(sourceLayer, 'viewSet') === '八视图'
|
||||
? '8'
|
||||
: '4',
|
||||
characterSpecReference:
|
||||
resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
@@ -1302,12 +1311,19 @@ function createNormalizedGenerationDialogDraft({
|
||||
}
|
||||
|
||||
if (inputs.action === 'character-animation.generate') {
|
||||
const canvasReferences = resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
'source',
|
||||
availableLayers,
|
||||
);
|
||||
const inputMode = getNormalizedString(sourceLayer, 'inputMode', 'image');
|
||||
const primaryReferenceSlot = inputMode === 'video' ? 'motion' : 'source';
|
||||
const canvasReferences = [
|
||||
...resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
primaryReferenceSlot,
|
||||
availableLayers,
|
||||
),
|
||||
// 新槽位上线前视频 AI 也使用 source;仅在 motion 缺失时兼容旧配方。
|
||||
...(inputMode === 'video' && primaryReferenceSlot === 'motion'
|
||||
? resolveNormalizedReferences(sourceLayer, 'source', availableLayers)
|
||||
: []),
|
||||
];
|
||||
const storedReferenceSrc = getNormalizedString(
|
||||
sourceLayer,
|
||||
'referenceSrc',
|
||||
@@ -1318,7 +1334,9 @@ function createNormalizedGenerationDialogDraft({
|
||||
inputMode === 'video' ? 'video' : 'image',
|
||||
);
|
||||
const storedReferenceInput = sourceLayer.generationInputs?.references.find(
|
||||
(reference) => reference.id === 'source',
|
||||
(reference) =>
|
||||
reference.id === primaryReferenceSlot ||
|
||||
(inputMode === 'video' && reference.id === 'source'),
|
||||
);
|
||||
const storedReferenceWidth = getNormalizedNumber(
|
||||
sourceLayer,
|
||||
@@ -1337,7 +1355,7 @@ function createNormalizedGenerationDialogDraft({
|
||||
id: `restored-character-animation-reference:${sourceLayer.id}`,
|
||||
label:
|
||||
storedReferenceInput?.label ??
|
||||
(storedReferenceMediaType === 'video' ? '参考视频' : '角色图片'),
|
||||
(storedReferenceMediaType === 'video' ? '动作视频' : '角色图片'),
|
||||
src: storedReferenceSrc,
|
||||
objectKey: storedReferenceSrc,
|
||||
...(storedReferenceInput?.refType === 'asset'
|
||||
@@ -1355,6 +1373,33 @@ function createNormalizedGenerationDialogDraft({
|
||||
: ('image' as const),
|
||||
}
|
||||
: undefined);
|
||||
const appearanceCanvasReferences = resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
'appearance',
|
||||
availableLayers,
|
||||
);
|
||||
const storedAppearanceSrcs = getNormalizedStringArray(
|
||||
sourceLayer,
|
||||
'appearanceReferenceSrc',
|
||||
)
|
||||
.map((src) => src.trim())
|
||||
.filter((src) => src.length > 0);
|
||||
const storedAppearanceInput = sourceLayer.generationInputs?.references.find(
|
||||
(reference) => reference.id === 'appearance',
|
||||
);
|
||||
const restoredAppearanceReferences =
|
||||
appearanceCanvasReferences.length > 0
|
||||
? appearanceCanvasReferences
|
||||
: storedAppearanceSrcs.map((storedAppearanceSrc, index) => ({
|
||||
id: `restored-character-animation-appearance:${sourceLayer.id}:${index}`,
|
||||
label: storedAppearanceInput?.label ?? '参考图',
|
||||
src: storedAppearanceSrc,
|
||||
objectKey: storedAppearanceSrc,
|
||||
mediaType: 'image' as const,
|
||||
...(storedAppearanceInput?.refType === 'asset'
|
||||
? { sourceAssetId: storedAppearanceInput.refId }
|
||||
: {}),
|
||||
}));
|
||||
const animationSource = restoredReference
|
||||
? (availableLayers ?? []).find(
|
||||
(layer) =>
|
||||
@@ -1373,7 +1418,11 @@ function createNormalizedGenerationDialogDraft({
|
||||
});
|
||||
// 帧数/时长必须匹配合法配对(32/4、40/5、48/6),否则 UI 会同时高亮
|
||||
// 冲突的选项而提交被后端拒绝。这里按合法配对校验,损坏数据回退到默认 32/4。
|
||||
const restoredFrameCount = getNormalizedNumber(sourceLayer, 'frameCount', 32);
|
||||
const restoredFrameCount = getNormalizedNumber(
|
||||
sourceLayer,
|
||||
'frameCount',
|
||||
32,
|
||||
);
|
||||
const restoredDurationSeconds = getNormalizedNumber(
|
||||
sourceLayer,
|
||||
'durationSeconds',
|
||||
@@ -1395,6 +1444,10 @@ function createNormalizedGenerationDialogDraft({
|
||||
prompt,
|
||||
sourceLayerId: animationSource?.id,
|
||||
generationReferences: restoredReference ? [restoredReference] : [],
|
||||
characterAnimationAppearanceReferences:
|
||||
restoredAppearanceReferences.length
|
||||
? restoredAppearanceReferences
|
||||
: undefined,
|
||||
characterAnimationBackgroundColor: (getNormalizedString(
|
||||
sourceLayer,
|
||||
'backgroundColor',
|
||||
@@ -1410,14 +1463,68 @@ function createNormalizedGenerationDialogDraft({
|
||||
'ratio',
|
||||
animationSource ? 'same' : '1:1',
|
||||
) as GenerateDialogState['characterAnimationRatio'],
|
||||
characterAnimationFrameCount: pairedFrameCount as GenerateDialogState['characterAnimationFrameCount'],
|
||||
characterAnimationDurationSeconds: pairedDurationSeconds as GenerateDialogState['characterAnimationDurationSeconds'],
|
||||
characterAnimationFrameCount:
|
||||
pairedFrameCount as GenerateDialogState['characterAnimationFrameCount'],
|
||||
characterAnimationDurationSeconds:
|
||||
pairedDurationSeconds as GenerateDialogState['characterAnimationDurationSeconds'],
|
||||
}),
|
||||
sourceLayer,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (inputs.action === 'character-animation.convert') {
|
||||
const sourceReferences = resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
'source',
|
||||
availableLayers,
|
||||
);
|
||||
const storedSource = getNormalizedString(
|
||||
sourceLayer,
|
||||
'sourceVideoSrc',
|
||||
).trim();
|
||||
const storedReferenceInput = sourceLayer.generationInputs?.references.find(
|
||||
(reference) => reference.id === 'source',
|
||||
);
|
||||
const restoredSource =
|
||||
sourceReferences[0] ??
|
||||
(storedSource
|
||||
? {
|
||||
id: `restored-character-animation-conversion:${sourceLayer.id}`,
|
||||
label: storedReferenceInput?.label ?? '源视频',
|
||||
src: storedSource,
|
||||
objectKey: storedSource,
|
||||
mediaType: 'video' as const,
|
||||
...(storedReferenceInput?.refType === 'asset'
|
||||
? { sourceAssetId: storedReferenceInput.refId }
|
||||
: {}),
|
||||
}
|
||||
: undefined);
|
||||
if (!restoredSource) {
|
||||
return null;
|
||||
}
|
||||
const draft = createCharacterAnimationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
});
|
||||
return draft
|
||||
? placeDraftBesideSourceLayer(
|
||||
{
|
||||
...draft,
|
||||
sourceLayerId: undefined,
|
||||
characterAnimationWorkflow: 'video-conversion',
|
||||
characterAnimationConversionVideoReference: restoredSource,
|
||||
characterAnimationConversionResolution: getNormalizedString(
|
||||
sourceLayer,
|
||||
'resolution',
|
||||
'480p',
|
||||
) as GenerateDialogState['characterAnimationConversionResolution'],
|
||||
},
|
||||
sourceLayer,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2136,24 +2243,52 @@ export function createCharacterAnimationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
layer,
|
||||
workflow = 'ai',
|
||||
}: {
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
layer?: CanvasLayer;
|
||||
workflow?: 'ai' | 'video-conversion';
|
||||
}): Omit<CanvasGenerationDialogState, 'id'> | null {
|
||||
if (layer && layer.assetKind !== 'character') {
|
||||
if (
|
||||
layer &&
|
||||
(workflow === 'video-conversion'
|
||||
? layer.mediaType !== 'video' &&
|
||||
!(
|
||||
layer.assetKind === 'character-animation' &&
|
||||
Boolean(layer.previewVideoPath?.trim())
|
||||
)
|
||||
: layer.assetKind !== 'character')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const draft: Omit<CanvasGenerationDialogState, 'id'> = {
|
||||
mode: 'character-animation',
|
||||
...(layer ? { sourceLayerId: layer.id } : {}),
|
||||
generationReferences: layer ? [createCanvasLayerReference(layer)] : [],
|
||||
generationReferences:
|
||||
layer && workflow === 'ai'
|
||||
? [
|
||||
createCanvasLayerReference(layer, {
|
||||
preferSequenceVideo: true,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
...(layer && workflow === 'video-conversion'
|
||||
? {
|
||||
characterAnimationConversionVideoReference:
|
||||
createCanvasLayerReference(layer, {
|
||||
preferSequenceVideo: true,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
characterAnimationResolution: '480p',
|
||||
characterAnimationRatio: layer ? 'same' : '1:1',
|
||||
characterAnimationWorkflow: workflow,
|
||||
characterAnimationConversionResolution: '480p',
|
||||
characterAnimationRatio: layer && workflow === 'ai' ? 'same' : '1:1',
|
||||
...(layer ? {} : { characterAnimationBackgroundColor: 'green' as const }),
|
||||
characterAnimationFrameCount: 32,
|
||||
characterAnimationDurationSeconds: 4,
|
||||
@@ -2220,18 +2355,42 @@ export function appendGenerationReference(
|
||||
}
|
||||
if (dialog?.mode === 'character-animation') {
|
||||
const mediaType = getReferenceMediaType(layer);
|
||||
if (
|
||||
mediaType === 'audio' ||
|
||||
(mediaType === 'image' &&
|
||||
layer.assetKind !== 'character' &&
|
||||
layer.assetKind !== 'character-animation')
|
||||
) {
|
||||
if (dialog.characterAnimationWorkflow === 'video-conversion') {
|
||||
return mediaType === 'video' || hasCharacterAnimationVideoReference(layer)
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
sourceLayerId: layer.id,
|
||||
characterAnimationConversionVideoReference:
|
||||
createCanvasLayerReference(layer, {
|
||||
preferSequenceVideo: true,
|
||||
}),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
const hasVideoMotionReference =
|
||||
dialog.generationReferences?.[0]?.mediaType === 'video';
|
||||
if (mediaType === 'audio') {
|
||||
return dialog;
|
||||
}
|
||||
return resizeGenerationPlaceholderToCharacterAnimationSelection({
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
sourceLayerId: layer.id,
|
||||
generationReferences: [createCanvasLayerReference(layer)],
|
||||
...(mediaType === 'image' &&
|
||||
dialog.generationReferences?.[0]?.mediaType === 'video'
|
||||
? {
|
||||
characterAnimationAppearanceReferences: [
|
||||
...(dialog.characterAnimationAppearanceReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
}
|
||||
: {
|
||||
sourceLayerId: layer.id,
|
||||
generationReferences: [
|
||||
createCanvasLayerReference(layer, {
|
||||
preferSequenceVideo: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
composerOpen: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
onSubmitRequest: () => void;
|
||||
lockedModel?: string;
|
||||
styleControl?: ReactNode;
|
||||
extraToggles?: ReactNode;
|
||||
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle?: (
|
||||
anchor: HTMLElement | null,
|
||||
@@ -138,6 +139,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
onSubmitRequest,
|
||||
lockedModel,
|
||||
styleControl,
|
||||
extraToggles,
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
}: ImageCanvasGenerationImageOptionsViewProps) {
|
||||
@@ -331,20 +333,25 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
</div>
|
||||
) : null}
|
||||
{styleControl ??
|
||||
(supportsImageStyle ? (
|
||||
<label className="image-canvas-editor__image-style-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dialog.style === 'pixelArt'}
|
||||
disabled={isGenerating}
|
||||
onChange={(event) =>
|
||||
updateDialog({
|
||||
style: event.target.checked ? 'pixelArt' : 'none',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>像素艺术</span>
|
||||
</label>
|
||||
(supportsImageStyle || extraToggles ? (
|
||||
<div className="image-canvas-editor__generation-toggles">
|
||||
{supportsImageStyle ? (
|
||||
<label className="image-canvas-editor__image-style-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dialog.style === 'pixelArt'}
|
||||
disabled={isGenerating}
|
||||
onChange={(event) =>
|
||||
updateDialog({
|
||||
style: event.target.checked ? 'pixelArt' : 'none',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>像素艺术</span>
|
||||
</label>
|
||||
) : null}
|
||||
{extraToggles}
|
||||
</div>
|
||||
) : null)}
|
||||
{includeModel ? (
|
||||
<div
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CANVAS_GENERATION_ACTIONS = [
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'character-animation.convert',
|
||||
'image.edit',
|
||||
'ui-design.extract-assets',
|
||||
'image.perfect-pixel',
|
||||
@@ -46,6 +47,7 @@ const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'character-animation.convert',
|
||||
'ui-design.extract-assets',
|
||||
]);
|
||||
|
||||
|
||||
@@ -558,6 +558,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
resultType: 'ai',
|
||||
taskId: 'character-animation-task-1',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '待机动作',
|
||||
@@ -605,7 +606,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
imageSequenceFrames: frames,
|
||||
previewVideoPath: null,
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
x: -222,
|
||||
y: -242,
|
||||
width: 1024,
|
||||
@@ -625,6 +626,61 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
expect(layer?.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates direct video conversion layers without fake prompt or model metadata', () => {
|
||||
const frames = [
|
||||
{ imageSrc: '/converted/frame-1.png', width: 640, height: 360 },
|
||||
{ imageSrc: '/converted/frame-2.png', width: 640, height: 360 },
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
resultType: 'video-conversion',
|
||||
taskId: 'conversion-task-1',
|
||||
processingMethod: '视频直接转换',
|
||||
frames,
|
||||
frameCount: 2,
|
||||
durationMs: 2_500,
|
||||
frameWidth: 640,
|
||||
frameHeight: 360,
|
||||
sourceFps: 29.97,
|
||||
samplingFps: 0.8,
|
||||
priceMudPoints: 0,
|
||||
resource: {
|
||||
resourceId: 'resource-converted-animation',
|
||||
projectId: 'project-1',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
width: 640,
|
||||
height: 360,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 2_500,
|
||||
},
|
||||
},
|
||||
generatedIndex: 18,
|
||||
title: '视频转换序列帧',
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.convert',
|
||||
fields: [],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
taskId: 'conversion-task-1',
|
||||
provider: 'FFmpeg',
|
||||
imageSequenceDurationMs: 2_500,
|
||||
});
|
||||
expect(layer?.prompt).toBeUndefined();
|
||||
expect(layer?.actualPrompt).toBeUndefined();
|
||||
expect(layer?.model).toBeUndefined();
|
||||
expect(layer?.previewVideoPath).toBeNull();
|
||||
});
|
||||
|
||||
it('links a character animation result layer to its persisted project resource', () => {
|
||||
const frames = [
|
||||
{
|
||||
@@ -644,6 +700,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
resultType: 'ai',
|
||||
taskId: 'character-animation-task-persisted',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '奔跑动作',
|
||||
@@ -704,6 +761,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
resultType: 'ai',
|
||||
taskId: 'character-animation-task-asset-only',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '跳跃动作',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
EditorAudioGenerationResult,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorIconSpritesheetGenerationResult,
|
||||
EditorIconSpritesheetIconResult,
|
||||
EditorImageGenerationResult,
|
||||
@@ -66,7 +66,7 @@ type AudioResultLayerOptions = {
|
||||
};
|
||||
|
||||
type CharacterAnimationResultLayerOptions = {
|
||||
generated: EditorCharacterAnimationGenerationResult;
|
||||
generated: EditorCharacterAnimationResult;
|
||||
generatedIndex: number;
|
||||
title: string;
|
||||
canvasSize: CanvasSize;
|
||||
@@ -497,7 +497,16 @@ export function createCharacterAnimationResultLayer({
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: previewFrame.imageSrc,
|
||||
imageSequenceFrames,
|
||||
previewVideoPath: null,
|
||||
previewVideoPath:
|
||||
generated.resultType === 'ai'
|
||||
? generated.previewVideoPath
|
||||
: sourceLayer?.mediaType === 'video'
|
||||
? sourceLayer.objectKey?.trim() ||
|
||||
sourceLayer.previewVideoPath ||
|
||||
sourceLayer.src
|
||||
: sourceLayer?.assetKind === 'character-animation'
|
||||
? (sourceLayer.previewVideoPath ?? null)
|
||||
: null,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
@@ -506,9 +515,11 @@ export function createCharacterAnimationResultLayer({
|
||||
originalHeight,
|
||||
zIndex: generatedIndex + 10,
|
||||
sourceType: 'generated',
|
||||
prompt: generated.prompt,
|
||||
actualPrompt: generated.prompt,
|
||||
model: generated.model,
|
||||
prompt: generated.resultType === 'ai' ? generated.prompt : undefined,
|
||||
actualPrompt: generated.resultType === 'ai' ? generated.prompt : undefined,
|
||||
model: generated.resultType === 'ai' ? generated.model : undefined,
|
||||
provider:
|
||||
generated.resultType === 'video-conversion' ? 'FFmpeg' : undefined,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceDurationMs,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
calculateEditorUiDesignPrice,
|
||||
calculateEditorVideoPrice,
|
||||
canOpenRedrawPanel,
|
||||
createCanvasLayerReference,
|
||||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
decodeCanvasGenerationInputs,
|
||||
@@ -64,6 +65,37 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
describe('ImageCanvasGenerationModel', () => {
|
||||
it('uses a character animation preview video when a video reference is requested', () => {
|
||||
const reference = createCanvasLayerReference(
|
||||
{
|
||||
id: 'layer-sequence',
|
||||
resourceId: 'resource-sequence',
|
||||
sourceResourceId: 'resource-motion-video',
|
||||
title: '序列帧动作',
|
||||
src: '/frames/frame-001.png',
|
||||
previewVideoPath: '/motion/preview.mp4',
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 480,
|
||||
height: 480,
|
||||
originalWidth: 480,
|
||||
originalHeight: 480,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
{ preferSequenceVideo: true },
|
||||
);
|
||||
|
||||
expect(reference).toMatchObject({
|
||||
mediaType: 'video',
|
||||
src: '/motion/preview.mp4',
|
||||
objectKey: '/motion/preview.mp4',
|
||||
resourceId: 'resource-motion-video',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves character animation pixel and display sizes from ratio and resolution', () => {
|
||||
expect(
|
||||
resolveEditorCharacterAnimationFramePixelSize({
|
||||
@@ -117,6 +149,59 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists motion and appearance slots for video-guided character animation', () => {
|
||||
const inputs = buildCharacterAnimationGenerationInputs(
|
||||
'复刻动作并替换外观',
|
||||
null,
|
||||
{
|
||||
id: 'motion-video',
|
||||
label: '奔跑动作.mov',
|
||||
src: 'users/user-1/motion.mov',
|
||||
mediaType: 'video',
|
||||
resourceId: 'resource-motion',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
referenceSrc: 'users/user-1/motion.mov',
|
||||
referenceMediaType: 'video',
|
||||
appearanceReferences: [
|
||||
{
|
||||
id: 'appearance-image',
|
||||
label: '骑士外观.png',
|
||||
src: 'users/user-1/appearance.png',
|
||||
mediaType: 'image',
|
||||
sourceAssetId: 'asset-appearance',
|
||||
},
|
||||
],
|
||||
appearanceReferenceSrcs: ['users/user-1/appearance.png'],
|
||||
},
|
||||
);
|
||||
|
||||
expect(inputs.references).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'motion',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-motion',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'appearance',
|
||||
refType: 'asset',
|
||||
refId: 'asset-appearance',
|
||||
}),
|
||||
]);
|
||||
expect(inputs.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: 'inputMode', title: '输入模式', value: 'video' },
|
||||
{
|
||||
id: 'appearanceReferenceSrc',
|
||||
title: '参考图',
|
||||
value: 'users/user-1/appearance.png',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
|
||||
@@ -913,6 +913,8 @@ export function buildCharacterAnimationGenerationInputs(
|
||||
backgroundColor?: string;
|
||||
referenceSrc?: string;
|
||||
referenceMediaType?: 'image' | 'video';
|
||||
appearanceReferences?: CharacterReferenceImage[];
|
||||
appearanceReferenceSrcs?: string[];
|
||||
} = {},
|
||||
): CanvasGenerationInputs {
|
||||
const inputMode =
|
||||
@@ -967,6 +969,15 @@ export function buildCharacterAnimationGenerationInputs(
|
||||
'referenceHeight',
|
||||
)
|
||||
: []),
|
||||
...(options.appearanceReferenceSrcs?.length
|
||||
? options.appearanceReferenceSrcs.flatMap((src) =>
|
||||
createGenerationInputField(
|
||||
'参考图',
|
||||
src,
|
||||
'appearanceReferenceSrc',
|
||||
),
|
||||
)
|
||||
: []),
|
||||
...createGenerationInputField('动作描述', promptText, 'prompt'),
|
||||
...(options.backgroundColor
|
||||
? createGenerationInputField(
|
||||
@@ -992,19 +1003,28 @@ export function buildCharacterAnimationGenerationInputs(
|
||||
'durationSeconds',
|
||||
),
|
||||
],
|
||||
references: sourceLayer
|
||||
? createLayerGenerationInputReference(
|
||||
reference?.mediaType === 'video' ? '参考视频' : '角色图片',
|
||||
sourceLayer,
|
||||
{ id: 'source' },
|
||||
)
|
||||
: reference
|
||||
references: [
|
||||
...(reference
|
||||
? createGenerationInputReference(
|
||||
reference.mediaType === 'video' ? '参考视频' : '角色图片',
|
||||
reference.mediaType === 'video' ? '参考视频' : '参考图片',
|
||||
reference,
|
||||
{ id: 'source' },
|
||||
{ id: reference.mediaType === 'video' ? 'motion' : 'source' },
|
||||
)
|
||||
: [],
|
||||
: sourceLayer
|
||||
? createLayerGenerationInputReference('角色图片', sourceLayer, {
|
||||
id: 'source',
|
||||
})
|
||||
: []),
|
||||
...(options.appearanceReferences?.length
|
||||
? options.appearanceReferences.flatMap((appearanceReference) =>
|
||||
createGenerationInputReference(
|
||||
'参考图',
|
||||
appearanceReference,
|
||||
{ id: 'appearance' },
|
||||
),
|
||||
)
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1264,19 +1284,33 @@ export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
|
||||
|
||||
export function createCanvasLayerReference(
|
||||
layer: CanvasLayer,
|
||||
options: { preferSequenceVideo?: boolean } = {},
|
||||
): CharacterReferenceImage {
|
||||
const mediaType =
|
||||
layer.mediaType === 'video' || layer.mediaType === 'audio'
|
||||
? layer.mediaType
|
||||
// 正式序列帧资源的 PNG 是最终交付物;在动作视频/直接转换槽位中,若仍保留底层视频,
|
||||
// 则沿用视频引用。未指定时保持旧的整张序列帧图片语义。
|
||||
const sequenceVideoSrc =
|
||||
options.preferSequenceVideo && layer.assetKind === 'character-animation'
|
||||
? layer.previewVideoPath?.trim()
|
||||
: undefined;
|
||||
const isVideoReference =
|
||||
Boolean(sequenceVideoSrc) || layer.mediaType === 'video';
|
||||
const mediaType = isVideoReference
|
||||
? 'video'
|
||||
: layer.mediaType === 'audio'
|
||||
? 'audio'
|
||||
: undefined;
|
||||
const src = sequenceVideoSrc || layer.objectKey?.trim() || layer.src;
|
||||
return {
|
||||
id: `canvas-${layer.id}`,
|
||||
label: layer.title,
|
||||
src: layer.objectKey?.trim() || layer.src,
|
||||
src,
|
||||
...(mediaType ? { mediaType } : {}),
|
||||
objectKey: layer.objectKey ?? undefined,
|
||||
assetKind: layer.assetKind ?? null,
|
||||
objectKey: sequenceVideoSrc || (layer.objectKey ?? undefined),
|
||||
assetObjectId: layer.assetObjectId ?? undefined,
|
||||
resourceId: layer.resourceId,
|
||||
resourceId: sequenceVideoSrc
|
||||
? (layer.sourceResourceId ?? layer.resourceId)
|
||||
: layer.resourceId,
|
||||
sourceAssetId: layer.sourceAssetId ?? undefined,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
@@ -1340,7 +1374,8 @@ export function buildDeterministicGenerationInputs(
|
||||
| 'image.perfect-pixel'
|
||||
| 'spritesheet.split'
|
||||
| 'image.remove-background'
|
||||
| 'image.crop-expand',
|
||||
| 'image.crop-expand'
|
||||
| 'character-animation.remove-background',
|
||||
sourceLayer: CanvasLayer,
|
||||
sourceTitle = '原图',
|
||||
): CanvasGenerationInputs {
|
||||
@@ -2182,7 +2217,6 @@ export function buildCharacterSheetGenerationInputs(
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
viewSet?: '4' | '8';
|
||||
} = {},
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
@@ -2195,11 +2229,6 @@ export function buildCharacterSheetGenerationInputs(
|
||||
options.model ?? DEFAULT_IMAGE_MODEL,
|
||||
'model',
|
||||
),
|
||||
...createGenerationInputField(
|
||||
'视角集合',
|
||||
options.viewSet === '8' ? '八视图' : '四视图',
|
||||
'viewSet',
|
||||
),
|
||||
...createGenerationInputField(
|
||||
'比例',
|
||||
options.aspectRatio ?? '1:1',
|
||||
|
||||
@@ -680,12 +680,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
action: 'character-sheet.generate',
|
||||
fields: expect.arrayContaining([
|
||||
{ id: 'prompt', title: '角色设定', value: '日系魔法少女' },
|
||||
{ id: 'viewSet', title: '视角集合', value: '四视图' },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input : null).toMatchObject({
|
||||
characterSheetViews: 4,
|
||||
kind: 'character-sheet',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
// 图集不做像素化、不去背景,screenColor/segModel 不携带。
|
||||
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
|
||||
@@ -696,33 +696,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('builds eight-direction character-sheet plans when view set is eight', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'character',
|
||||
prompt: '日系魔法少女',
|
||||
status: 'idle',
|
||||
characterSheetEnabled: true,
|
||||
characterSheetViewSet: '8',
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 4,
|
||||
});
|
||||
|
||||
expect(plan.kind === 'image' ? plan.input : null).toMatchObject({
|
||||
kind: 'character-sheet',
|
||||
characterSheetViews: 8,
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||||
expect.objectContaining({
|
||||
action: 'character-sheet.generate',
|
||||
fields: expect.arrayContaining([
|
||||
{ id: 'viewSet', title: '视角集合', value: '八视图' },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks UI design generation submissions to gpt-image-2', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
|
||||
@@ -802,8 +802,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
|
||||
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
|
||||
const sheetEnabled = dialog.characterSheetEnabled === true;
|
||||
const sheetViewSet = dialog.characterSheetViewSet ?? '4';
|
||||
// 中文注释:多方向角色图走后端 kind=character-sheet(绿色间隔底,便于连通块拆分),
|
||||
// 中文注释:多方向角色图走后端 kind=character-sheet(绿色间隔底,便于后续拆分),
|
||||
// 产物按 icon-spritesheet 落库,复用既有「拆分图集」能力;不做像素化、不去背景。
|
||||
return {
|
||||
kind: 'image',
|
||||
@@ -823,7 +822,6 @@ export function buildImageGenerationSubmissionPlan({
|
||||
...(sheetEnabled
|
||||
? {
|
||||
assetKind: 'icon-spritesheet',
|
||||
characterSheetViews: sheetViewSet === '8' ? 8 : 4,
|
||||
}
|
||||
: {}),
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
@@ -845,7 +843,6 @@ export function buildImageGenerationSubmissionPlan({
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
viewSet: sheetViewSet,
|
||||
},
|
||||
)
|
||||
: buildCharacterGenerationInputs(
|
||||
@@ -1183,8 +1180,8 @@ export function buildCharacterAnimationSubmissionPlan({
|
||||
}): CharacterAnimationSubmissionPlan {
|
||||
const promptText = panel.promptText.trim();
|
||||
const resultTitle = resolveGenerationAssetLabel(panel.assetLabel, '角色动作');
|
||||
if (!promptText) {
|
||||
throw new Error('动画描述不能为空。');
|
||||
if (!promptText && !reference && !sourceLayer) {
|
||||
throw new Error('动画描述和参考素材不能同时为空。');
|
||||
}
|
||||
const inputMode =
|
||||
reference?.mediaType === 'video'
|
||||
|
||||
@@ -11,8 +11,7 @@ type ImageCanvasReferenceSlotTone =
|
||||
| 'character'
|
||||
| 'quick-edit'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'character-sheet';
|
||||
| 'audio';
|
||||
|
||||
type ImageCanvasReferenceSlotProps = {
|
||||
buttonRef?: RefObject<HTMLButtonElement | null>;
|
||||
@@ -26,8 +25,6 @@ type ImageCanvasReferenceSlotProps = {
|
||||
objectKey?: string | null;
|
||||
disabled?: boolean;
|
||||
isAdd?: boolean;
|
||||
/** 选中态(用于模式开关类入口):置位时高亮边框并带 aria-pressed。 */
|
||||
pressed?: boolean;
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
@@ -45,7 +42,6 @@ export function ImageCanvasReferenceSlot({
|
||||
objectKey,
|
||||
disabled = false,
|
||||
isAdd = false,
|
||||
pressed,
|
||||
onClick,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
@@ -58,11 +54,11 @@ export function ImageCanvasReferenceSlot({
|
||||
? 'image-canvas-editor__reference-chip--has-image'
|
||||
: 'image-canvas-editor__reference-chip--icon-only',
|
||||
isAdd ? 'image-canvas-editor__reference-chip--upload' : null,
|
||||
pressed ? 'image-canvas-editor__reference-chip--active' : null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '); const slotContent = (
|
||||
.join(' ');
|
||||
const slotContent = (
|
||||
<>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
{hasPreviewImage ? (
|
||||
@@ -93,7 +89,6 @@ export function ImageCanvasReferenceSlot({
|
||||
className={slotClassName}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={pressed === undefined ? undefined : pressed}
|
||||
title={title ?? ariaLabel}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user