角色多方向图生成

This commit is contained in:
2026-08-12 18:14:25 +08:00
parent 1575afc92c
commit 132bf7db35
16 changed files with 727 additions and 26 deletions
@@ -90,6 +90,12 @@ function CharacterGenerationHarness({
<output aria-label="当前角色设定">{dialog.prompt}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前多方向角色图">
{String(dialog.characterSheetEnabled === true)}
</output>
<output aria-label="当前多方向图视角数">
{dialog.characterSheetViewSet ?? '4'}
</output>
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
</div>
@@ -122,6 +128,64 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
);
});
it('toggles the multi-direction character sheet option', () => {
render(
<CharacterGenerationHarness
initialDialog={createDialog({ characterSheetEnabled: false })}
/>,
);
const toggle = screen.getByRole('button', { name: '多方向角色图' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(toggle);
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('true');
expect(
screen.getByRole('button', { name: '多方向角色图' }).getAttribute(
'aria-pressed',
),
).toBe('true');
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
expect(screen.getByLabelText('当前多方向角色图').textContent).toBe('false');
});
it('shows the view-set dropdown only while the sheet is enabled', () => {
render(
<CharacterGenerationHarness
initialDialog={createDialog({ characterSheetEnabled: false })}
/>,
);
expect(screen.queryByRole('button', { name: /多方向图视角/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '多方向角色图' }));
expect(
screen.getByRole('button', { name: '多方向图视角 四方向' }),
).toBeTruthy();
});
it('switches the character sheet view set between four and eight', () => {
render(
<CharacterGenerationHarness
initialDialog={createDialog({
characterSheetEnabled: true,
characterSheetViewSet: '4',
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '多方向图视角 四方向' }));
fireEvent.click(screen.getByRole('button', { name: '八方向' }));
expect(screen.getByLabelText('当前多方向图视角数').textContent).toBe('8');
expect(
screen.getByRole('button', { name: '多方向图视角 八方向' }),
).toBeTruthy();
});
it('updates character prompt, clears failed state and submits', () => {
const submitCharacter = vi.fn();
render(
@@ -1,10 +1,12 @@
import { ClipboardList, ImagePlus } from 'lucide-react';
import { ChevronDown, ClipboardList, ImagePlus, LayoutGrid } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useRef,
useState,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
@@ -12,6 +14,7 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import type {
CharacterReferenceImage,
@@ -101,6 +104,8 @@ export function ImageCanvasCharacterGenerationComposerView({
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
const [isViewSetMenuOpen, setIsViewSetMenuOpen] = useState(false);
const viewSetButtonRef = useRef<HTMLButtonElement | null>(null);
useImageCanvasFloatingOptionDismiss({
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
@@ -109,6 +114,25 @@ export function ImageCanvasCharacterGenerationComposerView({
setIsCharacterReferenceMenuOpen(false);
},
});
useImageCanvasFloatingOptionDismiss({
isOpen: isViewSetMenuOpen,
boundaryRefs: [viewSetButtonRef],
onDismiss: () => setIsViewSetMenuOpen(false),
restoreFocusRef: viewSetButtonRef,
});
const setViewSet = (value: '4' | '8') => {
setIsViewSetMenuOpen(false);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
characterSheetViewSet: value,
}
: currentDialog,
);
};
const selectedViewSetLabel =
(dialog.characterSheetViewSet ?? '4') === '8' ? '八方向' : '四方向';
return (
<div
@@ -216,6 +240,30 @@ export function ImageCanvasCharacterGenerationComposerView({
isAdd
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
/>
<ImageCanvasReferenceSlot
tone="character-sheet"
icon={<LayoutGrid className="h-4 w-4" aria-hidden="true" />}
label="多方向图"
ariaLabel="多方向角色图"
title={
dialog.characterSheetEnabled === true
? '多方向角色图(开启)'
: '多方向角色图'
}
disabled={dialog.status === 'generating'}
pressed={dialog.characterSheetEnabled === true}
onClick={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
characterSheetEnabled:
currentDialog.characterSheetEnabled !== true,
}
: currentDialog,
)
}
/>
{isCharacterReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
@@ -292,8 +340,12 @@ export function ImageCanvasCharacterGenerationComposerView({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
submitLabel={
dialog.characterSheetEnabled === true ? '生成多方向图集' : '生成'
}
submitAriaLabel={
dialog.characterSheetEnabled === true ? '生成多方向图集' : '生成'
}
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit(dialog);
@@ -301,6 +353,62 @@ export function ImageCanvasCharacterGenerationComposerView({
}}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
styleControl={
dialog.characterSheetEnabled === true ? (
<div className="image-canvas-editor__option-popover-anchor">
<PlatformInlineOptionButton
ref={viewSetButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--viewset"
aria-label={`多方向图视角 ${selectedViewSetLabel}`}
aria-expanded={isViewSetMenuOpen}
disabled={dialog.status === 'generating'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsViewSetMenuOpen((open) => !open)}
>
{selectedViewSetLabel}
</PlatformInlineOptionButton>
{isViewSetMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="多方向图视角数"
placement="top-start"
style={buildPortalMenuStyle(
viewSetButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
</span>
<div className="image-canvas-editor__option-popover-items">
{(['4', '8'] as const).map((value) => (
<button
key={value}
type="button"
className="image-canvas-editor__option-popover-choice"
aria-pressed={
(dialog.characterSheetViewSet ?? '4') ===
value
}
onClick={() => setViewSet(value)}
>
{value === '4' ? '四方向' : '八方向'}
</button>
))}
</div>
</div>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : undefined
}
/>
</div>
</div>
@@ -82,6 +82,7 @@ export type CanvasGenerationAction =
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'character-sheet.generate'
| 'icon.generate'
| 'ui-design.generate'
| 'publication.generate'
@@ -278,6 +279,11 @@ export type GenerateDialogState = {
generationReferences?: CharacterReferenceImage[];
characterSpecReference?: CharacterReferenceImage | null;
characterReferences?: CharacterReferenceImage[];
// 中文注释:角色面板的"多方向角色图"开关——开启时生成一张四方向角色图集
// (后端 kind=character-sheet,产物按 icon-spritesheet 落库,可走既有拆分图集)。
characterSheetEnabled?: boolean;
// 中文注释:多方向角色图的视角数(4 或 8),仅 characterSheetEnabled 时生效。
characterSheetViewSet?: '4' | '8';
iconSpecReference?: CharacterReferenceImage | null;
iconDescriptions?: string[];
publicationWorkflowId?: PublicationMaterialsWorkflowId;
@@ -1228,6 +1228,41 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores a multi-direction character sheet recipe into the character composer', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-sheet-result',
resourceId: 'resource-sheet-result',
sourceType: 'generated',
assetKind: 'icon-spritesheet',
generationInputs: {
version: 2,
action: 'character-sheet.generate',
fields: [
{ id: 'prompt', title: '角色设定', value: '日系魔法少女' },
{ id: 'model', title: '模型', value: 'nanobanana2' },
{ id: 'viewSet', title: '视角集合', value: '八视图' },
{ id: 'aspectRatio', title: '比例', value: '1:1' },
{ id: 'imageSize', title: '尺寸', value: '1K' },
],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'character',
prompt: '日系魔法少女',
sourceLayerId: 'layer-sheet-result',
style: 'none',
characterSheetEnabled: true,
characterSheetViewSet: '8',
});
});
it('maps legacy ui spec.generate inputs to the unified icon spec composer', () => {
expect(
createSameSourceGenerationDialogDraft({
@@ -316,6 +316,8 @@ export function createCharacterGenerationDialogDraft({
style: 'none',
characterSpecReference: null,
characterReferences: [],
characterSheetEnabled: false,
characterSheetViewSet: '4',
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
@@ -1061,6 +1063,41 @@ function createNormalizedGenerationDialogDraft({
);
}
if (inputs.action === 'character-sheet.generate') {
// 多方向角色图回弹到角色面板并恢复图集模式与视角数。
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
style: 'none',
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
characterSheetEnabled: true,
characterSheetViewSet:
getNormalizedString(sourceLayer, 'viewSet') === '八视图'
? '8'
: '4',
characterSpecReference:
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
characterReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'icon.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
@@ -17,6 +17,7 @@ export const CANVAS_GENERATION_ACTIONS = [
'scene.generate',
'spec.generate',
'character.generate',
'character-sheet.generate',
'icon.generate',
'ui-design.generate',
'publication.generate',
@@ -37,6 +38,7 @@ const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
'scene.generate',
'spec.generate',
'character.generate',
'character-sheet.generate',
'icon.generate',
'ui-design.generate',
'publication.generate',
@@ -2174,6 +2174,58 @@ export function buildCharacterGenerationInputs(
};
}
export function buildCharacterSheetGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references: CharacterReferenceImage[] | undefined,
options: {
model?: string;
aspectRatio?: string;
imageSize?: string;
viewSet?: '4' | '8';
} = {},
): CanvasGenerationInputs {
return {
version: 2,
action: 'character-sheet.generate',
fields: [
...createGenerationInputField('角色设定', prompt, 'prompt'),
...createGenerationInputField(
'模型',
options.model ?? DEFAULT_IMAGE_MODEL,
'model',
),
...createGenerationInputField(
'视角集合',
options.viewSet === '8' ? '八视图' : '四视图',
'viewSet',
),
...createGenerationInputField(
'比例',
options.aspectRatio ?? '1:1',
'aspectRatio',
),
...createGenerationInputField(
'尺寸',
options.imageSize ?? '1K',
'imageSize',
),
],
references: [
...(specReference
? createGenerationInputReference('角色规范', specReference, {
id: 'specReference',
})
: []),
...(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`常规参考图 ${index + 1}`, reference, {
id: 'reference',
}),
),
],
};
}
export function buildUiDesignGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
@@ -642,6 +642,87 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
);
});
it('builds character-sheet plans when multi-direction sheet is enabled', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'character',
prompt: '日系魔法少女',
status: 'idle',
characterSheetEnabled: true,
characterSpecReference: {
id: 'spec',
label: '角色规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-character-spec',
},
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '日系魔法少女',
input: {
prompt: '日系魔法少女',
kind: 'character-sheet',
assetKind: 'icon-spritesheet',
style: 'none',
},
result: {
assetKind: 'icon-spritesheet',
title: '角色多方向图 3',
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
expect.objectContaining({
version: 2,
action: 'character-sheet.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '日系魔法少女' },
{ id: 'viewSet', title: '视角集合', value: '四视图' },
]),
}),
);
expect(plan.kind === 'image' ? plan.input : null).toMatchObject({
characterSheetViews: 4,
});
// 图集不做像素化、不去背景,screenColor/segModel 不携带。
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
'screenColor',
);
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
'segModel',
);
});
it('builds eight-direction character-sheet plans when view set is eight', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'character',
prompt: '日系魔法少女',
status: 'idle',
characterSheetEnabled: true,
characterSheetViewSet: '8',
},
layers: [],
nextGeneratedIndex: 4,
});
expect(plan.kind === 'image' ? plan.input : null).toMatchObject({
kind: 'character-sheet',
characterSheetViews: 8,
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
expect.objectContaining({
action: 'character-sheet.generate',
fields: expect.arrayContaining([
{ id: 'viewSet', title: '视角集合', value: '八视图' },
]),
}),
);
});
it('locks UI design generation submissions to gpt-image-2', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -26,6 +26,7 @@ import type {
import {
buildBackgroundMusicGenerationInputs,
buildCharacterGenerationInputs,
buildCharacterSheetGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
@@ -800,37 +801,64 @@ export function buildImageGenerationSubmissionPlan({
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
const sheetEnabled = dialog.characterSheetEnabled === true;
const sheetViewSet = dialog.characterSheetViewSet ?? '4';
// 中文注释:多方向角色图走后端 kind=character-sheet(绿色间隔底,便于连通块拆分),
// 产物按 icon-spritesheet 落库,复用既有「拆分图集」能力;不做像素化、不去背景。
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
kind: 'character',
kind: sheetEnabled ? 'character-sheet' : 'character',
model: imageModel,
screenColor,
segModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
...(sheetEnabled ? {} : { screenColor, segModel }),
style: sheetEnabled
? 'none'
: dialog.style === 'pixelArt'
? 'pixelArt'
: 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(sheetEnabled
? {
assetKind: 'icon-spritesheet',
characterSheetViews: sheetViewSet === '8' ? 8 : 4,
}
: {}),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
assetKind: 'character',
assetKind: sheetEnabled ? 'icon-spritesheet' : 'character',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`角色形象 ${nextGeneratedIndex}`,
),
generationInputs: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
sheetEnabled
? `角色多方向图 ${nextGeneratedIndex}`
: `角色形象 ${nextGeneratedIndex}`,
),
generationInputs: sheetEnabled
? buildCharacterSheetGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
viewSet: sheetViewSet,
},
)
: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
},
rememberImageModel: imageModel,
};
@@ -11,7 +11,8 @@ type ImageCanvasReferenceSlotTone =
| 'character'
| 'quick-edit'
| 'video'
| 'audio';
| 'audio'
| 'character-sheet';
type ImageCanvasReferenceSlotProps = {
buttonRef?: RefObject<HTMLButtonElement | null>;
@@ -25,6 +26,8 @@ type ImageCanvasReferenceSlotProps = {
objectKey?: string | null;
disabled?: boolean;
isAdd?: boolean;
/** 选中态(用于模式开关类入口):置位时高亮边框并带 aria-pressed。 */
pressed?: boolean;
onClick?: () => void;
onRemove?: () => void;
removeLabel?: string;
@@ -42,6 +45,7 @@ export function ImageCanvasReferenceSlot({
objectKey,
disabled = false,
isAdd = false,
pressed,
onClick,
onRemove,
removeLabel,
@@ -54,11 +58,11 @@ export function ImageCanvasReferenceSlot({
? 'image-canvas-editor__reference-chip--has-image'
: 'image-canvas-editor__reference-chip--icon-only',
isAdd ? 'image-canvas-editor__reference-chip--upload' : null,
pressed ? 'image-canvas-editor__reference-chip--active' : null,
className,
]
.filter(Boolean)
.join(' ');
const slotContent = (
.join(' '); const slotContent = (
<>
<span className="image-canvas-editor__reference-chip-icon">
{hasPreviewImage ? (
@@ -89,6 +93,7 @@ export function ImageCanvasReferenceSlot({
className={slotClassName}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={pressed === undefined ? undefined : pressed}
title={title ?? ariaLabel}
onClick={onClick}
>
+22
View File
@@ -7226,6 +7226,12 @@ button.image-canvas-editor__reference-chip:disabled {
white-space: nowrap;
}
.image-canvas-editor__option-cluster--viewset {
width: auto;
max-width: 6.5rem;
justify-self: start;
}
.image-canvas-editor__option-popover-anchor {
position: relative;
min-width: 0;
@@ -7719,6 +7725,22 @@ button.image-canvas-editor__reference-chip:disabled {
grid-column: 1 / -1;
}
.image-canvas-editor__reference-chip--character-sheet
.image-canvas-editor__reference-chip-icon {
color: #db2777;
}
.image-canvas-editor__reference-chip--active {
border-color: #a5b4fc;
background: #eef2ff;
color: #3730a3;
}
button.image-canvas-editor__reference-chip--active:hover:not(:disabled) {
border-color: #a5b4fc;
background: #e0e7ff;
}
.image-canvas-editor__publication-composer {
grid-template-rows: auto auto auto;
}
@@ -205,6 +205,7 @@ export type EditorAssetGenerationInputs = {
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'character-sheet.generate'
| 'icon.generate'
| 'ui-design.generate'
| 'publication.generate'
@@ -329,13 +330,19 @@ export type EditorImageGenerationInput = {
prompt: string;
size?: string;
kind?:
'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material';
| 'spec'
| 'character'
| 'character-sheet'
| 'quick-edit'
| 'ui-design'
| 'publication-material';
model?: string;
screenColor?: string;
segModel?: string;
style?: EditorImageGenerationStyle;
aspectRatio?: string;
imageSize?: string;
characterSheetViews?: 4 | 8;
referenceImageSrcs?: string[];
projectId?: string | null;
assetKind?: string | null;
@@ -1194,6 +1201,9 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
...(input.style ? { style: input.style } : {}),
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
...(input.imageSize ? { imageSize: input.imageSize } : {}),
...(input.characterSheetViews
? { characterSheetViews: input.characterSheetViews }
: {}),
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),