Spine序列帧修复与功能收紧

This commit is contained in:
2026-08-13 01:05:57 +08:00
parent 132bf7db35
commit d6cb68cf13
56 changed files with 4995 additions and 922 deletions
@@ -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