合并画板素材生成与宣发入口改动
新增 UI 设计图提取素材入口并复用 gpt-image-2 spritesheet 拆分流程。 保留图标生成与 UI 提取的 spritesheet 原图并同步放置拆分素材到画布。 补齐宣发素材演示入口、占位图样式、引用上传与生成提交链路。 调整编辑器生成接口 body 限制和 VectorEngine 请求兼容逻辑。 更新画板音乐生成、项目基线、宣发素材与图片画布相关文档。
This commit is contained in:
@@ -11,10 +11,12 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const switchTool = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="generate"
|
||||
onSwitchTool={switchTool}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
@@ -45,6 +47,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '生成图标素材' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
|
||||
|
||||
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
|
||||
@@ -52,17 +55,20 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
|
||||
});
|
||||
|
||||
it('only keeps the select and hand tools visibly pressed', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const renderToolbar = (effectiveTool: Parameters<
|
||||
typeof ImageCanvasBottomToolbarView
|
||||
>[0]['effectiveTool']) => (
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
@@ -96,6 +102,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
['character', '生成角色形象'],
|
||||
['icon', '生成图标素材'],
|
||||
['ui-design', '生成UI设计图'],
|
||||
['publication', '宣发素材'],
|
||||
] as const) {
|
||||
rerender(renderToolbar(tool[0]));
|
||||
|
||||
@@ -112,10 +119,12 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const closeToolOptions = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={openToolOptions}
|
||||
@@ -127,10 +136,14 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
fireEvent.pointerLeave(specToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(musicToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(musicToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(publicationToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(publicationToolWrapRef.current!);
|
||||
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Hand,
|
||||
ImageIcon,
|
||||
ImagePlus,
|
||||
Megaphone,
|
||||
MousePointer2,
|
||||
Music,
|
||||
UserRound,
|
||||
@@ -15,11 +16,12 @@ import type { RefObject } from 'react';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasTool } from './ImageCanvasEditorTypes';
|
||||
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec'>;
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
|
||||
|
||||
type ImageCanvasBottomToolbarViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
effectiveTool: CanvasTool;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
|
||||
@@ -43,6 +45,7 @@ const canvasTools: Array<{
|
||||
},
|
||||
{ id: 'video', label: '生成视频', icon: Clapperboard },
|
||||
{ id: 'music', label: '生成音乐', icon: Music },
|
||||
{ id: 'publication', label: '宣发素材', icon: Megaphone },
|
||||
{
|
||||
id: 'spec',
|
||||
label: '生成规范',
|
||||
@@ -55,7 +58,7 @@ const canvasTools: Array<{
|
||||
];
|
||||
|
||||
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
|
||||
return tool === 'music' || tool === 'spec';
|
||||
return tool === 'music' || tool === 'spec' || tool === 'publication';
|
||||
}
|
||||
|
||||
function hasPersistentPressedState(tool: CanvasTool) {
|
||||
@@ -65,6 +68,7 @@ function hasPersistentPressedState(tool: CanvasTool) {
|
||||
export function ImageCanvasBottomToolbarView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
effectiveTool,
|
||||
onSwitchTool,
|
||||
onOpenToolOptions,
|
||||
@@ -88,10 +92,19 @@ export function ImageCanvasBottomToolbarView({
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
|
||||
ref={
|
||||
id === 'spec'
|
||||
? specToolWrapRef
|
||||
: id === 'music'
|
||||
? musicToolWrapRef
|
||||
: publicationToolWrapRef
|
||||
}
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar-option-wrap',
|
||||
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
|
||||
id === 'publication'
|
||||
? 'image-canvas-editor__publication-tool-wrap'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
|
||||
@@ -500,6 +500,68 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms before deleting a generating placeholder because mud points are not refunded', async () => {
|
||||
let resolveGeneration!: (value: unknown) => void;
|
||||
generateEditorImageMock.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveGeneration = resolve;
|
||||
}),
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '准备删除的生成中图片' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
const frame = screen.getByLabelText('图像生成占位图');
|
||||
expect(frame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--generating',
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '删除生成中的占位图',
|
||||
});
|
||||
expect(
|
||||
within(confirmDialog).getByText(
|
||||
'删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
|
||||
expect(screen.queryByRole('dialog', { name: '删除生成中的占位图' })).toBeNull();
|
||||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
|
||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveGeneration({
|
||||
imageSrc: 'data:image/png;base64,ZGVsZXRlZC1wbGFjZWhvbGRlcg==',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '准备删除的生成中图片',
|
||||
actualPrompt: '准备删除的生成中图片',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-deleted-placeholder-1',
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2933,6 +2995,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
});
|
||||
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '删除生成中的占位图',
|
||||
});
|
||||
expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认删除' }));
|
||||
|
||||
expect(screen.queryByLabelText('快速编辑生成占位图')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
canvasViewportRef: createRef<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
musicToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
publicationToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
isPanning: false,
|
||||
effectiveTool: 'select',
|
||||
canvasBackgroundColor: '#f8fafc',
|
||||
|
||||
@@ -15,6 +15,7 @@ export type CanvasAssetKind =
|
||||
| 'icon'
|
||||
| 'icon-spritesheet'
|
||||
| 'icon-spec'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
| 'sound-effect'
|
||||
@@ -111,6 +112,7 @@ export type CanvasTool =
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design';
|
||||
|
||||
export type SidebarPanel = 'assets' | 'layers';
|
||||
@@ -145,6 +147,17 @@ export type CharacterReferenceImage = {
|
||||
assetObjectId?: string | null;
|
||||
};
|
||||
|
||||
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:
|
||||
@@ -153,6 +166,7 @@ export type GenerateDialogState = {
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design'
|
||||
| 'quick-edit'
|
||||
| 'character-animation'
|
||||
@@ -172,6 +186,9 @@ export type GenerateDialogState = {
|
||||
characterReferences?: CharacterReferenceImage[];
|
||||
iconSpecReference?: CharacterReferenceImage | null;
|
||||
iconDescriptions?: string[];
|
||||
publicationWorkflowId?: PublicationMaterialsWorkflowId;
|
||||
publicationGameInfo?: PublicationMaterialsGameInfo;
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
videoModel?: EditorVideoModel;
|
||||
@@ -313,7 +330,8 @@ export type UploadTarget =
|
||||
| 'character-spec'
|
||||
| 'character-reference'
|
||||
| 'icon-spec'
|
||||
| 'ui-design-icon-spec';
|
||||
| 'ui-design-icon-spec'
|
||||
| 'publication-reference';
|
||||
|
||||
export type SnapGuide = {
|
||||
vertical?: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createEditorProjectResource,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import type {
|
||||
@@ -64,9 +65,11 @@ export function ImageCanvasEditorView() {
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
@@ -82,6 +85,8 @@ export function ImageCanvasEditorView() {
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
|
||||
useState<CanvasGenerationDialogState | null>(null);
|
||||
const [imageContextMenu, setImageContextMenu] =
|
||||
useState<ImageContextMenuState | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||
@@ -511,9 +516,11 @@ export function ImageCanvasEditorView() {
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
generationReferenceButtonRef,
|
||||
publicationReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
@@ -546,6 +553,8 @@ export function ImageCanvasEditorView() {
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
isPickingQuickEditReferenceFromCanvas,
|
||||
setIsPickingQuickEditReferenceFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
@@ -565,6 +574,7 @@ export function ImageCanvasEditorView() {
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickQuickEditReferenceFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
@@ -661,6 +671,7 @@ export function ImageCanvasEditorView() {
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingQuickEditReferenceFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
isPickingUiDesignSpecFromCanvas,
|
||||
@@ -668,6 +679,7 @@ export function ImageCanvasEditorView() {
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickQuickEditReferenceFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
@@ -683,6 +695,45 @@ export function ImageCanvasEditorView() {
|
||||
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
|
||||
[],
|
||||
);
|
||||
const removeCanvasGenerationDialog = useCallback(
|
||||
(dialogId: string) => {
|
||||
captureCanvasHistory();
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
captureCanvasHistory,
|
||||
removeCanvasGenerationDialogById,
|
||||
setActiveTool,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (dialog.status === 'generating') {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
return;
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
|
||||
);
|
||||
const confirmRemoveGeneratingDialog = useCallback(() => {
|
||||
const dialog = pendingGenerationDeleteDialog;
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
setPendingGenerationDeleteDialog(null);
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
}, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]);
|
||||
|
||||
useImageCanvasKeyboardShortcuts({
|
||||
generateDialogRef,
|
||||
@@ -690,11 +741,7 @@ export function ImageCanvasEditorView() {
|
||||
redoCanvasChange,
|
||||
undoCanvasChange,
|
||||
deleteLayerById: deleteLayerByIdFromShortcut,
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => {
|
||||
captureCanvasHistory();
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setActiveTool('select');
|
||||
},
|
||||
requestRemoveCanvasGenerationDialog,
|
||||
setActiveTool,
|
||||
setGenerateDialog,
|
||||
setImageContextMenu,
|
||||
@@ -703,8 +750,11 @@ export function ImageCanvasEditorView() {
|
||||
closeEditorChromePanels,
|
||||
setIsSpecMenuOpen,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen:
|
||||
generationSurface.setIsPublicationReferenceMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
setIsPickingQuickEditReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
@@ -866,6 +916,7 @@ export function ImageCanvasEditorView() {
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -960,20 +1011,31 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={editorRootRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
uploadAccept={getEditorUploadAccept(uploadTarget)}
|
||||
onUploadInputChange={handleUploadInputChange}
|
||||
assetDragPreview={assetDragPreview}
|
||||
sidebarProps={sidebarProps}
|
||||
topbarProps={topbarProps}
|
||||
stageProps={stageProps}
|
||||
metadataProps={{
|
||||
layer: metadataLayer,
|
||||
onClose: () => setMetadataLayer(null),
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={editorRootRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
uploadAccept={getEditorUploadAccept(uploadTarget)}
|
||||
onUploadInputChange={handleUploadInputChange}
|
||||
assetDragPreview={assetDragPreview}
|
||||
sidebarProps={sidebarProps}
|
||||
topbarProps={topbarProps}
|
||||
stageProps={stageProps}
|
||||
metadataProps={{
|
||||
layer: metadataLayer,
|
||||
onClose: () => setMetadataLayer(null),
|
||||
}}
|
||||
/>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={Boolean(pendingGenerationDeleteDialog)}
|
||||
title="删除生成中的占位图"
|
||||
confirmLabel="确认删除"
|
||||
onClose={() => setPendingGenerationDeleteDialog(null)}
|
||||
onConfirm={confirmRemoveGeneratingDialog}
|
||||
>
|
||||
删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。
|
||||
</PlatformDangerConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,18 +30,21 @@ function createComposerProps(
|
||||
characterReferenceButtonRef: createRef(),
|
||||
generationReferenceButtonRef: createRef(),
|
||||
iconSpecButtonRef: createRef(),
|
||||
publicationReferenceButtonRef: createRef(),
|
||||
isSpecMenuOpen: false,
|
||||
isCharacterSpecMenuOpen: false,
|
||||
isCharacterReferenceMenuOpen: false,
|
||||
isGenerationReferenceMenuOpen: false,
|
||||
isIconSpecMenuOpen: false,
|
||||
isUiDesignSpecMenuOpen: false,
|
||||
isPublicationReferenceMenuOpen: false,
|
||||
isPickingCharacterSpecFromCanvas: false,
|
||||
isPickingCharacterReferenceFromCanvas: false,
|
||||
isPickingGenerationReferenceFromCanvas: false,
|
||||
isPickingQuickEditReferenceFromCanvas: false,
|
||||
isPickingIconSpecFromCanvas: false,
|
||||
isPickingUiDesignSpecFromCanvas: false,
|
||||
isPickingPublicationReferenceFromCanvas: false,
|
||||
generateDialog,
|
||||
generationComposerStyle: { left: 320, top: 240 },
|
||||
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
|
||||
@@ -80,11 +83,13 @@ function createComposerProps(
|
||||
setIsPickingQuickEditReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsPublicationReferenceMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingPublicationReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
onOpenSpecDialog: vi.fn(),
|
||||
onRequestUpload: vi.fn(),
|
||||
onSubmitImageGeneration: vi.fn(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
|
||||
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
||||
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
|
||||
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
|
||||
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
|
||||
@@ -49,18 +51,21 @@ import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOpt
|
||||
|
||||
type ImageCanvasGenerationComposerViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
isSpecMenuOpen: boolean;
|
||||
isGenerationReferenceMenuOpen: boolean;
|
||||
isPublicationReferenceMenuOpen: boolean;
|
||||
isCharacterSpecMenuOpen: boolean;
|
||||
isCharacterReferenceMenuOpen: boolean;
|
||||
isIconSpecMenuOpen: boolean;
|
||||
isUiDesignSpecMenuOpen: boolean;
|
||||
isPickingGenerationReferenceFromCanvas: boolean;
|
||||
isPickingQuickEditReferenceFromCanvas: boolean;
|
||||
isPickingPublicationReferenceFromCanvas: boolean;
|
||||
isPickingCharacterSpecFromCanvas: boolean;
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
@@ -85,12 +90,16 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
SetStateAction<CharacterAnimationPanelState | null>
|
||||
>;
|
||||
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<
|
||||
SetStateAction<boolean>
|
||||
>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -841,18 +850,21 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
|
||||
export function ImageCanvasGenerationComposerView({
|
||||
specToolWrapRef,
|
||||
publicationReferenceButtonRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
generationReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
isSpecMenuOpen,
|
||||
isGenerationReferenceMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
isIconSpecMenuOpen,
|
||||
isUiDesignSpecMenuOpen,
|
||||
isPickingGenerationReferenceFromCanvas,
|
||||
isPickingQuickEditReferenceFromCanvas,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
isPickingIconSpecFromCanvas,
|
||||
@@ -875,12 +887,14 @@ export function ImageCanvasGenerationComposerView({
|
||||
setCropExpandPanel,
|
||||
setCharacterAnimationPanel,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
setIsUiDesignSpecMenuOpen,
|
||||
setIsPickingGenerationReferenceFromCanvas,
|
||||
setIsPickingQuickEditReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
@@ -974,6 +988,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 === 'ui-design' &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -1098,6 +1137,11 @@ export function ImageCanvasGenerationComposerView({
|
||||
请选择画布中的图片作为参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingPublicationReferenceFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图片作为宣发素材参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingIconSpecFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图标规范,按 Esc 退出
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CanvasViewport,
|
||||
CharacterAnimationPanelState,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
QuickEditPanelState,
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
@@ -21,12 +22,15 @@ import {
|
||||
createCanvasLayerReference,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_PUBLICATION_GAME_INFO,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
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,
|
||||
UI_DESIGN_FRAME_DISPLAY_SIZE,
|
||||
@@ -262,6 +266,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 createVideoGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
@@ -575,6 +610,22 @@ export function appendGenerationReference(
|
||||
: dialog;
|
||||
}
|
||||
|
||||
export function appendPublicationReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
): GenerateDialogState | null {
|
||||
return dialog?.mode === 'publication' && getReferenceMediaType(layer) === 'image'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
|
||||
export function assignIconSpecReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
@@ -685,6 +736,7 @@ export function hideGeneratedLayerComposerAfterBlur(
|
||||
dialog?.mode === 'quick-edit' ||
|
||||
dialog?.mode === 'character-animation' ||
|
||||
dialog?.mode === 'video' ||
|
||||
dialog?.mode === 'publication' ||
|
||||
dialog?.mode === 'audio-sound-effect' ||
|
||||
dialog?.mode === 'audio-background-music') &&
|
||||
dialog.status !== 'generating'
|
||||
@@ -706,6 +758,7 @@ export function closeGenerateComposerDialog(
|
||||
dialog?.mode === 'quick-edit' ||
|
||||
dialog?.mode === 'character-animation' ||
|
||||
dialog?.mode === 'video' ||
|
||||
dialog?.mode === 'publication' ||
|
||||
dialog?.mode === 'audio-sound-effect' ||
|
||||
dialog?.mode === 'audio-background-music'
|
||||
? {
|
||||
|
||||
@@ -11,9 +11,15 @@ import type {
|
||||
CanvasLayer,
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
PublicationMaterialsGameInfo,
|
||||
PublicationMaterialsWorkflowId,
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
getPublicationMaterialsWorkflow,
|
||||
type PublicationMaterialsWorkflow,
|
||||
} from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
|
||||
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
|
||||
@@ -51,6 +57,22 @@ export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
|
||||
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
|
||||
export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
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;
|
||||
@@ -58,6 +80,11 @@ export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
|
||||
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
|
||||
export const ICON_DESCRIPTION_LIMIT = 100;
|
||||
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
};
|
||||
export const DEFAULT_ICON_DESCRIPTIONS = [
|
||||
'返回按钮',
|
||||
'设置按钮',
|
||||
@@ -274,6 +301,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return '图标规范';
|
||||
}
|
||||
if (layer.assetKind === 'publication-material') {
|
||||
return '宣发素材';
|
||||
}
|
||||
if (layer.assetKind === 'ui-design') {
|
||||
return 'UI设计';
|
||||
}
|
||||
@@ -327,6 +357,9 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return '图标规范图片';
|
||||
}
|
||||
if (layer.assetKind === 'publication-material') {
|
||||
return '宣发素材图片';
|
||||
}
|
||||
if (layer.assetKind === 'ui-design') {
|
||||
return 'UI设计图';
|
||||
}
|
||||
@@ -565,6 +598,71 @@ export function buildUiDesignGenerationInputs(
|
||||
};
|
||||
}
|
||||
|
||||
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.promptLabel ?? 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 buildIconGenerationInputs(
|
||||
iconDescriptions: string[],
|
||||
specReference: CharacterReferenceImage,
|
||||
@@ -657,6 +755,7 @@ export function isCanvasGenerationDialog(
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
@@ -678,6 +777,9 @@ export function getGenerationFrameAriaLabel(
|
||||
if (dialog.mode === 'icon') {
|
||||
return '图标素材生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'publication') {
|
||||
return '宣发素材生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return 'UI设计图生成占位图';
|
||||
}
|
||||
@@ -709,6 +811,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`;
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return 'UI Design Generator';
|
||||
}
|
||||
|
||||
@@ -234,6 +234,144 @@ 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 one 720p portrait image', () => {
|
||||
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',
|
||||
},
|
||||
result: {
|
||||
title: '11 宣发素材',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'本次只生成一张 720 x 1280 竖版详情图',
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'不是五图合集或拼贴',
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'此处换上你的游戏二维码',
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
|
||||
'方块内部上方用黑色小字',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when edit source layer is missing', () => {
|
||||
expect(() =>
|
||||
buildImageGenerationSubmissionPlan({
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
buildEditGenerationInputs,
|
||||
buildIconGenerationInputs,
|
||||
buildImageGenerationInputs,
|
||||
buildPublicationMaterialsGenerationInputs,
|
||||
buildPublicationMaterialsGenerationPrompt,
|
||||
buildPublicationMaterialsPrompt,
|
||||
buildSoundEffectGenerationInputs,
|
||||
buildSpecGenerationInputs,
|
||||
buildSpecPrompt,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
SPEC_GENERATION_SIZE,
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
type ImageGenerationSubmissionOptions = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -294,6 +298,42 @@ 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',
|
||||
...(dialog.publicationReferences?.length
|
||||
? {
|
||||
referenceImageSrcs: dialog.publicationReferences.map(
|
||||
(reference) => reference.src,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
result: {
|
||||
title: `${nextGeneratedIndex} 宣发素材`,
|
||||
generationInputs: buildPublicationMaterialsGenerationInputs(
|
||||
dialog.publicationGameInfo,
|
||||
dialog.publicationReferences,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (dialog.mode === 'video') {
|
||||
const resolution = dialog.videoResolution ?? '480p';
|
||||
const durationSeconds = dialog.videoDurationSeconds ?? 4;
|
||||
|
||||
@@ -173,6 +173,9 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isCanvasGenerationComposerVisible(createDialog({ mode: 'publication' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCanvasGenerationComposerVisible({
|
||||
mode: 'edit',
|
||||
|
||||
@@ -203,6 +203,7 @@ export function isCanvasGenerationComposerVisible(
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'ui-design' ||
|
||||
dialog?.mode === 'quick-edit' ||
|
||||
dialog?.mode === 'publication' ||
|
||||
dialog?.mode === 'character-animation' ||
|
||||
dialog?.mode === 'video' ||
|
||||
dialog?.mode === 'audio-sound-effect' ||
|
||||
|
||||
@@ -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,128 @@
|
||||
import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes';
|
||||
|
||||
export type PublicationMaterialsWorkflow = {
|
||||
id: PublicationMaterialsWorkflowId;
|
||||
label: string;
|
||||
promptLabel?: 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: '详情五图',
|
||||
promptLabel: '详情页单图',
|
||||
englishName: 'Detail Gallery',
|
||||
outputLabel: '单张详情图',
|
||||
sizeLabel: '720 x 1280',
|
||||
outputSize: '720x1280',
|
||||
scenario: '作品详情、应用介绍和运营分发图组',
|
||||
fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'],
|
||||
framework: [
|
||||
'选择当前这张详情图要表达的单一卖点',
|
||||
'生成一张带短标题的竖版详情页素材',
|
||||
'让主体、玩法反馈和背景符号保持同一套游戏物料气质',
|
||||
'按单图尺寸、标题可读性、主体完整度和非合集画面验收',
|
||||
],
|
||||
promptConstraints: [
|
||||
'本次只生成一张 720 x 1280 竖版详情图;不要把五张图合成一张长图、拼图、多分镜合集或截图说明页',
|
||||
'每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句',
|
||||
'画面保持与同一游戏后续详情图可组成一套:同一游戏名、同一主体物件体系、同一画风、同一色彩气质',
|
||||
'构图要服务当前单一卖点,主体、玩法道具、反馈效果和背景层级清楚;核心信息缩小后仍能识别',
|
||||
'短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、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]!
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export type ImageCanvasStageViewProps = {
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
isPanning: boolean;
|
||||
effectiveTool: CanvasTool;
|
||||
canvasBackgroundColor: string;
|
||||
@@ -130,14 +131,19 @@ export type ImageCanvasStageViewProps = {
|
||||
onToggleMinimap: () => void;
|
||||
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
onOpenToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
|
||||
onCloseToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
|
||||
onOpenToolOptions: (
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
|
||||
) => void;
|
||||
onCloseToolOptions: (
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function ImageCanvasStageView({
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -339,6 +345,7 @@ export function ImageCanvasStageView({
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={onSwitchTool}
|
||||
onOpenToolOptions={onOpenToolOptions}
|
||||
|
||||
@@ -33,7 +33,8 @@ type GenerationReferenceUploadTarget =
|
||||
| 'video-reference-audio'
|
||||
| 'icon-spec'
|
||||
| 'spec-reference'
|
||||
| 'ui-design-icon-spec';
|
||||
| 'ui-design-icon-spec'
|
||||
| 'publication-reference';
|
||||
|
||||
const VIDEO_REFERENCE_LIMITS = {
|
||||
image: 9,
|
||||
@@ -155,6 +156,18 @@ export function applyGenerationReferenceUpload({
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
if (target === 'publication-reference') {
|
||||
return dialog?.mode === 'publication'
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
...references,
|
||||
],
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
if (target === 'generation-reference') {
|
||||
return dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'video' ||
|
||||
|
||||
@@ -216,6 +216,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
dialog,
|
||||
);
|
||||
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||
expect(
|
||||
within(frame).queryByRole('button', { name: '删除图标素材生成占位图' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -295,6 +298,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
'image-canvas-editor__generation-frame--character-animation',
|
||||
iconClassName: 'lucide-person-standing',
|
||||
},
|
||||
{
|
||||
mode: 'publication',
|
||||
ariaLabel: '宣发素材生成占位图',
|
||||
generatorLabel: 'Home Image Generator',
|
||||
badgeLabel: '宣发',
|
||||
frameClassName: 'image-canvas-editor__generation-frame--publication',
|
||||
iconClassName: 'lucide-image',
|
||||
},
|
||||
{
|
||||
mode: 'video',
|
||||
ariaLabel: '视频生成占位图',
|
||||
@@ -365,6 +376,12 @@ describe('ImageCanvasWorldView', () => {
|
||||
generatorLabel: 'Action Generator',
|
||||
badgeLabel: '动作',
|
||||
},
|
||||
{
|
||||
mode: 'publication',
|
||||
ariaLabel: '宣发素材生成占位图',
|
||||
generatorLabel: 'Home Image Generator',
|
||||
badgeLabel: '宣发',
|
||||
},
|
||||
{
|
||||
mode: 'video',
|
||||
ariaLabel: '视频生成占位图',
|
||||
@@ -433,6 +450,12 @@ describe('ImageCanvasWorldView', () => {
|
||||
generatorLabel: 'Action Generator',
|
||||
badgeLabel: '动作',
|
||||
},
|
||||
{
|
||||
mode: 'publication',
|
||||
ariaLabel: '宣发素材生成占位图',
|
||||
generatorLabel: 'Home Image Generator',
|
||||
badgeLabel: '宣发',
|
||||
},
|
||||
{
|
||||
mode: 'video',
|
||||
ariaLabel: '视频生成占位图',
|
||||
|
||||
@@ -88,6 +88,14 @@ function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
|
||||
icon: <ImageIcon className="h-8 w-8" />,
|
||||
labelIcon: <ImageIcon className="h-4 w-4" />,
|
||||
};
|
||||
case 'publication':
|
||||
return {
|
||||
frameClassName: 'image-canvas-editor__generation-frame--publication',
|
||||
badgeClassName: 'publication',
|
||||
badgeLabel: '宣发',
|
||||
icon: <ImageIcon className="h-8 w-8" />,
|
||||
labelIcon: <ImageIcon className="h-4 w-4" />,
|
||||
};
|
||||
case 'video':
|
||||
return {
|
||||
frameClassName: 'image-canvas-editor__generation-frame--video',
|
||||
@@ -141,7 +149,6 @@ function buildInverseScaleStyle(
|
||||
'--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-frame-action-offset': `calc(-0.75rem * ${inverseScale})`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,6 +586,7 @@ export function ImageCanvasWorldView({
|
||||
generateDialog?.mode === 'icon' ||
|
||||
generateDialog?.mode === 'ui-design' ||
|
||||
generateDialog?.mode === 'quick-edit' ||
|
||||
generateDialog?.mode === 'publication' ||
|
||||
generateDialog?.mode === 'character-animation' ||
|
||||
generateDialog?.mode === 'video' ||
|
||||
generateDialog?.mode === 'audio-sound-effect' ||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -18,6 +24,9 @@ import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -26,6 +35,8 @@ const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||||
resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock,
|
||||
resolveEditorImageReferenceDataUrlForGeneration:
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
@@ -219,10 +230,7 @@ function SubmissionWorkflowHarness({
|
||||
>
|
||||
填写快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void workflow.submitQuickEdit()}
|
||||
>
|
||||
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
|
||||
提交快速编辑
|
||||
</button>
|
||||
<button
|
||||
@@ -286,6 +294,7 @@ function SubmissionWorkflowHarness({
|
||||
describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockReset();
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
extractEditorUiDesignAssetsMock.mockReset();
|
||||
generateEditorCharacterAnimationMock.mockReset();
|
||||
@@ -294,6 +303,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockImplementation(
|
||||
async (src: string) => `data:image/png;base64,${src.split('/').pop()}`,
|
||||
);
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
});
|
||||
|
||||
it('submits quick edits, clears the panel, and fits the source plus result', async () => {
|
||||
@@ -431,6 +443,132 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('submits publication detail materials as one billable editor image and appends the result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
imageSrc: 'data:image/png;base64,publication',
|
||||
width: 720,
|
||||
height: 1280,
|
||||
prompt: '游戏名:云朵消消乐',
|
||||
}),
|
||||
);
|
||||
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,large-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',
|
||||
referenceImageSrcs: ['data:image/png;base64,large-ref'],
|
||||
});
|
||||
});
|
||||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||||
expect(prompt).toContain('游戏名:云朵消消乐');
|
||||
expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图');
|
||||
expect(prompt).toContain('不是五图合集或拼贴');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-1:1 宣发素材:-:-',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-generated-2',
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe(
|
||||
'layer-generated-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('compresses publication references before submitting generation', async () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
||||
'data:image/jpeg;base64,compressed-ref',
|
||||
);
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
imageSrc: 'data:image/png;base64,publication',
|
||||
width: 720,
|
||||
height: 540,
|
||||
prompt: '游戏名:云朵消消乐',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'dialog-publication',
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: 'publication-cover-image',
|
||||
publicationGameInfo: {
|
||||
gameName: '云朵消消乐',
|
||||
gameCategories: '休闲消除',
|
||||
gameDescription: '点击同类云朵完成消除',
|
||||
},
|
||||
publicationReferences: [
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '主视觉参考',
|
||||
src: 'data:image/png;base64,large-original-ref',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
).toHaveBeenCalledWith('data:image/png;base64,large-original-ref');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'publication-material',
|
||||
referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('validates icon submission before calling the API', async () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import {
|
||||
resolveEditorImageReferenceDataUrl,
|
||||
resolveEditorImageReferenceDataUrlForGeneration,
|
||||
} from '../../services/image-editor/editorImageReference';
|
||||
import {
|
||||
editEditorImage,
|
||||
extractEditorUiDesignAssets,
|
||||
@@ -95,6 +98,26 @@ type GenerationSubmissionWorkflowOptions = {
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
async function normalizePublicationReferenceImages(
|
||||
input: Parameters<typeof generateEditorImage>[0],
|
||||
) {
|
||||
if (
|
||||
input.kind !== 'publication-material' ||
|
||||
!input.referenceImageSrcs?.length
|
||||
) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
referenceImageSrcs: await Promise.all(
|
||||
input.referenceImageSrcs.map((referenceImageSrc) =>
|
||||
resolveEditorImageReferenceDataUrlForGeneration(referenceImageSrc),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
layers,
|
||||
canvasSize,
|
||||
@@ -776,7 +799,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
canvasDialog?.id,
|
||||
);
|
||||
} else {
|
||||
const generated = await generateEditorImage(submissionPlan.input);
|
||||
const imageGenerationInput =
|
||||
submissionPlan.input.kind === 'publication-material'
|
||||
? await normalizePublicationReferenceImages(submissionPlan.input)
|
||||
: submissionPlan.input;
|
||||
const generated = await generateEditorImage(imageGenerationInput);
|
||||
if (submissionPlan.rememberImageModel) {
|
||||
rememberImageModel(submissionPlan.rememberImageModel);
|
||||
}
|
||||
|
||||
@@ -60,10 +60,12 @@ function GenerationSurfaceHarness() {
|
||||
const layerCounterRef = useRef(0);
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [viewport, setViewport] = useState({ x: 10, y: 20, scale: 2 });
|
||||
const dialogs = useCanvasGenerationDialogs();
|
||||
const activeDialog = dialogs.generateDialog;
|
||||
@@ -80,10 +82,12 @@ function GenerationSurfaceHarness() {
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generationReferenceButtonRef,
|
||||
publicationReferenceButtonRef,
|
||||
generateDialog: dialogs.generateDialog,
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
activeCanvasGenerationDialog: activeCanvasDialog,
|
||||
@@ -116,6 +120,7 @@ function GenerationSurfaceHarness() {
|
||||
>
|
||||
音乐入口
|
||||
</span>
|
||||
<span ref={publicationToolWrapRef}>宣发入口</span>
|
||||
<span data-testid="tool">{activeTool}</span>
|
||||
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||||
<span data-testid="dialog">
|
||||
@@ -171,6 +176,12 @@ function GenerationSurfaceHarness() {
|
||||
>
|
||||
切换UI设计
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('publication')}
|
||||
>
|
||||
切换宣发素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('select')}
|
||||
|
||||
@@ -22,10 +22,12 @@ import type {
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
SidebarPanel,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import { PUBLICATION_MATERIALS_WORKFLOWS } from './ImageCanvasPublicationMaterialsModel';
|
||||
import {
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveCropExpandPanelStyle,
|
||||
@@ -40,7 +42,7 @@ type CanvasGenerationDialogUpdater = (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => CanvasGenerationDialogState | null;
|
||||
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec'>;
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
|
||||
|
||||
type ImageCanvasGenerationSurfaceOptions = {
|
||||
layers: CanvasLayer[];
|
||||
@@ -51,10 +53,12 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
layerCounterRef: MutableRefObject<number>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
||||
@@ -128,10 +132,12 @@ export function useImageCanvasGenerationSurface({
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generationReferenceButtonRef,
|
||||
publicationReferenceButtonRef,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
activeCanvasGenerationDialog,
|
||||
@@ -243,6 +249,7 @@ export function useImageCanvasGenerationSurface({
|
||||
clearToolbarOptionCloseTimer();
|
||||
generationWorkflow.setIsMusicMenuOpen(tool === 'music');
|
||||
generationWorkflow.setIsSpecMenuOpen(tool === 'spec');
|
||||
generationWorkflow.setIsPublicationMenuOpen(tool === 'publication');
|
||||
},
|
||||
[clearToolbarOptionCloseTimer, generationWorkflow],
|
||||
);
|
||||
@@ -255,6 +262,10 @@ export function useImageCanvasGenerationSurface({
|
||||
generationWorkflow.setIsMusicMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (tool === 'publication') {
|
||||
generationWorkflow.setIsPublicationMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
generationWorkflow.setIsSpecMenuOpen(false);
|
||||
}, TOOLBAR_OPTION_CLOSE_DELAY_MS);
|
||||
},
|
||||
@@ -281,6 +292,11 @@ export function useImageCanvasGenerationSurface({
|
||||
setActiveTool('spec');
|
||||
return true;
|
||||
}
|
||||
if (tool === 'publication') {
|
||||
openToolbarOptionMenu('publication');
|
||||
setActiveTool('publication');
|
||||
return true;
|
||||
}
|
||||
if (tool === 'character') {
|
||||
generationWorkflow.openCharacterGenerationDialog();
|
||||
return true;
|
||||
@@ -302,10 +318,14 @@ export function useImageCanvasGenerationSurface({
|
||||
<>
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
generationReferenceButtonRef={generationReferenceButtonRef}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
isPublicationReferenceMenuOpen={
|
||||
generationWorkflow.isPublicationReferenceMenuOpen
|
||||
}
|
||||
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
|
||||
isGenerationReferenceMenuOpen={
|
||||
generationWorkflow.isGenerationReferenceMenuOpen
|
||||
@@ -316,6 +336,9 @@ export function useImageCanvasGenerationSurface({
|
||||
}
|
||||
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
|
||||
isUiDesignSpecMenuOpen={generationWorkflow.isUiDesignSpecMenuOpen}
|
||||
isPickingPublicationReferenceFromCanvas={
|
||||
generationWorkflow.isPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
isPickingGenerationReferenceFromCanvas={
|
||||
generationWorkflow.isPickingGenerationReferenceFromCanvas
|
||||
}
|
||||
@@ -364,6 +387,9 @@ export function useImageCanvasGenerationSurface({
|
||||
setIsGenerationReferenceMenuOpen={
|
||||
generationWorkflow.setIsGenerationReferenceMenuOpen
|
||||
}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
generationWorkflow.setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
|
||||
setIsUiDesignSpecMenuOpen={generationWorkflow.setIsUiDesignSpecMenuOpen}
|
||||
setIsPickingGenerationReferenceFromCanvas={
|
||||
@@ -372,6 +398,9 @@ export function useImageCanvasGenerationSurface({
|
||||
setIsPickingQuickEditReferenceFromCanvas={
|
||||
generationWorkflow.setIsPickingQuickEditReferenceFromCanvas
|
||||
}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
generationWorkflow.setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
setIsPickingCharacterSpecFromCanvas={
|
||||
generationWorkflow.setIsPickingCharacterSpecFromCanvas
|
||||
}
|
||||
@@ -409,6 +438,35 @@ export function useImageCanvasGenerationSurface({
|
||||
onSpecMenuPointerEnter={() => openToolbarOptionMenu('spec')}
|
||||
onSpecMenuPointerLeave={() => closeToolbarOptionMenu('spec')}
|
||||
/>
|
||||
{generationWorkflow.isPublicationMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu image-canvas-editor__publication-menu"
|
||||
label="宣发素材类型"
|
||||
placement="top-start"
|
||||
style={buildToolbarPortalMenuStyle(
|
||||
publicationToolWrapRef.current,
|
||||
)}
|
||||
onPointerEnter={() => openToolbarOptionMenu('publication')}
|
||||
onPointerLeave={() => closeToolbarOptionMenu('publication')}
|
||||
onFocus={() => openToolbarOptionMenu('publication')}
|
||||
onBlur={() => closeToolbarOptionMenu('publication')}
|
||||
>
|
||||
{PUBLICATION_MATERIALS_WORKFLOWS.map((workflow) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={workflow.id}
|
||||
onClick={() =>
|
||||
generationWorkflow.openPublicationGenerationDialog(
|
||||
workflow.id as PublicationMaterialsWorkflowId,
|
||||
)
|
||||
}
|
||||
>
|
||||
{workflow.label}
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
{generationWorkflow.isMusicMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
CropExpandResizeHandle,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
PublicationMaterialsWorkflowId,
|
||||
QuickEditPanelState,
|
||||
SidebarPanel,
|
||||
SpecFormValues,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
appendCharacterReference,
|
||||
appendGenerationReference,
|
||||
appendQuickEditReference,
|
||||
appendPublicationReference,
|
||||
assignCharacterSpecReference,
|
||||
assignIconSpecReference,
|
||||
assignUiDesignSpecReference,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
createEditDialogDraft,
|
||||
createGenerateDialogDraft,
|
||||
createIconGenerationDialogDraft,
|
||||
createPublicationGenerationDialogDraft,
|
||||
createQuickEditPanelDraft,
|
||||
createRedrawPanelDraft,
|
||||
createSoundEffectGenerationDialogDraft,
|
||||
@@ -233,6 +236,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] =
|
||||
useState(false);
|
||||
const [isMusicMenuOpen, setIsMusicMenuOpen] = 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 [cropExpandPanel, setCropExpandPanel] =
|
||||
@@ -300,6 +310,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
setIsMusicMenuOpen(false);
|
||||
setIsPublicationMenuOpen(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setImageContextMenu(null);
|
||||
}, [setImageContextMenu]);
|
||||
|
||||
@@ -434,6 +447,25 @@ export function useImageCanvasGenerationWorkflow({
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openPublicationGenerationDialog = useCallback(
|
||||
(workflowId: PublicationMaterialsWorkflowId) => {
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createPublicationGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
workflowId,
|
||||
}),
|
||||
);
|
||||
activateCanvasGenerationEntry('publication');
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const openVideoGenerationDialog = useCallback(() => {
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createVideoGenerationDialogDraft({ canvasSize, viewport }),
|
||||
@@ -859,6 +891,18 @@ export function useImageCanvasGenerationWorkflow({
|
||||
[generateDialog, setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const pickPublicationReferenceFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
appendPublicationReference(currentDialog, layer),
|
||||
);
|
||||
setIsPickingPublicationReferenceFromCanvas(false);
|
||||
setIsPublicationReferenceMenuOpen(false);
|
||||
setImageContextMenu(null);
|
||||
},
|
||||
[setGenerateDialog, setImageContextMenu],
|
||||
);
|
||||
|
||||
const updateIconDescriptionsText = useCallback(
|
||||
(value: string) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
@@ -1080,11 +1124,18 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
isMusicMenuOpen,
|
||||
setIsMusicMenuOpen,
|
||||
isPublicationMenuOpen,
|
||||
setIsPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
openGenerateDialog,
|
||||
openSpecDialog,
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
openIconGenerationDialog,
|
||||
openPublicationGenerationDialog,
|
||||
openVideoGenerationDialog,
|
||||
openUiDesignGenerationDialog,
|
||||
openSoundEffectGenerationDialog,
|
||||
@@ -1102,6 +1153,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
submitIconSpritesheetGeneration,
|
||||
submitQuickEdit,
|
||||
submitCropExpand,
|
||||
@@ -1138,7 +1190,10 @@ export function useImageCanvasGenerationWorkflow({
|
||||
isPickingUiDesignSpecFromCanvas,
|
||||
isUiDesignSpecMenuOpen,
|
||||
isMusicMenuOpen,
|
||||
isPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
isSpecMenuOpen,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
openBackgroundMusicGenerationDialog,
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
@@ -1147,6 +1202,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
extractUiDesignAssets,
|
||||
openGenerateDialog,
|
||||
openIconGenerationDialog,
|
||||
openPublicationGenerationDialog,
|
||||
openRedrawPanel,
|
||||
openUiDesignGenerationDialog,
|
||||
openQuickEditPanel,
|
||||
@@ -1159,6 +1215,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
pickGenerationReferenceFromLayer,
|
||||
pickQuickEditReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
pickPublicationReferenceFromLayer,
|
||||
pickUiDesignSpecFromLayer,
|
||||
quickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user