完成图片编辑器宣发素材生成
新增宣发素材入口、工作流模型、画布生成卡片和参考图选择能力。 接入宣发素材真实生图请求,隐藏 Prompt 摘要和约束预览,并将约束作为内部 Prompt 使用。 支持游戏首图、详情五图、运营海报尺寸,其中详情五图一次生成五张 720x1280 竖图。 后端仅对 publication-material 生图扣 5 泥点,并沿用现有失败退款逻辑。 补充前后端测试和宣发素材设计文档。
This commit is contained in:
@@ -12,6 +12,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={createRef<HTMLSpanElement>()}
|
||||
publicationToolWrapRef={createRef<HTMLSpanElement>()}
|
||||
effectiveTool="generate"
|
||||
onSwitchTool={switchTool}
|
||||
/>,
|
||||
@@ -32,10 +33,12 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
|
||||
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, 'text');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(4, 'text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Hand,
|
||||
ImageIcon,
|
||||
ImagePlus,
|
||||
Megaphone,
|
||||
MousePointer2,
|
||||
Shapes,
|
||||
Sparkles,
|
||||
@@ -17,6 +18,7 @@ import type { CanvasTool } from './ImageCanvasEditorTypes';
|
||||
|
||||
type ImageCanvasBottomToolbarViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
effectiveTool: CanvasTool;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
};
|
||||
@@ -33,6 +35,7 @@ const canvasTools: Array<{
|
||||
{ id: 'spec', label: '生成规范', icon: ClipboardList },
|
||||
{ id: 'character', label: '生成角色形象', icon: Sparkles },
|
||||
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
|
||||
{ id: 'publication', label: '宣发素材', icon: Megaphone },
|
||||
{ id: 'text', label: '文字工具', icon: Type },
|
||||
{ id: 'shape', label: '形状标注工具', icon: Shapes },
|
||||
{ id: 'export', label: '导出工具', icon: Download },
|
||||
@@ -40,6 +43,7 @@ const canvasTools: Array<{
|
||||
|
||||
export function ImageCanvasBottomToolbarView({
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
effectiveTool,
|
||||
onSwitchTool,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
@@ -65,6 +69,20 @@ export function ImageCanvasBottomToolbarView({
|
||||
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}
|
||||
|
||||
@@ -115,6 +115,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
return {
|
||||
canvasViewportRef: createRef<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
publicationToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
isPanning: false,
|
||||
effectiveTool: 'select',
|
||||
canvasBackgroundColor: '#f8fafc',
|
||||
|
||||
@@ -92,6 +92,7 @@ export type CanvasTool =
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'text'
|
||||
| 'shape'
|
||||
| 'export';
|
||||
@@ -122,9 +123,20 @@ export type CharacterReferenceImage = {
|
||||
src: string;
|
||||
};
|
||||
|
||||
export type PublicationMaterialsGameInfo = {
|
||||
gameName: string;
|
||||
gameCategories: string;
|
||||
gameDescription: string;
|
||||
};
|
||||
|
||||
export type PublicationMaterialsWorkflowId =
|
||||
| 'publication-cover-image'
|
||||
| 'publication-detail-gallery'
|
||||
| 'publication-promo-poster';
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon';
|
||||
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon' | 'publication';
|
||||
prompt: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
@@ -137,6 +149,9 @@ export type GenerateDialogState = {
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
iconDescriptions?: string[];
|
||||
publicationWorkflowId?: PublicationMaterialsWorkflowId;
|
||||
publicationGameInfo?: PublicationMaterialsGameInfo;
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
imageModel?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
@@ -222,7 +237,8 @@ export type UploadTarget =
|
||||
| 'spec-reference'
|
||||
| 'character-spec'
|
||||
| 'character-reference'
|
||||
| 'icon-spec';
|
||||
| 'icon-spec'
|
||||
| 'publication-reference';
|
||||
|
||||
export type SnapGuide = {
|
||||
vertical?: number;
|
||||
|
||||
@@ -47,6 +47,8 @@ export function ImageCanvasEditorView() {
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -352,6 +354,8 @@ export function ImageCanvasEditorView() {
|
||||
viewport,
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
publicationReferenceButtonRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
@@ -378,6 +382,8 @@ export function ImageCanvasEditorView() {
|
||||
setIsSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
@@ -385,12 +391,15 @@ export function ImageCanvasEditorView() {
|
||||
setIsIconSpecMenuOpen,
|
||||
isPickingIconSpecFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
openCharacterAnimationPanel,
|
||||
openEditDialog,
|
||||
openQuickEditPanel,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
clearDeletedLayerGenerationState,
|
||||
generationComposerStyle,
|
||||
@@ -484,10 +493,12 @@ export function ImageCanvasEditorView() {
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
clearCanvasFocus,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
moveViewportFromMinimapPointer,
|
||||
@@ -516,10 +527,13 @@ export function ImageCanvasEditorView() {
|
||||
setIsSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
});
|
||||
@@ -672,6 +686,7 @@ export function ImageCanvasEditorView() {
|
||||
const stageProps = {
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
|
||||
@@ -16,6 +16,11 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
|
||||
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
||||
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
|
||||
import {
|
||||
getPublicationMaterialsWorkflow,
|
||||
PUBLICATION_MATERIALS_WORKFLOWS,
|
||||
} from './ImageCanvasPublicationMaterialsModel';
|
||||
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
|
||||
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
|
||||
import { SPEC_TYPE_LABEL } from './ImageCanvasGenerationModel';
|
||||
@@ -31,6 +36,8 @@ import type {
|
||||
|
||||
type ImageCanvasGenerationComposerViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
@@ -38,9 +45,12 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
isCharacterSpecMenuOpen: boolean;
|
||||
isCharacterReferenceMenuOpen: boolean;
|
||||
isIconSpecMenuOpen: boolean;
|
||||
isPublicationMenuOpen: boolean;
|
||||
isPublicationReferenceMenuOpen: boolean;
|
||||
isPickingCharacterSpecFromCanvas: boolean;
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
isPickingPublicationReferenceFromCanvas: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
iconComposerStyle: CSSProperties | null;
|
||||
@@ -61,10 +71,18 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPublicationMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<
|
||||
SetStateAction<boolean>
|
||||
>;
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onOpenPublicationWorkflow: (
|
||||
workflowId: NonNullable<GenerateDialogState['publicationWorkflowId']>,
|
||||
) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
|
||||
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
|
||||
@@ -118,6 +136,8 @@ function renderEditorPortal(node: ReactNode) {
|
||||
|
||||
export function ImageCanvasGenerationComposerView({
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
publicationReferenceButtonRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
@@ -125,9 +145,12 @@ export function ImageCanvasGenerationComposerView({
|
||||
isCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
isIconSpecMenuOpen,
|
||||
isPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
generateDialog,
|
||||
generationComposerStyle,
|
||||
iconComposerStyle,
|
||||
@@ -146,10 +169,14 @@ export function ImageCanvasGenerationComposerView({
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
onOpenSpecDialog,
|
||||
onOpenPublicationWorkflow,
|
||||
onRequestUpload,
|
||||
onSubmitImageGeneration,
|
||||
onSubmitIconSpritesheetGeneration,
|
||||
@@ -185,6 +212,30 @@ export function ImageCanvasGenerationComposerView({
|
||||
)
|
||||
: null}
|
||||
|
||||
{isPublicationMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__publication-menu image-canvas-editor__portal-menu"
|
||||
label="宣发素材类型"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
publicationToolWrapRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
{PUBLICATION_MATERIALS_WORKFLOWS.map((workflow) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={workflow.id}
|
||||
className="image-canvas-editor__publication-menu-item"
|
||||
onClick={() => onOpenPublicationWorkflow(workflow.id)}
|
||||
>
|
||||
{workflow.label}
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
|
||||
{generateDialog?.mode === 'generate' &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -198,6 +249,31 @@ export function ImageCanvasGenerationComposerView({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{generateDialog?.mode === 'publication' &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
<ImageCanvasPublicationMaterialsDemoPanelView
|
||||
dialog={generateDialog}
|
||||
workflow={getPublicationMaterialsWorkflow(
|
||||
generateDialog.publicationWorkflowId ?? 'publication-cover-image',
|
||||
)}
|
||||
style={generationComposerStyle}
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{generateDialog?.mode === 'spec' &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -273,6 +349,11 @@ export function ImageCanvasGenerationComposerView({
|
||||
请选择画布中的图标素材规范,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingPublicationReferenceFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图片作为宣发素材参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickEditPanel &&
|
||||
quickEditPanel.status !== 'generating' &&
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createEditDialogDraft,
|
||||
createGenerateDialogDraft,
|
||||
createIconGenerationDialogDraft,
|
||||
createPublicationGenerationDialogDraft,
|
||||
createQuickEditPanelDraft,
|
||||
createSpecDialogDraft,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
@@ -132,6 +133,60 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates detail gallery publication drafts in 720p portrait ratio', () => {
|
||||
const canvasSize = { width: 1000, height: 900 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
expect(
|
||||
createPublicationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId: 'publication-detail-gallery',
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'publication',
|
||||
publicationWorkflowId: 'publication-detail-gallery',
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
placeholder: {
|
||||
x: 320,
|
||||
y: 130,
|
||||
width: 360,
|
||||
height: 640,
|
||||
originalWidth: 720,
|
||||
originalHeight: 1280,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates promo poster publication drafts in 720p landscape ratio', () => {
|
||||
const canvasSize = { width: 1000, height: 900 };
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates edit, quick-edit, and character animation panel drafts', () => {
|
||||
const sourceLayer = createLayer({
|
||||
prompt: '原图提示',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CanvasViewport,
|
||||
CharacterAnimationPanelState,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
QuickEditPanelState,
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
@@ -16,11 +17,14 @@ import {
|
||||
createCanvasLayerReference,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_PUBLICATION_GAME_INFO,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
PUBLICATION_FRAME_DISPLAY_SIZE,
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
SPEC_FRAME_DISPLAY_SIZE,
|
||||
SPEC_FRAME_ORIGINAL_SIZE,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -180,6 +184,37 @@ export function createIconGenerationDialogDraft({
|
||||
};
|
||||
}
|
||||
|
||||
export function createPublicationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId,
|
||||
}: {
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
workflowId: PublicationMaterialsWorkflowId;
|
||||
}): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const frameDisplaySize = PUBLICATION_FRAME_DISPLAY_SIZE[workflowId];
|
||||
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
|
||||
return {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
|
||||
publicationReferences: [],
|
||||
placeholder: {
|
||||
x: worldCenter.x - frameDisplaySize.width / 2,
|
||||
y: worldCenter.y - frameDisplaySize.height / 2,
|
||||
width: frameDisplaySize.width,
|
||||
height: frameDisplaySize.height,
|
||||
originalWidth: frameOriginalSize.width,
|
||||
originalHeight: frameOriginalSize.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEditDialogDraft(
|
||||
sourceLayer: CanvasLayer,
|
||||
): GenerateDialogState {
|
||||
@@ -255,6 +290,22 @@ export function appendCharacterReference(
|
||||
: dialog;
|
||||
}
|
||||
|
||||
export function appendPublicationReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
): GenerateDialogState | null {
|
||||
return dialog?.mode === 'publication'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
|
||||
export function assignIconSpecReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
@@ -348,7 +399,8 @@ export function hideGeneratedLayerComposerAfterBlur(
|
||||
return (dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'spec' ||
|
||||
dialog?.mode === 'character' ||
|
||||
dialog?.mode === 'icon') &&
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'publication') &&
|
||||
dialog.status !== 'generating'
|
||||
? {
|
||||
...dialog,
|
||||
@@ -360,7 +412,7 @@ export function hideGeneratedLayerComposerAfterBlur(
|
||||
export function closeGenerateComposerDialog(
|
||||
dialog: GenerateDialogState | null,
|
||||
): GenerateDialogState | null {
|
||||
return dialog?.mode === 'generate'
|
||||
return dialog?.mode === 'generate' || dialog?.mode === 'publication'
|
||||
? {
|
||||
...dialog,
|
||||
composerOpen: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
EditorImageGenerationItemResult,
|
||||
EditorIconSpritesheetGenerationResult,
|
||||
EditorIconSpritesheetIconResult,
|
||||
EditorImageGenerationResult,
|
||||
@@ -15,7 +16,7 @@ import type {
|
||||
type CanvasSize = { width: number; height: number };
|
||||
|
||||
type GeneratedResultLayerOptions = {
|
||||
generated: EditorImageGenerationResult;
|
||||
generated: EditorImageGenerationItemResult;
|
||||
generatedIndex: number;
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildEditGenerationInputs,
|
||||
buildIconGenerationInputs,
|
||||
buildImageGenerationInputs,
|
||||
buildPublicationMaterialsGenerationInputs,
|
||||
buildPublicationMaterialsPrompt,
|
||||
buildQuickEditModelOptions,
|
||||
buildSpecGenerationInputs,
|
||||
buildSpecPrompt,
|
||||
@@ -156,6 +158,39 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('builds publication material prompts from structured game fields', () => {
|
||||
const gameInfo = {
|
||||
gameName: ' 重庆洪崖洞火锅 ',
|
||||
gameCategories: '抓大鹅、休闲、治愈、手绘风',
|
||||
gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
};
|
||||
|
||||
expect(buildPublicationMaterialsPrompt(gameInfo)).toBe(
|
||||
[
|
||||
'游戏名:重庆洪崖洞火锅',
|
||||
'游戏分类:抓大鹅、休闲、治愈、手绘风',
|
||||
'一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(
|
||||
buildPublicationMaterialsGenerationInputs(gameInfo, [
|
||||
{ id: 'ref-a', label: '参考图', src: '/ref.png' },
|
||||
]),
|
||||
).toEqual({
|
||||
fields: [
|
||||
{ title: '游戏名', value: '重庆洪崖洞火锅' },
|
||||
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
|
||||
{
|
||||
title: '一句话描述游戏',
|
||||
value: '在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{ title: '宣发参考图 1', label: '参考图', src: '/ref.png' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
|
||||
const iconDialog: CanvasGenerationDialogState = {
|
||||
id: 'dialog-icon',
|
||||
@@ -166,6 +201,14 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
|
||||
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
|
||||
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
|
||||
expect(
|
||||
getGenerationFrameLabel({
|
||||
...iconDialog,
|
||||
id: 'dialog-publication',
|
||||
mode: 'publication',
|
||||
publicationWorkflowId: 'publication-cover-image',
|
||||
}),
|
||||
).toBe('Home Image Generator');
|
||||
expect(
|
||||
resolveImageGenerationErrorMessage(
|
||||
new ApiClientError({
|
||||
|
||||
@@ -10,10 +10,16 @@ import type {
|
||||
CanvasLayer,
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsGameInfo,
|
||||
PublicationMaterialsWorkflowId,
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isGeneratedLayer } from './ImageCanvasEditorModel';
|
||||
import {
|
||||
getPublicationMaterialsWorkflow,
|
||||
type PublicationMaterialsWorkflow,
|
||||
} from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
export const SPEC_GENERATION_COST = 5;
|
||||
export const SPEC_GENERATION_SIZE = '2048x1152';
|
||||
@@ -23,6 +29,22 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
|
||||
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
|
||||
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
|
||||
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
|
||||
export const PUBLICATION_FRAME_ORIGINAL_SIZE: Record<
|
||||
PublicationMaterialsWorkflowId,
|
||||
{ width: number; height: number }
|
||||
> = {
|
||||
'publication-cover-image': { width: 720, height: 540 },
|
||||
'publication-detail-gallery': { width: 720, height: 1280 },
|
||||
'publication-promo-poster': { width: 1280, height: 720 },
|
||||
};
|
||||
export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
|
||||
PublicationMaterialsWorkflowId,
|
||||
{ width: number; height: number }
|
||||
> = {
|
||||
'publication-cover-image': { width: 480, height: 360 },
|
||||
'publication-detail-gallery': { width: 360, height: 640 },
|
||||
'publication-promo-poster': { width: 560, height: 315 },
|
||||
};
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
@@ -31,6 +53,11 @@ export const ICON_DESCRIPTION_LIMIT = 100;
|
||||
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
|
||||
export const ICON_COMPOSER_MIN_WIDTH_REM = 28;
|
||||
export const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4;
|
||||
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
};
|
||||
export const DEFAULT_ICON_DESCRIPTIONS = [
|
||||
'返回按钮',
|
||||
'设置按钮',
|
||||
@@ -347,6 +374,71 @@ export function buildIconGenerationInputs(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePublicationGameInfo(
|
||||
gameInfo: PublicationMaterialsGameInfo | null | undefined,
|
||||
): PublicationMaterialsGameInfo {
|
||||
return {
|
||||
gameName: gameInfo?.gameName?.trim() ?? '',
|
||||
gameCategories: gameInfo?.gameCategories?.trim() ?? '',
|
||||
gameDescription: gameInfo?.gameDescription?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPublicationMaterialsPrompt(
|
||||
gameInfo: PublicationMaterialsGameInfo | null | undefined,
|
||||
) {
|
||||
const normalizedGameInfo = normalizePublicationGameInfo(gameInfo);
|
||||
return [
|
||||
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
|
||||
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
|
||||
...createGenerationInputField(
|
||||
'一句话描述游戏',
|
||||
normalizedGameInfo.gameDescription,
|
||||
),
|
||||
]
|
||||
.map((field) => `${field.title}:${field.value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildPublicationMaterialsGenerationPrompt({
|
||||
gameInfo,
|
||||
workflow,
|
||||
}: {
|
||||
gameInfo: PublicationMaterialsGameInfo | null | undefined;
|
||||
workflow: PublicationMaterialsWorkflow;
|
||||
}) {
|
||||
const inputPrompt = buildPublicationMaterialsPrompt(gameInfo);
|
||||
return [
|
||||
`【宣发素材类型】${workflow.label}`,
|
||||
inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。',
|
||||
`【生图约束】\n${workflow.promptConstraints.join('\n')}`,
|
||||
`【参考图约束】\n${workflow.referenceConstraints.join('\n')}`,
|
||||
`【输出检查】\n${workflow.postProcessConstraints.join('\n')}`,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function buildPublicationMaterialsGenerationInputs(
|
||||
gameInfo: PublicationMaterialsGameInfo | null | undefined,
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
): CanvasGenerationInputs {
|
||||
const normalizedGameInfo = normalizePublicationGameInfo(gameInfo);
|
||||
return {
|
||||
fields: [
|
||||
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
|
||||
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
|
||||
...createGenerationInputField(
|
||||
'一句话描述游戏',
|
||||
normalizedGameInfo.gameDescription,
|
||||
),
|
||||
],
|
||||
references: (references ?? []).map((reference, index) => ({
|
||||
title: `宣发参考图 ${index + 1}`,
|
||||
label: reference.label,
|
||||
src: reference.src,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditGenerationInputs(
|
||||
title: '修改要求' | '快速编辑提示词',
|
||||
prompt: string,
|
||||
@@ -372,7 +464,8 @@ export function isCanvasGenerationDialog(
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon'),
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,6 +481,9 @@ export function getGenerationFrameAriaLabel(
|
||||
if (dialog.mode === 'icon') {
|
||||
return '图标素材生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'publication') {
|
||||
return '宣发素材生成占位图';
|
||||
}
|
||||
return '图像生成占位图';
|
||||
}
|
||||
|
||||
@@ -401,6 +497,11 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
|
||||
if (dialog.mode === 'icon') {
|
||||
return 'Icon Generator';
|
||||
}
|
||||
if (dialog.mode === 'publication') {
|
||||
return `${getPublicationMaterialsWorkflow(
|
||||
dialog.publicationWorkflowId ?? 'publication-cover-image',
|
||||
).englishName} Generator`;
|
||||
}
|
||||
return 'Image Generator';
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,136 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds publication material plans with structured game fields', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
publicationGameInfo: {
|
||||
gameName: ' 重庆洪崖洞火锅 ',
|
||||
gameCategories: '抓大鹅、休闲、治愈、手绘风',
|
||||
gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
},
|
||||
publicationReferences: [
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '参考图',
|
||||
src: 'data:image/png;base64,ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 10,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
kind: 'image',
|
||||
normalizedPrompt: [
|
||||
'游戏名:重庆洪崖洞火锅',
|
||||
'游戏分类:抓大鹅、休闲、治愈、手绘风',
|
||||
'一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
].join('\n'),
|
||||
input: {
|
||||
prompt: expect.stringContaining('【宣发素材类型】游戏首图'),
|
||||
size: '720x540',
|
||||
kind: 'publication-material',
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
},
|
||||
result: {
|
||||
title: '10 宣发素材',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '游戏名', value: '重庆洪崖洞火锅' },
|
||||
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
|
||||
{
|
||||
title: '一句话描述游戏',
|
||||
value: '在一堆物品里点击,找到三个一样的物品将其消除',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '宣发参考图 1',
|
||||
label: '参考图',
|
||||
src: 'data:image/png;base64,ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
|
||||
expect(prompt).toContain('游戏名:重庆洪崖洞火锅');
|
||||
expect(prompt).toContain('标题文字必须直接进入画面');
|
||||
expect(prompt).toContain('输出检查:720 x 540,4:3');
|
||||
});
|
||||
|
||||
it('builds detail gallery publication plans as 720p portrait images', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
publicationWorkflowId: 'publication-detail-gallery',
|
||||
publicationGameInfo: {
|
||||
gameName: '云朵消消乐',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 11,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'image',
|
||||
input: {
|
||||
prompt: expect.stringContaining('【宣发素材类型】详情五图'),
|
||||
size: '720x1280',
|
||||
kind: 'publication-material',
|
||||
candidateCount: 5,
|
||||
},
|
||||
result: {
|
||||
title: '11 宣发素材',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'五张均为 720 x 1280 竖版',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds promo poster publication plans as 720p landscape images', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
publicationWorkflowId: 'publication-promo-poster',
|
||||
publicationGameInfo: {
|
||||
gameName: '火锅节活动',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 12,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'image',
|
||||
input: {
|
||||
prompt: expect.stringContaining('【宣发素材类型】运营海报'),
|
||||
size: '1280x720',
|
||||
kind: 'publication-material',
|
||||
},
|
||||
result: {
|
||||
title: '12 宣发素材',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'16:9 横版 720p,建议 1280 x 720',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when edit source layer is missing', () => {
|
||||
expect(() =>
|
||||
buildImageGenerationSubmissionPlan({
|
||||
|
||||
@@ -20,11 +20,15 @@ import {
|
||||
buildCharacterGenerationInputs,
|
||||
buildEditGenerationInputs,
|
||||
buildImageGenerationInputs,
|
||||
buildPublicationMaterialsGenerationInputs,
|
||||
buildPublicationMaterialsGenerationPrompt,
|
||||
buildPublicationMaterialsPrompt,
|
||||
buildSpecGenerationInputs,
|
||||
buildSpecPrompt,
|
||||
calculateCharacterAnimationPrice,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
type ImageGenerationSubmissionOptions = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -156,6 +160,45 @@ export function buildImageGenerationSubmissionPlan({
|
||||
};
|
||||
}
|
||||
|
||||
if (dialog.mode === 'publication') {
|
||||
const workflow = getPublicationMaterialsWorkflow(
|
||||
dialog.publicationWorkflowId ?? 'publication-cover-image',
|
||||
);
|
||||
const publicationPrompt =
|
||||
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
|
||||
normalizedPrompt;
|
||||
const generationPrompt = buildPublicationMaterialsGenerationPrompt({
|
||||
gameInfo: dialog.publicationGameInfo,
|
||||
workflow,
|
||||
});
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt: publicationPrompt,
|
||||
input: {
|
||||
prompt: generationPrompt,
|
||||
size: workflow.outputSize,
|
||||
kind: 'publication-material',
|
||||
...(workflow.id === 'publication-detail-gallery'
|
||||
? { candidateCount: 5 }
|
||||
: {}),
|
||||
...(dialog.publicationReferences?.length
|
||||
? {
|
||||
referenceImageSrcs: dialog.publicationReferences.map(
|
||||
(reference) => reference.src,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
result: {
|
||||
title: `${nextGeneratedIndex} 宣发素材`,
|
||||
generationInputs: buildPublicationMaterialsGenerationInputs(
|
||||
dialog.publicationGameInfo,
|
||||
dialog.publicationReferences,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import type { GenerateDialogState, UploadTarget } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
|
||||
function createIconDialog(
|
||||
@@ -35,7 +35,7 @@ function IconComposerHarness({
|
||||
initialDialog: GenerateDialogState;
|
||||
initialMenuOpen?: boolean;
|
||||
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
|
||||
onRequestUpload?: (target: 'asset' | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec') => void;
|
||||
onRequestUpload?: (target: UploadTarget) => void;
|
||||
onUpdateIconDescription?: (index: number, value: string) => void;
|
||||
onAddIconDescription?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
|
||||
@@ -183,6 +183,7 @@ export function isCanvasGenerationComposerVisible(
|
||||
dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'spec' ||
|
||||
dialog?.mode === 'character' ||
|
||||
dialog?.mode === 'icon'
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'publication'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
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 type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsGameInfo,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
buildPublicationMaterialsPrompt,
|
||||
DEFAULT_PUBLICATION_GAME_INFO,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
workflow: PublicationMaterialsWorkflow;
|
||||
style: CSSProperties;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
isPublicationReferenceMenuOpen: boolean;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
renderEditorPortal: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle: (
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
||||
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePublicationGameInfoField(
|
||||
currentDialog: GenerateDialogState | null,
|
||||
key: keyof PublicationMaterialsGameInfo,
|
||||
value: string,
|
||||
): GenerateDialogState | null {
|
||||
if (currentDialog?.mode !== 'publication') {
|
||||
return currentDialog;
|
||||
}
|
||||
const publicationGameInfo = {
|
||||
...DEFAULT_PUBLICATION_GAME_INFO,
|
||||
...currentDialog.publicationGameInfo,
|
||||
[key]: value,
|
||||
};
|
||||
return {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
publicationGameInfo,
|
||||
prompt: buildPublicationMaterialsPrompt(publicationGameInfo),
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceThumb({
|
||||
reference,
|
||||
index,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="image-canvas-editor__publication-ref-thumb"
|
||||
title={reference.label}
|
||||
>
|
||||
<img src={reference.src} alt={reference.label} />
|
||||
<span>{index + 1}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
dialog,
|
||||
workflow,
|
||||
style,
|
||||
publicationReferenceButtonRef,
|
||||
isPublicationReferenceMenuOpen,
|
||||
setGenerateDialog,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
renderEditorPortal,
|
||||
buildPortalMenuStyle,
|
||||
onRequestUpload,
|
||||
onSubmit,
|
||||
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
|
||||
const references = dialog.publicationReferences ?? [];
|
||||
const publicationGameInfo = {
|
||||
...DEFAULT_PUBLICATION_GAME_INFO,
|
||||
...dialog.publicationGameInfo,
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="image-canvas-editor__publication-composer"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label={`${workflow.label}生成卡片`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(dialog);
|
||||
}}
|
||||
>
|
||||
<header className="image-canvas-editor__publication-composer-header">
|
||||
<div>
|
||||
<span>{workflow.outputLabel}</span>
|
||||
<h2>{workflow.englishName}</h2>
|
||||
</div>
|
||||
<strong>{workflow.sizeLabel}</strong>
|
||||
</header>
|
||||
|
||||
<div className="image-canvas-editor__publication-composer-fields">
|
||||
<label>
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
游戏名
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
aria-label={`${workflow.label}游戏名`}
|
||||
value={publicationGameInfo.gameName}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="重庆洪崖洞火锅"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__publication-input"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
updatePublicationGameInfoField(
|
||||
currentDialog,
|
||||
'gameName',
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
游戏分类
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
aria-label={`${workflow.label}游戏分类`}
|
||||
value={publicationGameInfo.gameCategories}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="抓大鹅、休闲、治愈、手绘风"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__publication-input"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
updatePublicationGameInfoField(
|
||||
currentDialog,
|
||||
'gameCategories',
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
一句话描述游戏
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label={`${workflow.label}一句话描述游戏`}
|
||||
value={publicationGameInfo.gameDescription}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="在一堆物品里点击,找到三个一样的物品将其消除"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__publication-description"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
updatePublicationGameInfoField(
|
||||
currentDialog,
|
||||
'gameDescription',
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
参考图
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__publication-reference-list">
|
||||
{references.map((reference, index) => (
|
||||
<ReferenceThumb
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
ref={publicationReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__publication-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsPublicationReferenceMenuOpen((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>
|
||||
</div>
|
||||
{isPublicationReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__publication-reference-menu image-canvas-editor__portal-menu"
|
||||
label="宣发素材参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
publicationReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingPublicationReferenceFromCanvas(true);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
onRequestUpload('publication-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<footer className="image-canvas-editor__publication-composer-footer">
|
||||
<span>{workflow.scenario}</span>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '消耗5泥点 · 生成'}
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes';
|
||||
|
||||
export type PublicationMaterialsWorkflow = {
|
||||
id: PublicationMaterialsWorkflowId;
|
||||
label: string;
|
||||
englishName: string;
|
||||
outputLabel: string;
|
||||
sizeLabel: string;
|
||||
outputSize: string;
|
||||
scenario: string;
|
||||
fields: string[];
|
||||
framework: string[];
|
||||
promptConstraints: string[];
|
||||
referenceConstraints: string[];
|
||||
postProcessConstraints: string[];
|
||||
};
|
||||
|
||||
export const PUBLICATION_MATERIALS_WORKFLOWS: PublicationMaterialsWorkflow[] = [
|
||||
{
|
||||
id: 'publication-cover-image',
|
||||
label: '游戏首图',
|
||||
englishName: 'Home Image',
|
||||
outputLabel: '单张主视觉',
|
||||
sizeLabel: '720 x 540',
|
||||
outputSize: '720x540',
|
||||
scenario: '推荐流、作品卡和首屏传播位',
|
||||
fields: ['游戏名称', '玩法分类标签', '一句话玩法', '主视觉关键词', '必要文字'],
|
||||
framework: [
|
||||
'读取游戏基础信息和现有参考图',
|
||||
'抽取核心玩法动作、主体物和城市/题材符号',
|
||||
'生成一张带标题文字的横版主视觉',
|
||||
'按首图尺寸、文字可读性和主体完整度验收',
|
||||
],
|
||||
promptConstraints: [
|
||||
'标题文字必须直接进入画面,优先使用游戏名称;标题应为清晰中文大字,少字、醒目、可读',
|
||||
'画面只表达一个强主题,不拼贴多张小图,不做详情页合集,不做截图说明页',
|
||||
'主体、玩法道具和背景层级必须清楚;核心主体适合小尺寸推荐卡,缩小后仍能一眼识别',
|
||||
'标题与主体不能互相遮挡;主体不裁切,标题不贴边,四周保留安全边距',
|
||||
'避免暗黑、脏污、低清、廉价拼贴、文字乱码、伪文字、英文标题、非中文标题、错误游戏名',
|
||||
],
|
||||
referenceConstraints: [
|
||||
'参考图只用于提取视觉约束:主体、玩法元素、色彩气质、画风、构图方向;不得照搬水印、边框、无关 UI、平台外壳或隐私信息',
|
||||
],
|
||||
postProcessConstraints: [
|
||||
'输出检查:720 x 540,4:3;主体完整不裁切;标题清晰可读;画面只有一个主视觉重点',
|
||||
'输出 metadata 记录完整提示词、尺寸和参考图摘要',
|
||||
'不做真实发布、不写作品数据,只作为画布素材候选',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'publication-detail-gallery',
|
||||
label: '详情五图',
|
||||
englishName: 'Detail Gallery',
|
||||
outputLabel: '五张详情图',
|
||||
sizeLabel: '720p / 9:16',
|
||||
outputSize: '720x1280',
|
||||
scenario: '作品详情、应用介绍和运营分发图组',
|
||||
fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'],
|
||||
framework: [
|
||||
'将玩法拆成 5 个可视化卖点',
|
||||
'每张图独立生成,统一标题样式和美术风格',
|
||||
'覆盖玩法说明、操作反馈、关卡目标和情绪价值',
|
||||
'逐张验收文字、构图、主体和图组一致性',
|
||||
],
|
||||
promptConstraints: [
|
||||
'每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句',
|
||||
'五张图保持同一游戏名、同一主体物件体系、同一画风、同一色彩气质,像同一套游戏详情物料',
|
||||
'五张图可以独立生成,允许顺序差异,不强依赖首图作为上游输入;但同一批次必须复用同一份参考摘要,保证一致性',
|
||||
'每张图构图要有差异:避免五张重复同一角度、同一主体姿势、同一背景',
|
||||
'短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、UI 挡主体',
|
||||
],
|
||||
referenceConstraints: [
|
||||
'从参考图解析并统一:画风、主体物件、玩法道具、背景符号、地域/场景元素、色板和光影氛围',
|
||||
'参考图只作为视觉理解来源,不照搬截图 UI、水印、边框、按钮、状态栏、弹窗或无关文字',
|
||||
],
|
||||
postProcessConstraints: [
|
||||
'输出检查:五张均为 720 x 1280 竖版;每张一个卖点;标题不截断、不乱码;五张整体风格一致',
|
||||
'metadata 分别记录每张图的卖点、提示词和序号',
|
||||
'标记低一致性图片,供后续局部重生成或替换',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'publication-promo-poster',
|
||||
label: '运营海报',
|
||||
englishName: 'Promo Poster',
|
||||
outputLabel: '单张活动海报',
|
||||
sizeLabel: '1280 x 720',
|
||||
outputSize: '1280x720',
|
||||
scenario: '社群、活动、渠道投放和运营节日位',
|
||||
fields: ['游戏名称', '活动主题', '玩法标签', '主文案', '行动指令'],
|
||||
framework: [
|
||||
'确认运营主题和投放场景',
|
||||
'抽取游戏核心视觉符号和活动情绪',
|
||||
'生成带主标题、卖点和行动指令的海报',
|
||||
'按传播可读性、品牌一致性和尺寸验收',
|
||||
],
|
||||
promptConstraints: [
|
||||
'主标题、游戏名和行动指令必须清晰入画;三者要有明确层级,不能互相抢占',
|
||||
'运营氛围可以更强,包括活动感、福利感、节日感、更新感,但不能脱离游戏玩法、主体物件和美术资产',
|
||||
'版式必须预留二维码或平台标识位置,但不得生成假二维码、假平台 Logo、假按钮或不可识别图标',
|
||||
'活动主题优先级高于普通详情介绍,但不得覆盖游戏识别;用户仍需一眼知道这是哪款游戏、什么玩法',
|
||||
'避免夸张承诺、敏感营销词、虚假福利、价格折扣、具体日期、不可读小字、文字乱码、伪文字',
|
||||
],
|
||||
referenceConstraints: [
|
||||
'参考图用于锁定角色/主体物件、玩法道具、地域/场景符号、色彩气质和画风,不照搬参考图中的 UI、水印、边框或无关文字',
|
||||
],
|
||||
postProcessConstraints: [
|
||||
'输出检查:16:9 横版 720p,建议 1280 x 720;主标题最大,游戏名清楚,行动指令可读;二维码/平台标识区留白干净',
|
||||
'metadata 记录活动主题、文案、提示词和预留位说明',
|
||||
'仅生成画布候选物料,不触发投放、分享或发布',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPublicationMaterialsWorkflow(
|
||||
workflowId: PublicationMaterialsWorkflowId,
|
||||
): PublicationMaterialsWorkflow {
|
||||
return (
|
||||
PUBLICATION_MATERIALS_WORKFLOWS.find(
|
||||
(workflow) => workflow.id === workflowId,
|
||||
) ?? PUBLICATION_MATERIALS_WORKFLOWS[0]!
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
export type ImageCanvasStageViewProps = {
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
isPanning: boolean;
|
||||
effectiveTool: CanvasTool;
|
||||
canvasBackgroundColor: string;
|
||||
@@ -125,6 +126,7 @@ export type ImageCanvasStageViewProps = {
|
||||
export function ImageCanvasStageView({
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -314,6 +316,7 @@ export function ImageCanvasStageView({
|
||||
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={onSwitchTool}
|
||||
/>
|
||||
|
||||
@@ -260,6 +260,11 @@ export function ImageCanvasWorldView({
|
||||
图标
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'publication' ? (
|
||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--publication">
|
||||
宣发
|
||||
</span>
|
||||
) : null}
|
||||
<span className="image-canvas-editor__generation-frame-size">
|
||||
{dialog.placeholder.originalWidth} x{' '}
|
||||
{dialog.placeholder.originalHeight}
|
||||
@@ -281,7 +286,8 @@ export function ImageCanvasWorldView({
|
||||
{(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'spec' ||
|
||||
generateDialog?.mode === 'character' ||
|
||||
generateDialog?.mode === 'icon') &&
|
||||
generateDialog?.mode === 'icon' ||
|
||||
generateDialog?.mode === 'publication') &&
|
||||
generateDialog.status === 'generating' &&
|
||||
generationComposerStyle ? (
|
||||
<PlatformStatusMessage
|
||||
|
||||
@@ -416,6 +416,88 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('submits publication materials as billable editor images and appends the result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
imageSrc: 'data:image/png;base64,publication',
|
||||
width: 720,
|
||||
height: 1280,
|
||||
prompt: '游戏名:云朵消消乐',
|
||||
images: Array.from({ length: 5 }, (_, index) =>
|
||||
createGenerated({
|
||||
imageSrc: `data:image/png;base64,publication-${index + 1}`,
|
||||
width: 720,
|
||||
height: 1280,
|
||||
prompt: '游戏名:云朵消消乐',
|
||||
taskId: `task-publication-${index + 1}`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'dialog-publication',
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: 'publication-detail-gallery',
|
||||
publicationGameInfo: {
|
||||
gameName: '云朵消消乐',
|
||||
gameCategories: '休闲消除',
|
||||
gameDescription: '点击同类云朵完成消除',
|
||||
},
|
||||
publicationReferences: [
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '主视觉参考',
|
||||
src: 'data:image/png;base64,ref',
|
||||
},
|
||||
],
|
||||
placeholder: {
|
||||
x: 180,
|
||||
y: 120,
|
||||
width: 360,
|
||||
height: 640,
|
||||
originalWidth: 720,
|
||||
originalHeight: 1280,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'publication:generating:closed:-:placeholder:-',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: expect.stringContaining('【宣发素材类型】详情五图'),
|
||||
size: '720x1280',
|
||||
kind: 'publication-material',
|
||||
candidateCount: 5,
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
});
|
||||
});
|
||||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||||
expect(prompt).toContain('游戏名:云朵消消乐');
|
||||
expect(prompt).toContain('五张均为 720 x 1280 竖版');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-1:1 宣发素材 1:-:-',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-5:1 宣发素材 5:-:-',
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe(
|
||||
'layer-generated-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates icon submission before calling the API', async () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
|
||||
@@ -162,6 +162,75 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
],
|
||||
);
|
||||
|
||||
const addGeneratedResultLayers = useCallback(
|
||||
(
|
||||
generatedItems: Parameters<
|
||||
typeof createGeneratedResultLayer
|
||||
>[0]['generated'][],
|
||||
options: {
|
||||
frame?: GenerateDialogState['placeholder'];
|
||||
assetKind?: CanvasLayer['assetKind'];
|
||||
title?: string;
|
||||
dialogId?: string;
|
||||
generationInputs?: CanvasGenerationInputs;
|
||||
} = {},
|
||||
) => {
|
||||
if (!generatedItems.length) {
|
||||
return;
|
||||
}
|
||||
const nextLayers = generatedItems.map((generated, index) => {
|
||||
layerCounterRef.current += 1;
|
||||
const generatedIndex = layerCounterRef.current;
|
||||
const layer = createGeneratedResultLayer({
|
||||
generated,
|
||||
generatedIndex,
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: options.frame,
|
||||
assetKind: options.assetKind,
|
||||
title:
|
||||
generatedItems.length > 1 && options.title
|
||||
? `${options.title} ${index + 1}`
|
||||
: options.title,
|
||||
generationInputs: options.generationInputs,
|
||||
});
|
||||
return {
|
||||
...layer,
|
||||
x: layer.x + index * (layer.width + 32),
|
||||
zIndex: generatedIndex + 10,
|
||||
};
|
||||
});
|
||||
|
||||
appendCanvasLayersWithResources(nextLayers);
|
||||
const firstLayer = nextLayers[0]!;
|
||||
selectSingleLayer(firstLayer.id);
|
||||
setActiveSidebarPanel('layers');
|
||||
if (options.dialogId) {
|
||||
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
|
||||
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
|
||||
? null
|
||||
: {
|
||||
...currentDialog,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: firstLayer.id,
|
||||
placeholder: undefined,
|
||||
errorMessage: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendCanvasLayersWithResources,
|
||||
canvasSize,
|
||||
layerCounterRef,
|
||||
selectSingleLayer,
|
||||
setActiveSidebarPanel,
|
||||
updateCanvasGenerationDialogById,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const addQuickEditResultLayer = useCallback(
|
||||
(
|
||||
generated: Parameters<typeof createQuickEditResultLayer>[0]['generated'],
|
||||
@@ -402,13 +471,26 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
if (submissionPlan.rememberImageModel) {
|
||||
rememberImageModel(submissionPlan.rememberImageModel);
|
||||
}
|
||||
addGeneratedResultLayer(generated, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
title: submissionPlan.result.title,
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
});
|
||||
const generatedItems = generated.images?.length
|
||||
? generated.images
|
||||
: [generated];
|
||||
if (generatedItems.length > 1) {
|
||||
addGeneratedResultLayers(generatedItems, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
title: submissionPlan.result.title,
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
});
|
||||
} else {
|
||||
addGeneratedResultLayer(generatedItems[0] ?? generated, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
title: submissionPlan.result.title,
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (canvasDialog) {
|
||||
@@ -432,6 +514,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
},
|
||||
[
|
||||
addGeneratedResultLayer,
|
||||
addGeneratedResultLayers,
|
||||
getGeneratingDialogPlaceholder,
|
||||
layerCounterRef,
|
||||
layers,
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
@@ -60,6 +59,8 @@ function GenerationSurfaceHarness() {
|
||||
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -75,6 +76,8 @@ function GenerationSurfaceHarness() {
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
publicationReferenceButtonRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
@@ -101,6 +104,7 @@ function GenerationSurfaceHarness() {
|
||||
return (
|
||||
<div>
|
||||
<span ref={specToolWrapRef}>规范入口</span>
|
||||
<span ref={publicationToolWrapRef}>宣发素材入口</span>
|
||||
<span data-testid="tool">{activeTool}</span>
|
||||
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||||
<span data-testid="dialog">
|
||||
@@ -132,6 +136,12 @@ function GenerationSurfaceHarness() {
|
||||
<button type="button" onClick={() => surface.switchGenerationTool('icon')}>
|
||||
切换图标
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('publication')}
|
||||
>
|
||||
切换宣发素材
|
||||
</button>
|
||||
<button type="button" onClick={() => surface.switchGenerationTool('text')}>
|
||||
切换文字
|
||||
</button>
|
||||
@@ -178,4 +188,32 @@ describe('useImageCanvasGenerationSurface', () => {
|
||||
const menu = screen.getByRole('menu', { name: '生成规范类型' });
|
||||
expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates a canvas publication generation card from the workflow menu', () => {
|
||||
render(<GenerationSurfaceHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换宣发素材' }));
|
||||
expect(screen.getByTestId('tool').textContent).toBe('publication');
|
||||
const menu = screen.getByRole('menu', { name: '宣发素材类型' });
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: '游戏首图' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'publication:open:placeholder',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '游戏首图生成卡片' }),
|
||||
).toBeTruthy();
|
||||
const gameNameInput = screen.getByLabelText('游戏首图游戏名');
|
||||
expect(gameNameInput).toBeTruthy();
|
||||
expect(screen.getByLabelText('游戏首图游戏分类')).toBeTruthy();
|
||||
expect(screen.getByLabelText('游戏首图一句话描述游戏')).toBeTruthy();
|
||||
fireEvent.change(gameNameInput, { target: { value: '重庆洪崖洞火锅' } });
|
||||
expect(screen.getByRole('button', { name: '添加参考图' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '消耗5泥点 · 生成' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('720 x 540')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('游戏首图Prompt 输入摘要')).toBeNull();
|
||||
expect(screen.queryByText('Prompt 约束')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
viewport: CanvasViewport;
|
||||
layerCounterRef: MutableRefObject<number>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
@@ -70,6 +72,8 @@ export function useImageCanvasGenerationSurface({
|
||||
viewport,
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
publicationReferenceButtonRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
@@ -163,6 +167,19 @@ export function useImageCanvasGenerationSurface({
|
||||
generationWorkflow.openIconGenerationDialog();
|
||||
return true;
|
||||
}
|
||||
if (tool === 'publication') {
|
||||
generationWorkflow.setIsSpecMenuOpen(false);
|
||||
generationWorkflow.setIsCharacterSpecMenuOpen(false);
|
||||
generationWorkflow.setIsCharacterReferenceMenuOpen(false);
|
||||
generationWorkflow.setIsIconSpecMenuOpen(false);
|
||||
generationWorkflow.setIsPickingCharacterSpecFromCanvas(false);
|
||||
generationWorkflow.setIsPickingCharacterReferenceFromCanvas(false);
|
||||
generationWorkflow.setIsPickingIconSpecFromCanvas(false);
|
||||
generationWorkflow.setIsPickingPublicationReferenceFromCanvas(false);
|
||||
generationWorkflow.setIsPublicationMenuOpen((open) => !open);
|
||||
setActiveTool('publication');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[generationWorkflow, setActiveTool],
|
||||
@@ -171,6 +188,8 @@ export function useImageCanvasGenerationSurface({
|
||||
const generationComposerNode = (
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
@@ -180,6 +199,10 @@ export function useImageCanvasGenerationSurface({
|
||||
generationWorkflow.isCharacterReferenceMenuOpen
|
||||
}
|
||||
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
|
||||
isPublicationMenuOpen={generationWorkflow.isPublicationMenuOpen}
|
||||
isPublicationReferenceMenuOpen={
|
||||
generationWorkflow.isPublicationReferenceMenuOpen
|
||||
}
|
||||
isPickingCharacterSpecFromCanvas={
|
||||
generationWorkflow.isPickingCharacterSpecFromCanvas
|
||||
}
|
||||
@@ -189,6 +212,9 @@ export function useImageCanvasGenerationSurface({
|
||||
isPickingIconSpecFromCanvas={
|
||||
generationWorkflow.isPickingIconSpecFromCanvas
|
||||
}
|
||||
isPickingPublicationReferenceFromCanvas={
|
||||
generationWorkflow.isPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
generateDialog={generateDialog}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
iconComposerStyle={iconComposerStyle}
|
||||
@@ -215,6 +241,10 @@ export function useImageCanvasGenerationSurface({
|
||||
generationWorkflow.setIsCharacterReferenceMenuOpen
|
||||
}
|
||||
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
|
||||
setIsPublicationMenuOpen={generationWorkflow.setIsPublicationMenuOpen}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
generationWorkflow.setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsPickingCharacterSpecFromCanvas={
|
||||
generationWorkflow.setIsPickingCharacterSpecFromCanvas
|
||||
}
|
||||
@@ -224,7 +254,13 @@ export function useImageCanvasGenerationSurface({
|
||||
setIsPickingIconSpecFromCanvas={
|
||||
generationWorkflow.setIsPickingIconSpecFromCanvas
|
||||
}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
generationWorkflow.setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
onOpenSpecDialog={generationWorkflow.openSpecDialog}
|
||||
onOpenPublicationWorkflow={
|
||||
generationWorkflow.openPublicationGenerationDialog
|
||||
}
|
||||
onRequestUpload={requestUpload}
|
||||
onSubmitImageGeneration={(dialog) =>
|
||||
void generationWorkflow.submitImageGeneration(dialog)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CharacterAnimationPanelState,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
QuickEditPanelState,
|
||||
SidebarPanel,
|
||||
SpecFormValues,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendIconDescriptionToDialog,
|
||||
appendPublicationReference,
|
||||
assignCharacterSpecReference,
|
||||
assignIconSpecReference,
|
||||
closeGenerateComposerDialog,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
createEditDialogDraft,
|
||||
createGenerateDialogDraft,
|
||||
createIconGenerationDialogDraft,
|
||||
createPublicationGenerationDialogDraft,
|
||||
createQuickEditPanelDraft,
|
||||
createSpecDialogDraft,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
@@ -116,6 +119,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
|
||||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||||
useState(false);
|
||||
const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false);
|
||||
const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
] = useState(false);
|
||||
const [quickEditPanel, setQuickEditPanel] =
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
@@ -153,6 +163,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
: DEFAULT_ICON_DESCRIPTIONS;
|
||||
|
||||
const openGenerateDialog = useCallback(() => {
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
createGenerateDialogDraft({ canvasSize, viewport }),
|
||||
);
|
||||
@@ -169,6 +182,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const openSpecDialog = useCallback(
|
||||
(specType: SpecGenerationType) => {
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
createSpecDialogDraft({ canvasSize, viewport, specType }),
|
||||
);
|
||||
@@ -202,6 +218,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const openCharacterGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
@@ -226,6 +245,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const openIconGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
@@ -250,6 +272,37 @@ export function useImageCanvasGenerationWorkflow({
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openPublicationGenerationDialog = useCallback(
|
||||
(workflowId: PublicationMaterialsWorkflowId) => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
createPublicationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId,
|
||||
}),
|
||||
);
|
||||
setActiveTool('publication');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
},
|
||||
[
|
||||
canvasSize,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const openEditDialog = useCallback(
|
||||
(sourceLayer: CanvasLayer) => {
|
||||
setMetadataLayer(null);
|
||||
@@ -318,6 +371,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
[setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const pickPublicationReferenceFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
appendPublicationReference(currentDialog, layer),
|
||||
);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setImageContextMenu(null);
|
||||
},
|
||||
[setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const updateIconDescription = useCallback(
|
||||
(index: number, value: string) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
@@ -422,6 +486,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
iconDescriptionValues,
|
||||
isSpecMenuOpen,
|
||||
setIsSpecMenuOpen,
|
||||
isPublicationMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
isCharacterSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
@@ -439,11 +509,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
openIconGenerationDialog,
|
||||
openPublicationGenerationDialog,
|
||||
openEditDialog,
|
||||
openQuickEditPanel,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
submitIconSpritesheetGeneration,
|
||||
submitQuickEdit,
|
||||
submitImageGeneration,
|
||||
@@ -469,20 +541,25 @@ export function useImageCanvasGenerationWorkflow({
|
||||
isCharacterReferenceMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
isIconSpecMenuOpen,
|
||||
isPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
isSpecMenuOpen,
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
openEditDialog,
|
||||
openGenerateDialog,
|
||||
openIconGenerationDialog,
|
||||
openPublicationGenerationDialog,
|
||||
openQuickEditPanel,
|
||||
openSpecDialog,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
quickEditModelOptions,
|
||||
quickEditPanel,
|
||||
quickEditSizeOptions,
|
||||
|
||||
@@ -88,8 +88,15 @@ function KeyboardShortcutsHarness({
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
] = useState(true);
|
||||
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true);
|
||||
const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(true);
|
||||
const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] =
|
||||
useState(true);
|
||||
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
||||
useState(true);
|
||||
const [
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
] = useState(true);
|
||||
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
||||
const [shiftPressed, setShiftPressed] = useState(false);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
|
||||
@@ -121,7 +128,10 @@ function KeyboardShortcutsHarness({
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
});
|
||||
@@ -156,9 +166,18 @@ function KeyboardShortcutsHarness({
|
||||
{String(isPickingCharacterReferenceFromCanvas)}
|
||||
</span>
|
||||
<span data-testid="icon-menu">{String(isIconSpecMenuOpen)}</span>
|
||||
<span data-testid="publication-menu">
|
||||
{String(isPublicationMenuOpen)}
|
||||
</span>
|
||||
<span data-testid="publication-reference-menu">
|
||||
{String(isPublicationReferenceMenuOpen)}
|
||||
</span>
|
||||
<span data-testid="icon-picking">
|
||||
{String(isPickingIconSpecFromCanvas)}
|
||||
</span>
|
||||
<span data-testid="publication-reference-picking">
|
||||
{String(isPickingPublicationReferenceFromCanvas)}
|
||||
</span>
|
||||
<span data-testid="space-panning">{String(isSpacePanning)}</span>
|
||||
<span data-testid="shift-pressed">{String(shiftPressed)}</span>
|
||||
</div>
|
||||
@@ -234,7 +253,14 @@ describe('useImageCanvasKeyboardShortcuts', () => {
|
||||
expect(screen.getByTestId('character-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('character-picking').textContent).toBe('false');
|
||||
expect(screen.getByTestId('icon-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('publication-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('publication-reference-menu').textContent).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByTestId('icon-picking').textContent).toBe('false');
|
||||
expect(
|
||||
screen.getByTestId('publication-reference-picking').textContent,
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('closes transient editor panels with Escape and collapses idle composers', () => {
|
||||
@@ -267,6 +293,13 @@ describe('useImageCanvasKeyboardShortcuts', () => {
|
||||
expect(screen.getByTestId('spec-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('character-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('icon-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('publication-menu').textContent).toBe('false');
|
||||
expect(screen.getByTestId('publication-reference-menu').textContent).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('publication-reference-picking').textContent,
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('keeps generating panels open when Escape closes transient chrome', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user