合并编辑器素材库能力
合并 codex/editor-asset-library 到宣发素材演示分支 保留素材库、生成器、音频、UI设计图和持久化相关能力 补回宣发素材入口与 5 泥点计费契约
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -28,4 +28,26 @@ describe('PlatformFloatingMenu', () => {
|
||||
|
||||
expect(onRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps pointer events from leaking to canvas-style parents', () => {
|
||||
const onParentPointerDown = vi.fn();
|
||||
const onMenuPointerDown = vi.fn();
|
||||
|
||||
render(
|
||||
<div onPointerDown={onParentPointerDown}>
|
||||
<PlatformFloatingMenu
|
||||
label="项目菜单"
|
||||
placement="bottom-end"
|
||||
onPointerDown={onMenuPointerDown}
|
||||
>
|
||||
<PlatformFloatingMenuItem>重命名</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('menu', { name: '项目菜单' }));
|
||||
|
||||
expect(onMenuPointerDown).toHaveBeenCalledOnce();
|
||||
expect(onParentPointerDown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
|
||||
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformFloatingMenuProps = {
|
||||
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
@@ -26,9 +26,12 @@ export function PlatformFloatingMenu({
|
||||
label,
|
||||
placement = 'top-end',
|
||||
style,
|
||||
onPointerDown,
|
||||
...divProps
|
||||
}: PlatformFloatingMenuProps) {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={[
|
||||
'platform-floating-menu',
|
||||
`platform-floating-menu--${placement}`,
|
||||
@@ -39,6 +42,10 @@ export function PlatformFloatingMenu({
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
style={style}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onPointerDown?.(event);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
LabelHTMLAttributes,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from 'react';
|
||||
|
||||
type PlatformIconButtonBaseProps = {
|
||||
@@ -52,16 +54,22 @@ type PlatformIconButtonProps =
|
||||
* 平台通用图标动作按钮。
|
||||
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
|
||||
*/
|
||||
export function PlatformIconButton({
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
variant = 'platformIcon',
|
||||
title,
|
||||
className,
|
||||
asChild,
|
||||
...actionProps
|
||||
}: PlatformIconButtonProps) {
|
||||
export const PlatformIconButton = forwardRef<
|
||||
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
|
||||
PlatformIconButtonProps
|
||||
>(function PlatformIconButton(
|
||||
{
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
variant = 'platformIcon',
|
||||
title,
|
||||
className,
|
||||
asChild,
|
||||
...actionProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const variantClassName = {
|
||||
platformIcon: 'platform-icon-button',
|
||||
surfaceFloating:
|
||||
@@ -78,6 +86,7 @@ export function PlatformIconButton({
|
||||
return (
|
||||
<label
|
||||
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
|
||||
ref={ref as Ref<HTMLLabelElement>}
|
||||
title={title}
|
||||
className={actionClassName}
|
||||
>
|
||||
@@ -112,6 +121,7 @@ export function PlatformIconButton({
|
||||
return (
|
||||
<span
|
||||
{...spanProps}
|
||||
ref={ref as Ref<HTMLSpanElement>}
|
||||
aria-disabled={disabled}
|
||||
aria-label={label}
|
||||
className={actionClassName}
|
||||
@@ -133,6 +143,7 @@ export function PlatformIconButton({
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={ref as Ref<HTMLButtonElement>}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
@@ -142,4 +153,4 @@ export function PlatformIconButton({
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type PlatformInlineOptionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -12,13 +12,13 @@ type PlatformInlineOptionButtonProps = Omit<
|
||||
* 平台内联选项按钮。
|
||||
* 统一承接工具面板里“当前选项 + 下拉箭头”这类轻量 pill 动作。
|
||||
*/
|
||||
export function PlatformInlineOptionButton({
|
||||
children,
|
||||
trailingIcon,
|
||||
className,
|
||||
type = 'button',
|
||||
...buttonProps
|
||||
}: PlatformInlineOptionButtonProps) {
|
||||
export const PlatformInlineOptionButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PlatformInlineOptionButtonProps
|
||||
>(function PlatformInlineOptionButton(
|
||||
{ children, trailingIcon, className, type = 'button', ...buttonProps },
|
||||
ref,
|
||||
) {
|
||||
const actionClassName = [
|
||||
'platform-inline-option-button inline-flex items-center justify-center border-0 bg-transparent text-sm font-black text-slate-700 transition-colors hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
className,
|
||||
@@ -27,7 +27,7 @@ export function PlatformInlineOptionButton({
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={actionClassName}>
|
||||
<button {...buttonProps} ref={ref} type={type} className={actionClassName}>
|
||||
<span className="platform-inline-option-button__label">{children}</span>
|
||||
{trailingIcon ? (
|
||||
<span className="platform-inline-option-button__trailing" aria-hidden="true">
|
||||
@@ -36,4 +36,4 @@ export function PlatformInlineOptionButton({
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -17,6 +17,10 @@ function createDialog(
|
||||
mode: 'generate',
|
||||
prompt: '初始提示',
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
imageModel: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +39,9 @@ function BasicGenerationHarness({
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
|
||||
const [isPickingReference, setIsPickingReference] = useState(false);
|
||||
const generationReferenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
@@ -42,13 +49,24 @@ function BasicGenerationHarness({
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
setGenerateDialog={setDialog}
|
||||
generationReferenceButtonRef={generationReferenceButtonRef}
|
||||
isGenerationReferenceMenuOpen={isReferenceMenuOpen}
|
||||
setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen}
|
||||
setIsPickingGenerationReferenceFromCanvas={setIsPickingReference}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="选择参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -71,31 +89,129 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '新的提示' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(requestUpload).toHaveBeenCalledWith('asset');
|
||||
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
||||
expect(submitGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs placeholder actions and closes through its interface', () => {
|
||||
it('keeps references on the first row and appends a new add-reference entry', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '参考图A',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
{
|
||||
id: 'ref-b',
|
||||
label: '参考图B',
|
||||
src: 'data:image/png;base64,cmVmQg==',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
const strip = panel.querySelector('.image-canvas-editor__reference-strip');
|
||||
|
||||
expect(strip).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy();
|
||||
expect(
|
||||
within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('removes an existing reference from its hover delete button', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '参考图A',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
|
||||
|
||||
expect(screen.queryByText('参考图A')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows only current image options and opens upward option panels', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
}),
|
||||
);
|
||||
const dimensionPanel = screen.getByRole('menu', {
|
||||
name: '生成图片尺寸选项',
|
||||
});
|
||||
expect(within(dimensionPanel).getByText('比例')).toBeTruthy();
|
||||
fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' }));
|
||||
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: /gpt-image-2/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
const submitButton = within(panel).getByRole('button', { name: '生成' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('clicks the parent generation panel to collapse an opened option panel', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('closes through its interface', () => {
|
||||
const closeComposer = vi.fn();
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<BasicGenerationHarness onClose={closeComposer} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成比例 1:1 2k 1张' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成模型 GPT Image' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('生成参数功能建设中');
|
||||
expect(alertSpy).toHaveBeenCalledWith('模型选择功能建设中');
|
||||
expect(closeComposer).toHaveBeenCalledTimes(1);
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import { ChevronDown, ImageIcon, X } from 'lucide-react';
|
||||
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import {
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
style: CSSProperties;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
|
||||
isGenerationReferenceMenuOpen?: boolean;
|
||||
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
|
||||
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle?: (
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: 'generation-reference') => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function triggerPlaceholderAction(label: string) {
|
||||
window.alert(`${label}功能建设中`);
|
||||
}
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -33,44 +52,105 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const label = reference.label || `参考图${index + 1}`;
|
||||
return (
|
||||
<ImageCanvasReferenceSlot
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={reference.src}
|
||||
label={label}
|
||||
ariaLabel={label}
|
||||
title={reference.label}
|
||||
onRemove={onRemove}
|
||||
removeLabel={`删除${label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasBasicGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
setGenerateDialog,
|
||||
generationReferenceButtonRef,
|
||||
isGenerationReferenceMenuOpen = false,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isGenerationReferenceMenuOpen,
|
||||
boundaryRefs: [generationReferenceButtonRef],
|
||||
onDismiss: () => setIsGenerationReferenceMenuOpen?.(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="image-canvas-editor__generation-composer"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="生成图片"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialog.status !== 'generating') {
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlatformIconButton
|
||||
variant="surfaceFloating"
|
||||
className="image-canvas-editor__generation-ref"
|
||||
label="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => onRequestUpload('asset')}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
<>
|
||||
<form
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="生成图片"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialog.status !== 'generating') {
|
||||
onSubmit(dialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>参考图</span>
|
||||
</PlatformIconButton>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
onRemove={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
generationReferences: (
|
||||
currentDialog.generationReferences ?? []
|
||||
).filter((item) => item.id !== reference.id),
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={generationReferenceButtonRef}
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
label="参考图"
|
||||
ariaLabel="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
isAdd
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
/>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="今天我们要创作什么"
|
||||
placeholder="今天想生成什么画面?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
@@ -86,35 +166,18 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
aria-label="生成比例 1:1 2k 1张"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('生成参数')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
中 · 1:1(2k) · 1张
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-model"
|
||||
aria-label="生成模型 GPT Image"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('模型选择')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
GPT Im...
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="生成"
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '12'}
|
||||
</PlatformActionButton>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
@@ -146,6 +209,38 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</form>
|
||||
</form>
|
||||
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
|
||||
label="参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
generationReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(true);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestUpload('generation-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,18 @@ import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||
describe('ImageCanvasBottomToolbarView', () => {
|
||||
it('renders the canvas tools and forwards tool changes', () => {
|
||||
const switchTool = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={createRef<HTMLSpanElement>()}
|
||||
publicationToolWrapRef={createRef<HTMLSpanElement>()}
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="generate"
|
||||
onSwitchTool={switchTool}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -22,9 +28,9 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '生成工具' })
|
||||
.getByRole('button', { name: '生成图片' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
).toBe('false');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '选择工具' })
|
||||
@@ -32,13 +38,112 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
).toBe('false');
|
||||
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成视频' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' }));
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '生成UI设计图' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成音乐' }));
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '生成图标素材' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '文字工具' }));
|
||||
|
||||
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(2, 'spec');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(4, 'text');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(3, 'spec');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
|
||||
});
|
||||
|
||||
it('only keeps the select and hand tools visibly pressed', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const renderToolbar = (effectiveTool: Parameters<
|
||||
typeof ImageCanvasBottomToolbarView
|
||||
>[0]['effectiveTool']) => (
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const { rerender } = render(renderToolbar('select'));
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '选择工具' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
rerender(renderToolbar('hand'));
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '抓手工具' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
for (const tool of [
|
||||
['upload', '上传工具'],
|
||||
['generate', '生成图片'],
|
||||
['video', '生成视频'],
|
||||
['music', '生成音乐'],
|
||||
['spec', '生成规范'],
|
||||
['character', '生成角色形象'],
|
||||
['icon', '生成图标素材'],
|
||||
['ui-design', '生成UI设计图'],
|
||||
['publication', '宣发素材'],
|
||||
] as const) {
|
||||
rerender(renderToolbar(tool[0]));
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: tool[1] })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens and closes bottom option tools on hover', () => {
|
||||
const openToolOptions = vi.fn();
|
||||
const closeToolOptions = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={openToolOptions}
|
||||
onCloseToolOptions={closeToolOptions}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerEnter(specToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(specToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(musicToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(musicToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(publicationToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(publicationToolWrapRef.current!);
|
||||
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,78 @@
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
ClipboardList,
|
||||
Download,
|
||||
Grid2X2,
|
||||
Hand,
|
||||
ImageIcon,
|
||||
ImagePlus,
|
||||
Megaphone,
|
||||
MousePointer2,
|
||||
Shapes,
|
||||
Sparkles,
|
||||
Type,
|
||||
WandSparkles,
|
||||
Music,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasTool } from './ImageCanvasEditorTypes';
|
||||
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
|
||||
|
||||
type ImageCanvasBottomToolbarViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
effectiveTool: CanvasTool;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
|
||||
onCloseToolOptions: (tool: ToolbarOptionTool) => void;
|
||||
};
|
||||
|
||||
const canvasTools: Array<{
|
||||
id: CanvasTool;
|
||||
label: string;
|
||||
icon: typeof MousePointer2;
|
||||
separatorBefore?: boolean;
|
||||
}> = [
|
||||
{ id: 'select', label: '选择工具', icon: MousePointer2 },
|
||||
{ id: 'hand', label: '抓手工具', icon: Hand },
|
||||
{ id: 'upload', label: '上传工具', icon: ImagePlus },
|
||||
{ id: 'generate', label: '生成工具', icon: WandSparkles },
|
||||
{ id: 'spec', label: '生成规范', icon: ClipboardList },
|
||||
{ id: 'character', label: '生成角色形象', icon: Sparkles },
|
||||
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
|
||||
{
|
||||
id: 'generate',
|
||||
label: '生成图片',
|
||||
icon: ImageIcon,
|
||||
separatorBefore: true,
|
||||
},
|
||||
{ id: 'video', label: '生成视频', icon: Clapperboard },
|
||||
{ id: 'music', label: '生成音乐', icon: Music },
|
||||
{ id: 'publication', label: '宣发素材', icon: Megaphone },
|
||||
{ id: 'text', label: '文字工具', icon: Type },
|
||||
{ id: 'shape', label: '形状标注工具', icon: Shapes },
|
||||
{ id: 'export', label: '导出工具', icon: Download },
|
||||
{
|
||||
id: 'spec',
|
||||
label: '生成规范',
|
||||
icon: ClipboardList,
|
||||
separatorBefore: true,
|
||||
},
|
||||
{ id: 'character', label: '生成角色形象', icon: UserRound },
|
||||
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
|
||||
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
|
||||
];
|
||||
|
||||
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
|
||||
return tool === 'music' || tool === 'spec' || tool === 'publication';
|
||||
}
|
||||
|
||||
function hasPersistentPressedState(tool: CanvasTool) {
|
||||
return tool === 'select' || tool === 'hand';
|
||||
}
|
||||
|
||||
export function ImageCanvasBottomToolbarView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
effectiveTool,
|
||||
onSwitchTool,
|
||||
onOpenToolOptions,
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -54,46 +81,68 @@ export function ImageCanvasBottomToolbarView({
|
||||
aria-label="AI画布工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon }) =>
|
||||
id === 'spec' ? (
|
||||
<span
|
||||
key={id}
|
||||
ref={specToolWrapRef}
|
||||
className="image-canvas-editor__spec-tool-wrap"
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
|
||||
if (isToolbarOptionTool(id)) {
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
ref={
|
||||
id === 'spec'
|
||||
? specToolWrapRef
|
||||
: id === 'music'
|
||||
? musicToolWrapRef
|
||||
: publicationToolWrapRef
|
||||
}
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar-option-wrap',
|
||||
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
|
||||
id === 'publication'
|
||||
? 'image-canvas-editor__publication-tool-wrap'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onPointerEnter={() => onOpenToolOptions(id)}
|
||||
onPointerLeave={() => onCloseToolOptions(id)}
|
||||
onFocus={() => onOpenToolOptions(id)}
|
||||
onBlur={() => onCloseToolOptions(id)}
|
||||
>
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={effectiveTool === id}
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
) : id === 'publication' ? (
|
||||
<span
|
||||
key={id}
|
||||
ref={publicationToolWrapRef}
|
||||
className="image-canvas-editor__publication-tool-wrap"
|
||||
>
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
key={id}
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,27 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
|
||||
|
||||
|
||||
function createSourceLayer(): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-a',
|
||||
resourceId: 'resource-a',
|
||||
title: '角色源图',
|
||||
src: 'data:image/png;base64,Y2hhcmFjdGVy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
originalWidth: 128,
|
||||
originalHeight: 128,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
}
|
||||
|
||||
function createPanel(
|
||||
patch: Partial<CharacterAnimationPanelState> = {},
|
||||
): CharacterAnimationPanelState {
|
||||
@@ -64,6 +82,7 @@ function CharacterAnimationPanelHarness({
|
||||
<div>
|
||||
<ImageCanvasCharacterAnimationPanelView
|
||||
panel={panel}
|
||||
sourceLayer={createSourceLayer()}
|
||||
style={{ left: 12, top: 24 }}
|
||||
price={18}
|
||||
setCharacterAnimationPanel={setPanel}
|
||||
@@ -84,7 +103,7 @@ function CharacterAnimationPanelHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
it('updates prompt, resolution and ratio while clearing failed state', () => {
|
||||
it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => {
|
||||
render(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
@@ -97,20 +116,37 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
fireEvent.change(screen.getByLabelText('动画描述'), {
|
||||
target: { value: `${'a'.repeat(4001)}` },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('分辨率'), {
|
||||
target: { value: '720p' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面比例'), {
|
||||
target: { value: '16:9' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
const menu = screen.getByRole('menu', { name: '动画参数选项' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000);
|
||||
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(menu).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('clicks the parent animation panel to collapse the parameter menu', () => {
|
||||
render(
|
||||
<CharacterAnimationPanelHarness initialPanel={createPanel()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', { name: '动画描述' }));
|
||||
|
||||
expect(screen.queryByRole('menu', { name: '动画参数选项' })).toBeNull();
|
||||
});
|
||||
|
||||
it('applies preset prompt and duration updates', () => {
|
||||
const updateDuration = vi.fn();
|
||||
render(
|
||||
@@ -124,9 +160,10 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '行走' }));
|
||||
fireEvent.change(screen.getByLabelText('时长'), {
|
||||
target: { value: '48' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' }));
|
||||
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toBe(
|
||||
'循环行走动作,步伐稳定。',
|
||||
@@ -145,7 +182,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成18泥点' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭角色动画生成面板' }),
|
||||
);
|
||||
@@ -177,7 +214,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
it('renders completed frame count and failed error state', () => {
|
||||
const result = {
|
||||
taskId: 'task-a',
|
||||
model: 'seedance2.0' as const,
|
||||
model: 'seedance2.0-fast' as const,
|
||||
prompt: '行走动作',
|
||||
previewVideoPath: '/preview.mp4',
|
||||
frames: [],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,23 @@ function CharacterGenerationHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
|
||||
render(<CharacterGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||||
const prompt = screen.getByRole('textbox', { name: '角色设定' });
|
||||
|
||||
expect(
|
||||
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
|
||||
.map((node) => node.textContent)
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
@@ -149,4 +166,32 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
|
||||
expect(screen.getByLabelText('选择常规参考图').textContent).toBe('true');
|
||||
});
|
||||
|
||||
it('removes bound character spec and regular references from their delete buttons', () => {
|
||||
render(<CharacterGenerationHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除角色形象规范' }));
|
||||
expect(screen.getByRole('button', { name: '角色形象规范' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '角色规范A' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
|
||||
expect(screen.queryByText('参考图A')).toBeNull();
|
||||
});
|
||||
|
||||
it('clicks the parent character panel to collapse reference menus', () => {
|
||||
render(
|
||||
<CharacterGenerationHarness
|
||||
initialDialog={createDialog()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
|
||||
expect(screen.getByRole('menu', { name: '角色形象规范来源' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', { name: '角色设定' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '角色形象规范来源' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,22 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -53,6 +55,30 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const label = reference.label || `参考图${index + 1}`;
|
||||
return (
|
||||
<ImageCanvasReferenceSlot
|
||||
tone="character"
|
||||
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={reference.src}
|
||||
label={label}
|
||||
ariaLabel={label}
|
||||
title={reference.label}
|
||||
onRemove={onRemove}
|
||||
removeLabel={`删除${label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasCharacterGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -72,6 +98,15 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onRememberImageModel,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
|
||||
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
|
||||
onDismiss: () => {
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="image-canvas-editor__character-composer"
|
||||
@@ -86,39 +121,33 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__character-reference-row">
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
角色形象规范
|
||||
</PlatformFieldLabel>
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={characterSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
{dialog.characterSpecReference ? (
|
||||
<img
|
||||
src={dialog.characterSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
{dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={characterSpecButtonRef}
|
||||
tone="spec"
|
||||
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={dialog.characterSpecReference?.src}
|
||||
label="角色规范"
|
||||
ariaLabel={dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
title={dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
disabled={dialog.status === 'generating'}
|
||||
isAdd={!dialog.characterSpecReference}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
onRemove={
|
||||
dialog.characterSpecReference
|
||||
? () =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterSpecReference: null,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
removeLabel="删除角色形象规范"
|
||||
/>
|
||||
{isCharacterSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -160,90 +189,78 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
常规参考图
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__character-reference-list">
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<span
|
||||
key={reference.id}
|
||||
className="image-canvas-editor__character-ref-thumb"
|
||||
title={reference.label}
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
onRemove={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
characterReferences: (
|
||||
currentDialog.characterReferences ?? []
|
||||
).filter((item) => item.id !== reference.id),
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={characterReferenceButtonRef}
|
||||
tone="default"
|
||||
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
label="参考图"
|
||||
ariaLabel="上传常规参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
isAdd
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
/>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<img src={reference.src} alt={reference.label} />
|
||||
<span className="image-canvas-editor__character-ref-index">
|
||||
{index + 1}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
上传常规参考图
|
||||
</span>
|
||||
</button>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
角色设定
|
||||
</PlatformFieldLabel>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
@@ -273,17 +290,13 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer, CropExpandPanelState } from './ImageCanvasEditorTypes';
|
||||
|
||||
type ImageCanvasCropExpandPanelViewProps = {
|
||||
panel: CropExpandPanelState;
|
||||
sourceLayer: CanvasLayer;
|
||||
style: CSSProperties;
|
||||
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
const CROP_EXPAND_FIELDS = [
|
||||
{ key: 'left', label: '左' },
|
||||
{ key: 'top', label: '上' },
|
||||
{ key: 'right', label: '右' },
|
||||
{ key: 'bottom', label: '下' },
|
||||
] as const;
|
||||
|
||||
function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
|
||||
return {
|
||||
...panel,
|
||||
status: panel.status === 'failed' ? 'idle' : panel.status,
|
||||
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageCanvasCropExpandPanelView({
|
||||
panel,
|
||||
sourceLayer,
|
||||
style,
|
||||
setCropExpandPanel,
|
||||
onSubmit,
|
||||
}: ImageCanvasCropExpandPanelViewProps) {
|
||||
return (
|
||||
<form
|
||||
className="image-canvas-editor__crop-expand-panel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="裁扩图片"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (panel.status !== 'processing') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__quick-edit-head">
|
||||
<div className="image-canvas-editor__quick-edit-reference">
|
||||
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
|
||||
<span>{sourceLayer.title}</span>
|
||||
</div>
|
||||
<EditorIconButton
|
||||
label="关闭裁扩图片"
|
||||
title="关闭"
|
||||
icon={X}
|
||||
onClick={() => setCropExpandPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="image-canvas-editor__crop-expand-fields">
|
||||
{CROP_EXPAND_FIELDS.map(({ key, label }) => (
|
||||
<label key={key} className="image-canvas-editor__crop-expand-field">
|
||||
<span>{label}</span>
|
||||
<PlatformTextField
|
||||
aria-label={`裁扩${label}`}
|
||||
value={String(panel.insets[key])}
|
||||
inputMode="numeric"
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) =>
|
||||
setCropExpandPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedCropExpandPanel(currentPanel),
|
||||
insets: {
|
||||
...currentPanel.insets,
|
||||
[key]: event.target.value,
|
||||
},
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{panel.status === 'failed' ? (
|
||||
<p className="image-canvas-editor__crop-expand-error" role="alert">
|
||||
{panel.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
className="image-canvas-editor__quick-edit-submit"
|
||||
disabled={panel.status === 'processing'}
|
||||
>
|
||||
{panel.status === 'processing' ? '处理中' : '应用'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,15 @@ import {
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
resolveSnappedLayerPosition,
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
splitCanvasLayoutItems,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
||||
@@ -122,6 +128,143 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-audio',
|
||||
resourceId: 'resource-audio',
|
||||
title: '游戏音效',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 420,
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
zIndex: 5,
|
||||
sourceType: 'generated',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-audio',
|
||||
{ imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3' },
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-audio',
|
||||
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores audio generation dialogs from saved layout', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-audio',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '金币掉落叮当声',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundType: 'loop',
|
||||
soundTempo: 120,
|
||||
generatedLayerId: 'layer-audio',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 420,
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
},
|
||||
};
|
||||
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
id: 'generation-dialog-audio',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '金币掉落叮当声',
|
||||
soundType: 'loop',
|
||||
soundTempo: 120,
|
||||
generatedLayerId: 'layer-audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes generation dialogs beside layers and splits them on load', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
resourceId: 'resource-generated',
|
||||
title: '生成图',
|
||||
src: 'data:image/png;base64,heavy',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 768,
|
||||
zIndex: 9,
|
||||
sourceType: 'generated',
|
||||
};
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-9',
|
||||
mode: 'generate',
|
||||
prompt: '刷新后要继续保留',
|
||||
status: 'generating',
|
||||
composerOpen: false,
|
||||
generatedLayerId: 'layer-generated',
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'reference-1',
|
||||
label: '参考图',
|
||||
src: 'data:image/png;base64,ref',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [layer],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
});
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout);
|
||||
|
||||
expect(layerItems).toHaveLength(1);
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
id: 'generation-dialog-9',
|
||||
mode: 'generate',
|
||||
prompt: '刷新后要继续保留',
|
||||
status: 'generating',
|
||||
generatedLayerId: 'layer-generated',
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 420,
|
||||
},
|
||||
});
|
||||
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
|
||||
label: '参考图',
|
||||
});
|
||||
});
|
||||
|
||||
it('snaps moving layers to nearby canvas and layer guides', () => {
|
||||
const movingLayer: CanvasLayer = {
|
||||
id: 'moving',
|
||||
|
||||
@@ -4,10 +4,13 @@ import type {
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
CanvasLayer,
|
||||
CanvasContextMenuState,
|
||||
CanvasMediaType,
|
||||
CanvasViewport,
|
||||
CharacterReferenceImage,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
SnapCandidate,
|
||||
@@ -150,6 +153,7 @@ export function serializeLayer(
|
||||
originalHeight: layer.originalHeight,
|
||||
zIndex: layer.zIndex,
|
||||
sourceType: layer.sourceType,
|
||||
mediaType: layer.mediaType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
@@ -169,6 +173,154 @@ export function serializeLayer(
|
||||
};
|
||||
}
|
||||
|
||||
type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
|
||||
itemType: 'generation-dialog';
|
||||
dialog: CanvasGenerationDialogState;
|
||||
};
|
||||
|
||||
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
||||
|
||||
export function serializeCanvasGenerationDialog(
|
||||
dialog: CanvasGenerationDialogState,
|
||||
): CanvasGenerationDialogSnapshot {
|
||||
return {
|
||||
itemType: 'generation-dialog',
|
||||
layerId: `generation-dialog:${dialog.id}`,
|
||||
resourceId: `generation-dialog:${dialog.id}`,
|
||||
dialog,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
}: {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
}): CanvasLayoutItems {
|
||||
return [
|
||||
...layers.map(serializeLayer),
|
||||
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
|
||||
];
|
||||
}
|
||||
|
||||
export function isCanvasGenerationDialogLayoutItem(
|
||||
item: EditorProjectLayerSnapshot,
|
||||
): item is CanvasGenerationDialogSnapshot {
|
||||
return (
|
||||
item.itemType === 'generation-dialog' &&
|
||||
Boolean(
|
||||
hydrateCanvasGenerationDialog(
|
||||
(item as { dialog?: unknown }).dialog,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function splitCanvasLayoutItems(
|
||||
items: EditorProjectLayerSnapshot[],
|
||||
): {
|
||||
layerItems: EditorProjectLayerSnapshot[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
} {
|
||||
const layerItems: EditorProjectLayerSnapshot[] = [];
|
||||
const generationDialogs: CanvasGenerationDialogState[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isCanvasGenerationDialogLayoutItem(item)) {
|
||||
const dialog = hydrateCanvasGenerationDialog(item.dialog);
|
||||
if (dialog) {
|
||||
generationDialogs.push(dialog);
|
||||
}
|
||||
return;
|
||||
}
|
||||
layerItems.push(item);
|
||||
});
|
||||
|
||||
return { layerItems, generationDialogs };
|
||||
}
|
||||
|
||||
export function hydrateCanvasGenerationDialog(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const snapshot = value as Partial<CanvasGenerationDialogState>;
|
||||
const id = stringOrNull(snapshot.id);
|
||||
const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : '';
|
||||
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
mode: snapshot.mode,
|
||||
prompt,
|
||||
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
|
||||
composerOpen:
|
||||
typeof snapshot.composerOpen === 'boolean'
|
||||
? snapshot.composerOpen
|
||||
: true,
|
||||
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
|
||||
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
|
||||
specType: isSpecGenerationType(snapshot.specType)
|
||||
? snapshot.specType
|
||||
: undefined,
|
||||
specValues: hydrateSpecFormValues(snapshot.specValues),
|
||||
specReference: hydrateCharacterReference(snapshot.specReference),
|
||||
generationReferences: hydrateCharacterReferences(
|
||||
snapshot.generationReferences,
|
||||
),
|
||||
characterSpecReference: hydrateCharacterReference(
|
||||
snapshot.characterSpecReference,
|
||||
),
|
||||
characterReferences: hydrateCharacterReferences(
|
||||
snapshot.characterReferences,
|
||||
),
|
||||
iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference),
|
||||
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
|
||||
? snapshot.iconDescriptions.filter(
|
||||
(description): description is string =>
|
||||
typeof description === 'string',
|
||||
)
|
||||
: undefined,
|
||||
uiDesignSpecReference: hydrateCharacterReference(
|
||||
snapshot.uiDesignSpecReference,
|
||||
),
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
videoModel:
|
||||
typeof snapshot.videoModel === 'string'
|
||||
? snapshot.videoModel
|
||||
: undefined,
|
||||
videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio),
|
||||
videoResolution:
|
||||
snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p'
|
||||
? snapshot.videoResolution
|
||||
: undefined,
|
||||
videoDurationSeconds:
|
||||
snapshot.videoDurationSeconds === 4 ||
|
||||
snapshot.videoDurationSeconds === 5
|
||||
? snapshot.videoDurationSeconds
|
||||
: undefined,
|
||||
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
|
||||
videoSound: snapshot.videoSound === 'off' ? 'off' : undefined,
|
||||
soundType:
|
||||
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
|
||||
? snapshot.soundType
|
||||
: undefined,
|
||||
soundTempo: bpmOrNull(snapshot.soundTempo),
|
||||
makeInstrumental:
|
||||
typeof snapshot.makeInstrumental === 'boolean'
|
||||
? snapshot.makeInstrumental
|
||||
: undefined,
|
||||
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
|
||||
imageSize: stringOrUndefined(snapshot.imageSize),
|
||||
errorMessage: stringOrUndefined(snapshot.errorMessage),
|
||||
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
|
||||
};
|
||||
}
|
||||
|
||||
export function hydrateLayer(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resourcesById: Map<string, { imageSrc: string }>,
|
||||
@@ -207,6 +359,10 @@ export function hydrateLayer(
|
||||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
: 'uploaded',
|
||||
mediaType:
|
||||
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
|
||||
? snapshot.mediaType
|
||||
: 'image',
|
||||
prompt: stringOrNull(snapshot.prompt),
|
||||
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
||||
model: stringOrNull(snapshot.model),
|
||||
@@ -292,10 +448,25 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function bpmOrNull(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const roundedValue = Math.round(value);
|
||||
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
|
||||
}
|
||||
|
||||
export function stringOrNull(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
export function stringOrUndefined(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
export function booleanFromSnapshot(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
@@ -406,7 +577,11 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
return value === 'spec' ||
|
||||
value === 'character' ||
|
||||
value === 'icon' ||
|
||||
value === 'icon-spec'
|
||||
value === 'icon-spec' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'video' ||
|
||||
value === 'sound-effect' ||
|
||||
value === 'background-music'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
@@ -425,6 +600,120 @@ export function isGeneratedLayer(layer: CanvasLayer) {
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasGenerationDialogMode(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationDialogState['mode'] {
|
||||
return (
|
||||
value === 'generate' ||
|
||||
value === 'spec' ||
|
||||
value === 'character' ||
|
||||
value === 'icon' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'video' ||
|
||||
value === 'audio-sound-effect' ||
|
||||
value === 'audio-background-music'
|
||||
);
|
||||
}
|
||||
|
||||
function isGenerationStatus(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationDialogState['status'] {
|
||||
return value === 'idle' || value === 'generating' || value === 'failed';
|
||||
}
|
||||
|
||||
function isSpecGenerationType(
|
||||
value: unknown,
|
||||
): value is NonNullable<CanvasGenerationDialogState['specType']> {
|
||||
return (
|
||||
value === 'character' ||
|
||||
value === 'ui' ||
|
||||
value === 'icon' ||
|
||||
value === 'custom'
|
||||
);
|
||||
}
|
||||
|
||||
function hydrateSpecFormValues(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['specValues'] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>;
|
||||
return {
|
||||
playSetting:
|
||||
typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '',
|
||||
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
|
||||
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
|
||||
characterView:
|
||||
typeof snapshot.characterView === 'string'
|
||||
? snapshot.characterView
|
||||
: '',
|
||||
customPrompt:
|
||||
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateCharacterReference(value: unknown): CharacterReferenceImage | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>;
|
||||
const id = stringOrNull(snapshot.id);
|
||||
const label = stringOrNull(snapshot.label);
|
||||
const src = stringOrNull(snapshot.src);
|
||||
const mediaType: CanvasMediaType =
|
||||
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
|
||||
? snapshot.mediaType
|
||||
: 'image';
|
||||
const mimeType = stringOrUndefined(snapshot.mimeType);
|
||||
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
|
||||
const durationSeconds =
|
||||
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
|
||||
const objectKey = stringOrUndefined(snapshot.objectKey);
|
||||
const assetObjectId = stringOrUndefined(snapshot.assetObjectId);
|
||||
return id && label && src
|
||||
? {
|
||||
id,
|
||||
label,
|
||||
src,
|
||||
mediaType,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
durationSeconds,
|
||||
objectKey,
|
||||
assetObjectId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function hydrateCharacterReferences(
|
||||
value: unknown,
|
||||
): CharacterReferenceImage[] | undefined {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((reference) => {
|
||||
const hydrated = hydrateCharacterReference(reference);
|
||||
return hydrated ? [hydrated] : [];
|
||||
})
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hydrateGenerationPlaceholder(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['placeholder'] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>;
|
||||
return {
|
||||
x: numberFromSnapshot(snapshot.x, 0),
|
||||
y: numberFromSnapshot(snapshot.y, 0),
|
||||
width: numberFromSnapshot(snapshot.width, 320),
|
||||
height: numberFromSnapshot(snapshot.height, 320),
|
||||
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
|
||||
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
||||
if (targetLayers.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -4,14 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
ImageCanvasMetadataModalViewProps,
|
||||
} from './ImageCanvasMetadataModalView';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataModalView';
|
||||
import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView';
|
||||
import type { ImageCanvasStageViewProps } from './ImageCanvasStageView';
|
||||
import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -115,6 +113,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
return {
|
||||
canvasViewportRef: createRef<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
musicToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
publicationToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
isPanning: false,
|
||||
effectiveTool: 'select',
|
||||
@@ -161,10 +160,13 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onDeleteSelectedLayer: vi.fn(),
|
||||
onDeleteGenerationDialog: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenEditDialog: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
onDownloadLayer: vi.fn(),
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
onCopyContextLayers: vi.fn(),
|
||||
onDuplicateContextLayers: vi.fn(),
|
||||
@@ -192,6 +194,8 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onToggleMinimap: vi.fn(),
|
||||
onMinimapPointerDown: vi.fn(),
|
||||
onSwitchTool: vi.fn(),
|
||||
onOpenToolOptions: vi.fn(),
|
||||
onCloseToolOptions: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,15 +224,15 @@ describe('ImageCanvasEditorShellView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('region', { name: '图片画布编辑器' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('region', { name: '图片画布编辑器' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('画布工作区')).toBeTruthy();
|
||||
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: '测试图片图片信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: '图片信息' })).toBeTruthy();
|
||||
|
||||
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
|
||||
const uploadInput = screen.getByLabelText(
|
||||
'上传图片文件',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(uploadInput);
|
||||
|
||||
expect(uploadInput.multiple).toBe(true);
|
||||
|
||||
@@ -18,6 +18,7 @@ type AssetDragPreview = {
|
||||
type ImageCanvasEditorShellViewProps = {
|
||||
editorRootRef: RefObject<HTMLElement | null>;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
uploadAccept?: string;
|
||||
onUploadInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||
assetDragPreview: AssetDragPreview | null;
|
||||
sidebarProps: ComponentProps<typeof ImageCanvasSidebarView>;
|
||||
@@ -29,6 +30,7 @@ type ImageCanvasEditorShellViewProps = {
|
||||
export function ImageCanvasEditorShellView({
|
||||
editorRootRef,
|
||||
uploadInputRef,
|
||||
uploadAccept = 'image/*',
|
||||
onUploadInputChange,
|
||||
assetDragPreview,
|
||||
sidebarProps,
|
||||
@@ -36,6 +38,11 @@ export function ImageCanvasEditorShellView({
|
||||
stageProps,
|
||||
metadataProps,
|
||||
}: ImageCanvasEditorShellViewProps) {
|
||||
const uploadInputLabel =
|
||||
uploadAccept.includes('video') || uploadAccept.includes('audio')
|
||||
? '上传媒体文件'
|
||||
: '上传图片文件';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={editorRootRef}
|
||||
@@ -46,9 +53,9 @@ export function ImageCanvasEditorShellView({
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={uploadAccept}
|
||||
multiple
|
||||
aria-label="上传图片文件"
|
||||
aria-label={uploadInputLabel}
|
||||
hidden
|
||||
onChange={onUploadInputChange}
|
||||
/>
|
||||
|
||||
@@ -3,16 +3,29 @@ import type {
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorVideoModel,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind = 'spec' | 'character' | 'icon' | 'icon-spec';
|
||||
export type CanvasAssetKind =
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'icon-spec'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
| 'sound-effect'
|
||||
| 'background-music';
|
||||
|
||||
export type CanvasMediaType = 'image' | 'video' | 'audio';
|
||||
|
||||
export type EditorAsset = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
width: number;
|
||||
height: number;
|
||||
folderId: string;
|
||||
@@ -52,6 +65,7 @@ export type CanvasLayer = {
|
||||
resourceId: string;
|
||||
title: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -89,13 +103,13 @@ export type CanvasTool =
|
||||
| 'hand'
|
||||
| 'upload'
|
||||
| 'generate'
|
||||
| 'video'
|
||||
| 'music'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'text'
|
||||
| 'shape'
|
||||
| 'export';
|
||||
| 'ui-design';
|
||||
|
||||
export type SidebarPanel = 'assets' | 'layers';
|
||||
|
||||
@@ -121,6 +135,12 @@ export type CharacterReferenceImage = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
};
|
||||
|
||||
export type PublicationMaterialsGameInfo = {
|
||||
@@ -136,7 +156,17 @@ export type PublicationMaterialsWorkflowId =
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon' | 'publication';
|
||||
mode:
|
||||
| 'generate'
|
||||
| 'edit'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
| 'audio-sound-effect'
|
||||
| 'audio-background-music';
|
||||
prompt: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
@@ -145,6 +175,7 @@ export type GenerateDialogState = {
|
||||
specType?: SpecGenerationType;
|
||||
specValues?: SpecFormValues;
|
||||
specReference?: CharacterReferenceImage | null;
|
||||
generationReferences?: CharacterReferenceImage[];
|
||||
characterSpecReference?: CharacterReferenceImage | null;
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
@@ -152,7 +183,18 @@ export type GenerateDialogState = {
|
||||
publicationWorkflowId?: PublicationMaterialsWorkflowId;
|
||||
publicationGameInfo?: PublicationMaterialsGameInfo;
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
videoModel?: EditorVideoModel;
|
||||
videoAspectRatio?: string;
|
||||
videoResolution?: '480p' | '720p';
|
||||
videoDurationSeconds?: 4 | 5;
|
||||
videoMode?: 'std';
|
||||
videoSound?: 'off';
|
||||
soundType?: 'one-shot' | 'loop';
|
||||
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏。
|
||||
soundTempo?: number | null;
|
||||
makeInstrumental?: boolean;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
@@ -212,6 +254,7 @@ export type CanvasContextMenuState =
|
||||
};
|
||||
|
||||
export type QuickEditPanelState = {
|
||||
mode?: 'quick-edit' | 'redraw';
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
@@ -220,6 +263,18 @@ export type QuickEditPanelState = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type CropExpandPanelState = {
|
||||
sourceLayerId: string;
|
||||
insets: {
|
||||
left: string;
|
||||
top: string;
|
||||
right: string;
|
||||
bottom: string;
|
||||
};
|
||||
status: 'idle' | 'processing' | 'failed';
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type CharacterAnimationPanelState = {
|
||||
sourceLayerId: string;
|
||||
promptText: string;
|
||||
@@ -234,10 +289,15 @@ export type CharacterAnimationPanelState = {
|
||||
|
||||
export type UploadTarget =
|
||||
| 'asset'
|
||||
| 'generation-reference'
|
||||
| 'video-reference-image'
|
||||
| 'video-reference-video'
|
||||
| 'video-reference-audio'
|
||||
| 'spec-reference'
|
||||
| 'character-spec'
|
||||
| 'character-reference'
|
||||
| 'icon-spec'
|
||||
| 'ui-design-icon-spec'
|
||||
| 'publication-reference';
|
||||
|
||||
export type SnapGuide = {
|
||||
|
||||
@@ -15,9 +15,9 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiClientError,
|
||||
AuthUiContext,
|
||||
ImageCanvasEditorView,
|
||||
createAuthValue,
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
@@ -437,8 +437,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
it('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const canvasImage = screen.getByAltText('画布图片:拼图素材');
|
||||
@@ -455,7 +454,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientY: 120,
|
||||
});
|
||||
|
||||
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
|
||||
const cropButton = screen.getByRole('button', { name: '裁扩按钮' });
|
||||
fireEvent.pointerDown(cropButton, {
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
@@ -464,7 +463,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
fireEvent.click(cropButton);
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
|
||||
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -489,7 +488,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
fireEvent.click(infoButton);
|
||||
|
||||
const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
|
||||
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
||||
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
||||
@@ -505,7 +504,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hydrates canvas images from Resolution instead of saved Size', async () => {
|
||||
@@ -547,15 +546,21 @@ describe('ImageCanvasEditorView', () => {
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
|
||||
);
|
||||
const infoPanel = screen.getByRole('dialog', {
|
||||
name: '旧布局图片图片信息',
|
||||
});
|
||||
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
||||
expect(within(infoPanel).queryByText('Size')).toBeNull();
|
||||
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes the selected layer from the floating toolbar', () => {
|
||||
it('downloads the selected layer from the floating toolbar', () => {
|
||||
let downloadName = '';
|
||||
let downloadHref = '';
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
downloadHref = this.href;
|
||||
},
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
@@ -569,9 +574,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientY: 120,
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
|
||||
expect(downloadName).toBe('拼图素材.png');
|
||||
expect(downloadHref).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -825,7 +832,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
|
||||
@@ -1237,30 +1244,23 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('switches tools and restores the previous tool after holding Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
const selectTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '选择工具',
|
||||
});
|
||||
const textTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '文字工具',
|
||||
});
|
||||
const handTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '抓手工具',
|
||||
});
|
||||
|
||||
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await user.click(textTool);
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('switches away from hand tool from the bottom toolbar', async () => {
|
||||
@@ -1271,15 +1271,15 @@ describe('ImageCanvasEditorView', () => {
|
||||
const handTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '抓手工具',
|
||||
});
|
||||
const textTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '文字工具',
|
||||
const selectTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '选择工具',
|
||||
});
|
||||
|
||||
await user.click(handTool);
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await user.click(textTool);
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
await user.click(selectTool);
|
||||
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
@@ -1357,12 +1357,12 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
const textTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '文字工具',
|
||||
const handTool = within(bottomToolbar).getByRole('button', {
|
||||
name: '抓手工具',
|
||||
});
|
||||
await user.click(textTool);
|
||||
await user.click(handTool);
|
||||
|
||||
expect(textTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(
|
||||
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
|
||||
).toBeNull();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,86 @@ export function isImageFile(file: File) {
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
export function readImageFileAsDataUrl(file: File) {
|
||||
export const SEEDANCE_REFERENCE_ACCEPT = {
|
||||
image:
|
||||
'.jpg,.jpeg,.png,.webp,.bmp,.tiff,.tif,.gif,.heic,.heif,image/jpeg,image/png,image/webp,image/bmp,image/tiff,image/gif,image/heic,image/heif',
|
||||
video: '.mp4,.mov,video/mp4,video/quicktime',
|
||||
audio: '.wav,.mp3,audio/wav,audio/x-wav,audio/mpeg,audio/mp3',
|
||||
} as const;
|
||||
|
||||
export function getEditorUploadAccept(target: string) {
|
||||
if (target === 'video-reference-video') {
|
||||
return SEEDANCE_REFERENCE_ACCEPT.video;
|
||||
}
|
||||
if (target === 'video-reference-audio') {
|
||||
return SEEDANCE_REFERENCE_ACCEPT.audio;
|
||||
}
|
||||
if (target === 'video-reference-image') {
|
||||
return SEEDANCE_REFERENCE_ACCEPT.image;
|
||||
}
|
||||
return 'image/*';
|
||||
}
|
||||
|
||||
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
|
||||
imageBytes: 30 * 1024 * 1024,
|
||||
videoBytes: 50 * 1024 * 1024,
|
||||
audioBytes: 15 * 1024 * 1024,
|
||||
mediaMinDurationSeconds: 2,
|
||||
mediaMaxDurationSeconds: 15,
|
||||
mediaTotalDurationSeconds: 15,
|
||||
} as const;
|
||||
|
||||
const SEEDANCE_IMAGE_EXTENSIONS = new Set([
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'gif',
|
||||
'heic',
|
||||
'heif',
|
||||
]);
|
||||
const SEEDANCE_VIDEO_EXTENSIONS = new Set(['mp4', 'mov']);
|
||||
const SEEDANCE_AUDIO_EXTENSIONS = new Set(['wav', 'mp3']);
|
||||
|
||||
function getFileExtension(file: File) {
|
||||
return file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
function hasAllowedExtension(file: File, extensions: Set<string>) {
|
||||
return extensions.has(getFileExtension(file));
|
||||
}
|
||||
|
||||
export function isSeedanceReferenceImageFile(file: File) {
|
||||
return (
|
||||
(file.type.startsWith('image/') || file.type === '') &&
|
||||
hasAllowedExtension(file, SEEDANCE_IMAGE_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSeedanceReferenceVideoFile(file: File) {
|
||||
return (
|
||||
(file.type === 'video/mp4' ||
|
||||
file.type === 'video/quicktime' ||
|
||||
file.type === '') &&
|
||||
hasAllowedExtension(file, SEEDANCE_VIDEO_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSeedanceReferenceAudioFile(file: File) {
|
||||
return (
|
||||
(file.type === 'audio/wav' ||
|
||||
file.type === 'audio/x-wav' ||
|
||||
file.type === 'audio/mpeg' ||
|
||||
file.type === 'audio/mp3' ||
|
||||
file.type === '') &&
|
||||
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -10,9 +89,46 @@ export function readImageFileAsDataUrl(file: File) {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
reject(new Error('图片读取失败'));
|
||||
reject(new Error('文件读取失败'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('图片读取失败'));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('文件读取失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function readImageFileAsDataUrl(file: File) {
|
||||
return readFileAsDataUrl(file);
|
||||
}
|
||||
|
||||
export function probeMediaDurationSeconds(
|
||||
file: File,
|
||||
mediaType: 'video' | 'audio',
|
||||
) {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || typeof URL === 'undefined') {
|
||||
resolve(Number.NaN);
|
||||
return;
|
||||
}
|
||||
const element =
|
||||
mediaType === 'video'
|
||||
? document.createElement('video')
|
||||
: document.createElement('audio');
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const cleanup = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
element.removeAttribute('src');
|
||||
element.load();
|
||||
};
|
||||
element.preload = 'metadata';
|
||||
element.onloadedmetadata = () => {
|
||||
const duration = element.duration;
|
||||
cleanup();
|
||||
resolve(duration);
|
||||
};
|
||||
element.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('读取媒体时长失败'));
|
||||
};
|
||||
element.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,24 +7,29 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendIconDescriptionToDialog,
|
||||
appendGenerationReference,
|
||||
assignCharacterSpecReference,
|
||||
assignIconSpecReference,
|
||||
assignUiDesignSpecReference,
|
||||
closeGenerateComposerDialog,
|
||||
createBackgroundMusicGenerationDialogDraft,
|
||||
createCharacterAnimationPanelDraft,
|
||||
createCharacterGenerationDialogDraft,
|
||||
createEditDialogDraft,
|
||||
createGenerateDialogDraft,
|
||||
createIconGenerationDialogDraft,
|
||||
createPublicationGenerationDialogDraft,
|
||||
createQuickEditPanelDraft,
|
||||
createSoundEffectGenerationDialogDraft,
|
||||
createSpecDialogDraft,
|
||||
createUiDesignGenerationDialogDraft,
|
||||
createVideoGenerationDialogDraft,
|
||||
formatIconDescriptionsText,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
parseIconDescriptionsText,
|
||||
updateCharacterAnimationDurationPanel,
|
||||
updateIconDescriptionInDialog,
|
||||
updateIconDescriptionsTextInDialog,
|
||||
updateSpecFormDialogValue,
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import { ICON_DESCRIPTION_LIMIT } from './ImageCanvasGenerationModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -115,6 +120,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
imageModel: 'unknown-model',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮',
|
||||
iconSpecReference: null,
|
||||
iconDescriptions: [
|
||||
'返回按钮',
|
||||
@@ -133,58 +139,85 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates detail gallery publication drafts in 720p portrait ratio', () => {
|
||||
const canvasSize = { width: 1000, height: 900 };
|
||||
it('creates video and UI design drafts restored from the original generation entry work', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
expect(createVideoGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'video',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [],
|
||||
videoModel: 'seedance2.0-fast',
|
||||
videoAspectRatio: '16:9',
|
||||
videoResolution: '480p',
|
||||
videoDurationSeconds: 4,
|
||||
videoMode: 'std',
|
||||
videoSound: 'off',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 202.5,
|
||||
width: 560,
|
||||
height: 315,
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createPublicationGenerationDialogDraft({
|
||||
createUiDesignGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId: 'publication-detail-gallery',
|
||||
imageModel: 'gpt-image-2',
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'publication',
|
||||
publicationWorkflowId: 'publication-detail-gallery',
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
mode: 'ui-design',
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
uiDesignSpecReference: null,
|
||||
placeholder: {
|
||||
x: 320,
|
||||
y: 130,
|
||||
width: 360,
|
||||
height: 640,
|
||||
originalWidth: 720,
|
||||
originalHeight: 1280,
|
||||
x: 200,
|
||||
y: 202.5,
|
||||
width: 560,
|
||||
height: 315,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates promo poster publication drafts in 720p landscape ratio', () => {
|
||||
const canvasSize = { width: 1000, height: 900 };
|
||||
it('creates audio generation drafts for sound effects and background music', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
expect(
|
||||
createPublicationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId: 'publication-promo-poster',
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'publication',
|
||||
publicationWorkflowId: 'publication-promo-poster',
|
||||
placeholder: {
|
||||
x: 220,
|
||||
y: 292.5,
|
||||
width: 560,
|
||||
height: 315,
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
},
|
||||
});
|
||||
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundType: 'one-shot',
|
||||
soundTempo: null,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
},
|
||||
});
|
||||
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates edit, quick-edit, and character animation panel drafts', () => {
|
||||
@@ -202,6 +235,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
sourceLayerId: 'layer-source',
|
||||
});
|
||||
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
prompt: '',
|
||||
size: '1024x768',
|
||||
@@ -269,6 +303,150 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog);
|
||||
});
|
||||
|
||||
it('routes picked canvas references to spec, image, video, and UI design fields', () => {
|
||||
const sourceLayer = createLayer({ title: '参考图' });
|
||||
const videoLayer = createLayer({
|
||||
id: 'layer-video',
|
||||
title: '动作参考',
|
||||
src: 'https://signed.example.com/reference.mp4',
|
||||
mediaType: 'video',
|
||||
objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4',
|
||||
assetObjectId: 'asset-object-video',
|
||||
});
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
{
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '失败',
|
||||
},
|
||||
sourceLayer,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
specReference: { label: '参考图' },
|
||||
});
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
{
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
},
|
||||
sourceLayer,
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: [{ label: '参考图' }],
|
||||
});
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
{
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
videoModel: 'seedance2.0-fast',
|
||||
generationReferences: [],
|
||||
},
|
||||
videoLayer,
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: [
|
||||
{
|
||||
label: '动作参考',
|
||||
mediaType: 'video',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
|
||||
assetObjectId: 'asset-object-video',
|
||||
},
|
||||
],
|
||||
});
|
||||
const imageDialog: GenerateDialogState = {
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
};
|
||||
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog);
|
||||
const specDialog: GenerateDialogState = {
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
};
|
||||
expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog);
|
||||
|
||||
const uiDialog: GenerateDialogState = {
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '失败',
|
||||
};
|
||||
expect(assignUiDesignSpecReference(uiDialog, sourceLayer)).toBe(uiDialog);
|
||||
expect(
|
||||
assignUiDesignSpecReference(
|
||||
uiDialog,
|
||||
createLayer({ title: '图标规范', assetKind: 'icon-spec' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
uiDesignSpecReference: { label: '图标规范' },
|
||||
});
|
||||
});
|
||||
|
||||
it('limits Seedance canvas-picked references by media type', () => {
|
||||
const videoDialog: GenerateDialogState = {
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
videoModel: 'seedance2.0',
|
||||
generationReferences: Array.from({ length: 3 }, (_, index) => ({
|
||||
id: `video-${index}`,
|
||||
label: `视频${index + 1}`,
|
||||
src: `generated-character-drafts/video-${index}.mp4`,
|
||||
mediaType: 'video',
|
||||
})),
|
||||
};
|
||||
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
videoDialog,
|
||||
createLayer({
|
||||
id: 'video-extra',
|
||||
title: '额外视频',
|
||||
mediaType: 'video',
|
||||
src: 'generated-character-drafts/video-extra.mp4',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: [
|
||||
{ label: '视频1' },
|
||||
{ label: '视频2' },
|
||||
{ label: '视频3' },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
videoDialog,
|
||||
createLayer({
|
||||
id: 'audio-first',
|
||||
title: '旁白',
|
||||
mediaType: 'audio',
|
||||
src: 'generated-character-drafts/audio.mp3',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: [
|
||||
{ label: '视频1' },
|
||||
{ label: '视频2' },
|
||||
{ label: '视频3' },
|
||||
{ label: '旁白', mediaType: 'audio' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates failed spec and icon dialog fields back to idle state', () => {
|
||||
const specDialog: GenerateDialogState = {
|
||||
mode: 'spec',
|
||||
@@ -305,31 +483,22 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
errorMessage: '描述错误',
|
||||
iconDescriptions: ['旧描述'],
|
||||
};
|
||||
expect(updateIconDescriptionInDialog(iconDialog, 0, '新描述')).toEqual(
|
||||
expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
iconDescriptions: ['新描述'],
|
||||
}),
|
||||
);
|
||||
expect(appendIconDescriptionToDialog(iconDialog)).toEqual(
|
||||
expect.objectContaining({
|
||||
iconDescriptions: ['旧描述', ''],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
appendIconDescriptionToDialog({
|
||||
...iconDialog,
|
||||
iconDescriptions: Array.from(
|
||||
{ length: ICON_DESCRIPTION_LIMIT },
|
||||
(_, index) => `图标${index}`,
|
||||
),
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
iconDescriptions: expect.arrayContaining(['图标0']),
|
||||
prompt: '剑\n盾、药水',
|
||||
iconDescriptions: ['剑', '盾', '药水'],
|
||||
}),
|
||||
);
|
||||
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
|
||||
'剑',
|
||||
'盾',
|
||||
'药水',
|
||||
'宝箱',
|
||||
'钥匙',
|
||||
]);
|
||||
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
|
||||
});
|
||||
|
||||
it('updates character animation duration and composer visibility', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user