Feat/游戏场景需求v1.0 (#139)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

实现[游戏场景需求v1.0](https://kcnz41bksl1c.feishu.cn/wiki/JSF3wdhduinpqhkrVGKcZFp4nng?psg_id=8477599259997387860&refer_index=1&refer_type=citation)。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: suzmii <suzmii@foxmail.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/139
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #139.
This commit is contained in:
2026-08-08 10:25:42 +08:00
committed by 段舒康
parent fc0dcb0782
commit 72f268e088
64 changed files with 3441 additions and 120 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

@@ -350,7 +350,7 @@ describe('CreationLandingView', () => {
['游戏角色', 'character-spec'],
['游戏UI', 'ui-design'],
['游戏音乐', 'background-music'],
['游戏场景', 'image'],
['游戏场景', 'scene'],
['游戏美宣', 'publication-cover'],
])(
'creates a new project from %s with its canvas tool intent',
@@ -58,7 +58,8 @@ type CreationFeatureTool =
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
| 'publication-cover'
| 'scene';
type CreationFeatureItem = {
title: string;
@@ -134,7 +135,7 @@ const CREATION_FEATURES: CreationFeatureItem[] = [
title: '游戏场景',
description: '轻松生成各类游戏场景素材',
iconSrc: '/creation-home/feature-scene.png',
action: { kind: 'tool', tool: 'image' },
action: { kind: 'tool', tool: 'scene' },
},
{
title: '游戏美宣',
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { CUSTOM_EDITOR_SCENE_STYLE_PRESET } from '../../../packages/shared/src/contracts/editorScene';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
@@ -20,9 +21,16 @@ import type {
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import {
EDITOR_SCENE_CONTENT_REQUIRED_ERROR,
EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR,
} from './ImageCanvasGenerationSubmissionModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
const EDITOR_SCENE_CONTENT_ERROR_ID = 'editor-scene-content-error';
const EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID = 'editor-scene-custom-style-error';
type ReferenceLabelFormatter = (
reference: CharacterReferenceImage,
index: number,
@@ -84,6 +92,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
submitLabel?: string;
submitAriaLabel?: string;
submittingStatusLabel?: string;
styleControl?: ReactNode;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
@@ -138,6 +147,7 @@ export function ImageCanvasBasicGenerationComposerView({
submitLabel = '生成',
submitAriaLabel = '生成',
submittingStatusLabel = '生成中',
styleControl,
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
const isQuickEdit = dialog.mode === 'quick-edit';
@@ -172,6 +182,22 @@ export function ImageCanvasBasicGenerationComposerView({
const finalFooterClassName =
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
const sceneContentError =
dialog.mode === 'scene' &&
dialog.status === 'failed' &&
dialog.errorMessage === EDITOR_SCENE_CONTENT_REQUIRED_ERROR
? dialog.errorMessage
: null;
const sceneCustomStyleError =
dialog.mode === 'scene' &&
dialog.status === 'failed' &&
dialog.errorMessage === EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR
? dialog.errorMessage
: null;
const hasSceneFieldError = Boolean(
sceneContentError || sceneCustomStyleError,
);
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
@@ -251,6 +277,10 @@ export function ImageCanvasBasicGenerationComposerView({
) : null}
<AutoGrowTextArea
aria-label={resolvedPromptLabel}
aria-invalid={sceneContentError ? true : undefined}
aria-describedby={
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
@@ -266,6 +296,50 @@ export function ImageCanvasBasicGenerationComposerView({
)
}
/>
{sceneContentError ? (
<p
id={EDITOR_SCENE_CONTENT_ERROR_ID}
className="image-canvas-editor__scene-field-error"
role="alert"
>
{sceneContentError}
</p>
) : null}
{dialog.mode === 'scene' &&
dialog.sceneStylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? (
<AutoGrowTextArea
aria-label="自定义画风"
aria-invalid={sceneCustomStyleError ? true : undefined}
aria-describedby={
sceneCustomStyleError
? EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID
: undefined
}
value={dialog.sceneCustomStyle ?? ''}
disabled={dialog.status === 'generating'}
placeholder="填写自定义画风,例如:90 年代复古像素风"
className={`${promptClassName} image-canvas-editor__scene-custom-style`}
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
sceneCustomStyle: event.target.value,
}
: currentDialog,
)
}
/>
) : null}
{sceneCustomStyleError ? (
<p
id={EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID}
className="image-canvas-editor__scene-field-error"
role="alert"
>
{sceneCustomStyleError}
</p>
) : null}
<div className={finalFooterClassName}>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
@@ -284,6 +358,7 @@ export function ImageCanvasBasicGenerationComposerView({
submitLabel={resolvedSubmitLabel}
submitAriaLabel={resolvedSubmitAriaLabel}
submitButtonClassName={submitButtonClassName}
styleControl={styleControl}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
@@ -299,7 +374,7 @@ export function ImageCanvasBasicGenerationComposerView({
{resolvedSubmittingStatusLabel}
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
{dialog.status === 'failed' && !hasSceneFieldError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
@@ -79,6 +79,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['生成图标素材', 'icon'],
['生成UI设计图', 'ui-design'],
['宣发素材', 'publication'],
['生成游戏场景', 'scene'],
] as const;
for (const [label, tool] of toolExpectations) {
@@ -6,6 +6,7 @@ import {
Hand,
ImageIcon,
Megaphone,
Mountain,
MousePointer2,
Music,
Upload,
@@ -55,6 +56,7 @@ const canvasTools: Array<{
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
{ id: 'scene', label: '生成游戏场景', icon: Mountain },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
];
@@ -1300,6 +1300,31 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores scene generation dialog fields', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-scene',
mode: 'scene',
prompt: '海边车站',
status: 'idle',
sceneStylePreset: 'custom',
sceneCustomStyle: '90年代复古像素风',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}),
).toMatchObject({
id: 'generation-dialog-scene',
mode: 'scene',
prompt: '海边车站',
sceneStylePreset: 'custom',
sceneCustomStyle: '90年代复古像素风',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
});
it('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
const dialogId = 'dialog-perfect-pixel-round-trip';
const operation = buildPerfectPixelOperation(dialogId);
@@ -1,3 +1,4 @@
import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetGenerationInputs,
EditorAssetLibrarySnapshot,
@@ -1301,6 +1302,10 @@ export function hydrateCanvasGenerationDialog(
resourcesById,
currentUserId,
),
sceneStylePreset: isEditorSceneStylePreset(snapshot.sceneStylePreset)
? snapshot.sceneStylePreset
: undefined,
sceneCustomStyle: stringOrUndefined(snapshot.sceneCustomStyle),
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
@@ -2222,7 +2227,8 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'ui-design' ||
value === 'video' ||
value === 'sound-effect' ||
value === 'background-music'
value === 'background-music' ||
value === 'scene'
? value
: null;
}
@@ -2251,6 +2257,7 @@ function isCanvasGenerationDialogMode(
value === 'icon' ||
value === 'publication' ||
value === 'ui-design' ||
value === 'scene' ||
value === 'quick-edit' ||
value === 'character-animation' ||
value === 'video' ||
@@ -1,3 +1,4 @@
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
@@ -28,7 +29,8 @@ export type CanvasAssetKind =
| 'ui-design'
| 'video'
| 'sound-effect'
| 'background-music';
| 'background-music'
| 'scene';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
@@ -143,7 +145,8 @@ export type CanvasTool =
| 'character'
| 'icon'
| 'publication'
| 'ui-design';
| 'ui-design'
| 'scene';
export type SidebarPanel = 'assets' | 'layers';
@@ -214,7 +217,8 @@ export type GenerateDialogState = {
| 'character-animation'
| 'video'
| 'audio-sound-effect'
| 'audio-background-music';
| 'audio-background-music'
| 'scene';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
@@ -233,6 +237,8 @@ export type GenerateDialogState = {
publicationGameInfo?: PublicationMaterialsGameInfo;
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
sceneStylePreset?: EditorSceneStylePreset;
sceneCustomStyle?: string;
imageModel?: string;
style?: EditorImageGenerationStyle;
videoModel?: EditorVideoModel;
@@ -534,6 +534,37 @@ describe('ImageCanvasEditorView', () => {
expect(window.location.search).toBe('?projectid=editor-project-music');
});
it('opens the scene startup tool with scene defaults', async () => {
loadEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-scene',
title: '场景项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-04T00:00:00.000Z',
}),
);
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-scene&tool=scene',
);
render(<ImageCanvasEditorView />);
const dialog = await screen.findByRole('dialog', {
name: '游戏场景生成器',
});
expect(
within(dialog).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }),
).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '画风预设 日系动画' }),
).toBeTruthy();
expect(window.location.search).toBe('?projectid=editor-project-scene');
});
it('flushes project persistence before returning from the topbar', async () => {
const onPopState = vi.fn();
window.addEventListener('popstate', onPopState);
@@ -125,7 +125,8 @@ type CanvasStartupTool =
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
| 'publication-cover'
| 'scene';
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'character-spec',
@@ -134,6 +135,7 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'background-music',
'image',
'publication-cover',
'scene',
];
type ImageCanvasEditorViewProps = {
@@ -192,6 +194,10 @@ function openCanvasStartupTool(
generationSurface.openBackgroundMusicGenerationDialog();
return;
}
if (tool === 'scene') {
generationSurface.openSceneGenerationDialog();
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
@@ -171,6 +171,15 @@ describe('ImageCanvasExportModel', () => {
});
});
it('preserves game scene semantics in exported layer metadata', () => {
const metadata = buildLayerExportMetadata(
buildLayer({ assetKind: 'scene' }),
'images/001-scene.png',
);
expect(metadata.visible.type).toBe('游戏场景');
});
it('filters built-in prompts from exported visible generation inputs', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
@@ -17,6 +17,8 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { resolveImageGenerationErrorMessage } from './ImageCanvasGenerationModel';
import { buildImageGenerationSubmissionPlan } from './ImageCanvasGenerationSubmissionModel';
import type {
BackgroundMusicPromptAssistComposerController,
BackgroundMusicPromptAssistDialogState,
@@ -114,6 +116,41 @@ function createComposerProps(
};
}
function SceneValidationHarness({
initialDialog,
}: {
initialDialog: GenerateDialogState;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
);
if (!dialog) {
return null;
}
const props = createComposerProps(dialog, {
setGenerateDialog: setDialog,
onSubmitImageGeneration: (submittedDialog) => {
try {
buildImageGenerationSubmissionPlan({
dialog: submittedDialog,
layers: [],
nextGeneratedIndex: 1,
});
} catch (error) {
setDialog({
...submittedDialog,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
},
});
return <ImageCanvasGenerationComposerView {...props} />;
}
function renderComposer(
generateDialog: GenerateDialogState,
overrides: Partial<
@@ -387,6 +424,182 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBeTruthy();
});
it('显示游戏场景专用输入和画风控件', () => {
renderComposer({
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'anime',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
const sceneContent = within(panel).getByRole('textbox', {
name: '画面内容',
});
expect(sceneContent.className).toContain('auto-grow-text-area');
expect(sceneContent.className).not.toContain('platform-text-field');
expect(
within(panel).getByRole('button', { name: '画风预设 日系动画' }),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }),
).toBeTruthy();
expect(within(panel).queryByText('像素艺术')).toBeNull();
});
it('触屏点击可以打开并关闭当前画风预览', () => {
renderComposer({
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'anime',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
fireEvent.click(screen.getByRole('button', { name: '画风预设 日系动画' }));
const previewButton = screen.getByRole('button', {
name: '预览日系动画画风',
});
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
fireEvent.click(previewButton);
fireEvent.blur(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
fireEvent.click(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('false');
expect(screen.queryByAltText('日系动画固定画风预览')).toBeNull();
fireEvent.focus(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
});
it('让场景自定义画风使用可自增长的多行字段', () => {
renderComposer({
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'custom',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
const customStyle = within(panel).getByRole('textbox', {
name: '自定义画风',
});
expect(customStyle.tagName).toBe('TEXTAREA');
expect(customStyle.className).toContain('auto-grow-text-area');
expect(customStyle.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(customStyle.className).not.toContain('platform-text-field');
});
it('提交空场景内容时在对应字段附近呈现关联错误', () => {
render(
<SceneValidationHarness
initialDialog={{
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'anime',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}}
/>,
);
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
const sceneContent = within(panel).getByRole('textbox', {
name: '画面内容',
});
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
const error = within(panel).getByRole('alert');
expect(error.textContent).toBe('请填写画面内容');
expect(error.className).toContain(
'image-canvas-editor__scene-field-error',
);
expect(sceneContent.getAttribute('aria-invalid')).toBe('true');
expect(sceneContent.getAttribute('aria-describedby')).toBe(error.id);
expect(
panel.querySelector('.image-canvas-editor__generate-status'),
).toBeNull();
fireEvent.change(sceneContent, { target: { value: '雨夜中的海边车站' } });
expect(within(panel).queryByRole('alert')).toBeNull();
expect(sceneContent.getAttribute('aria-invalid')).toBeNull();
expect(sceneContent.getAttribute('aria-describedby')).toBeNull();
});
it('提交空自定义画风时在自定义字段附近呈现关联错误', () => {
render(
<SceneValidationHarness
initialDialog={{
mode: 'scene',
prompt: '雨夜中的海边车站',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'custom',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}}
/>,
);
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
const customStyle = within(panel).getByRole('textbox', {
name: '自定义画风',
});
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
const error = within(panel).getByRole('alert');
expect(error.textContent).toBe('请填写自定义画风');
expect(error.className).toContain(
'image-canvas-editor__scene-field-error',
);
expect(customStyle.getAttribute('aria-invalid')).toBe('true');
expect(customStyle.getAttribute('aria-describedby')).toBe(error.id);
expect(
panel.querySelector('.image-canvas-editor__generate-status'),
).toBeNull();
fireEvent.change(customStyle, { target: { value: '90 年代复古像素风' } });
expect(within(panel).queryByRole('alert')).toBeNull();
expect(customStyle.getAttribute('aria-invalid')).toBeNull();
expect(customStyle.getAttribute('aria-describedby')).toBeNull();
});
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
const setGenerateDialog = vi.fn();
renderComposer(
@@ -83,6 +83,7 @@ import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPubli
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSceneStyleControl } from './ImageCanvasSceneStyleControl';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import type { BackgroundMusicPromptAssistComposerController } from './useImageCanvasBackgroundMusicPromptAssist';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -1351,7 +1352,8 @@ export function ImageCanvasGenerationComposerView({
{!isPerfectPixelDialog &&
(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit') &&
generateDialog?.mode === 'quick-edit' ||
generateDialog?.mode === 'scene') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasBasicGenerationComposerView
@@ -1373,6 +1375,31 @@ export function ImageCanvasGenerationComposerView({
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
dialogLabel={
generateDialog.mode === 'scene' ? '游戏场景生成器' : undefined
}
promptLabel={generateDialog.mode === 'scene' ? '画面内容' : undefined}
promptPlaceholder={
generateDialog.mode === 'scene'
? '描述你想生成的游戏场景,例如:雨夜中的欧洲小镇街道……'
: undefined
}
optionLabelPrefix={
generateDialog.mode === 'scene' ? '游戏场景' : undefined
}
referenceButtonLabel={
generateDialog.mode === 'scene' ? '参考图' : undefined
}
styleControl={
generateDialog.mode === 'scene' ? (
<ImageCanvasSceneStyleControl
dialog={generateDialog}
setGenerateDialog={setGenerateDialog}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
) : undefined
}
/>
) : null}
@@ -26,6 +26,7 @@ import {
createQuickEditPanelDraft,
createRedrawPanelDraft,
createSameSourceGenerationDialogDraft,
createSceneGenerationDialogDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
@@ -143,6 +144,29 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates scene generation dialogs with product defaults', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createSceneGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'scene',
prompt: '',
status: 'idle',
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
originalWidth: 1024,
originalHeight: 576,
},
});
});
it('creates character and icon generation drafts with the selected model dimensions', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
@@ -1064,6 +1088,25 @@ describe('ImageCanvasGenerationDialogModel', () => {
};
expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog);
const sceneDialog: GenerateDialogState = {
mode: 'scene',
prompt: '',
status: 'idle',
generationReferences: [],
};
const sceneWithReference = appendGenerationReference(
sceneDialog,
sourceLayer,
);
expect(sceneWithReference).toMatchObject({
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })),
).toMatchObject({
generationReferences: [{ label: '参考图' }, { label: '源图' }],
});
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
@@ -1,3 +1,7 @@
import {
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
resolveEditorSceneStylePresetByLabel,
} from '../../../packages/shared/src/contracts/editorScene';
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
@@ -200,6 +204,44 @@ export function createGenerateDialogDraft({
};
}
export function createSceneGenerationDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const imageModel = DEFAULT_IMAGE_MODEL;
const aspectRatio = '16:9';
const imageSize = '1K';
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio,
imageSize,
});
return {
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: DEFAULT_EDITOR_SCENE_STYLE_PRESET,
sceneCustomStyle: '',
imageModel,
aspectRatio,
imageSize,
placeholder: {
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) {
return specType === 'character' || specType === 'ui';
}
@@ -466,6 +508,7 @@ const USER_PROMPT_INPUT_TITLES = new Set(
'快速编辑提示词',
'重绘提示词',
'动作描述',
'画面内容',
].map((title) => title.toLowerCase()),
);
@@ -628,6 +671,9 @@ function resolveGeneratedSourceDialogMode({
if (sourceLayer.assetKind === 'character') {
return 'character';
}
if (sourceLayer.assetKind === 'scene') {
return 'scene';
}
if (sourceLayer.assetKind === 'ui-design') {
return 'ui-design';
}
@@ -996,6 +1042,33 @@ export function createSameSourceGenerationDialogDraft({
);
}
if (sourceMode === 'scene') {
const fields = getGenerationInputFieldValues(sourceLayer);
const styleLabel = fields.get('视觉风格');
const sceneStylePreset =
sourceDialog?.sceneStylePreset ??
resolveEditorSceneStylePresetByLabel(styleLabel);
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createSceneGenerationDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
sceneStylePreset,
sceneCustomStyle:
fields.get('自定义画风') ?? sourceDialog?.sceneCustomStyle ?? '',
generationReferences:
sourceDialog?.mode === 'scene'
? (sourceDialog.generationReferences ?? [])
: [],
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'video') {
const draft = createVideoRedrawGenerationDialogDraft(sourceLayer);
if (!draft) {
@@ -1468,6 +1541,7 @@ export function appendGenerationReference(
}
if (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
@@ -1628,6 +1702,7 @@ export function hideGeneratedLayerComposerAfterBlur(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return (dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
@@ -1650,6 +1725,7 @@ export function closeGenerateComposerDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
@@ -42,6 +42,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
submitAriaLabel?: string;
submitButtonClassName?: string;
lockedModel?: string;
styleControl?: ReactNode;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
@@ -134,6 +135,7 @@ export function ImageCanvasGenerationImageOptionsView({
submitAriaLabel = '生成',
submitButtonClassName = 'image-canvas-editor__generation-submit',
lockedModel,
styleControl,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
}: ImageCanvasGenerationImageOptionsViewProps) {
@@ -326,21 +328,22 @@ export function ImageCanvasGenerationImageOptionsView({
: null}
</div>
) : null}
{supportsImageStyle ? (
<label className="image-canvas-editor__image-style-toggle">
<input
type="checkbox"
checked={dialog.style === 'pixelArt'}
disabled={isGenerating}
onChange={(event) =>
updateDialog({
style: event.target.checked ? 'pixelArt' : 'none',
})
}
/>
<span>像素艺术</span>
</label>
) : null}
{styleControl ??
(supportsImageStyle ? (
<label className="image-canvas-editor__image-style-toggle">
<input
type="checkbox"
checked={dialog.style === 'pixelArt'}
disabled={isGenerating}
onChange={(event) =>
updateDialog({
style: event.target.checked ? 'pixelArt' : 'none',
})
}
/>
<span>像素艺术</span>
</label>
) : null)}
{includeModel ? (
<div
className={[
@@ -1,3 +1,7 @@
import {
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
getEditorSceneStylePresetLabel,
} from '../../../packages/shared/src/contracts/editorScene';
import { ApiClientError } from '../../services/apiClient';
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
import type {
@@ -747,6 +751,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.assetKind === 'ui-design') {
return 'UI设计';
}
if (layer.assetKind === 'scene') {
return '游戏场景';
}
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
return '视频';
}
@@ -800,6 +807,9 @@ export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'ui-design') {
return 'UI设计图';
}
if (layer.assetKind === 'scene') {
return '游戏场景';
}
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
return '生成视频';
}
@@ -1116,6 +1126,27 @@ export function buildImageGenerationInputs(
};
}
export function buildSceneGenerationInputs(
sceneContent: string,
stylePreset: NonNullable<GenerateDialogState['sceneStylePreset']>,
customStyle: string | null | undefined,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
const styleLabel = getEditorSceneStylePresetLabel(stylePreset);
return {
fields: [
...createGenerationInputField('画面内容', sceneContent),
...createGenerationInputField('视觉风格', styleLabel),
...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET
? createGenerationInputField('自定义画风', customStyle)
: []),
],
references: (references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`场景参考图 ${index + 1}`, reference),
),
};
}
export function buildVideoGenerationInputs(
prompt: string,
durationSeconds: number,
@@ -1397,6 +1428,7 @@ export function isCanvasGenerationDialog(
return Boolean(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'scene' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
@@ -1416,6 +1448,9 @@ export function getGenerationFrameAriaLabel(
if (dialog.mode === 'character') {
return '角色生成占位图';
}
if (dialog.mode === 'scene') {
return '游戏场景生成占位图';
}
if (dialog.mode === 'spec') {
return '规范生成占位图';
}
@@ -1450,6 +1485,9 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
if (dialog.mode === 'character') {
return 'Character Generator';
}
if (dialog.mode === 'scene') {
return 'Scene Generator';
}
if (dialog.mode === 'spec') {
return 'Spec Generator';
}
@@ -62,6 +62,96 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds structured scene generation plans without assembling backend prompts', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'scene',
prompt: ' 雨夜中的欧洲小镇街道 ',
status: 'idle',
sceneStylePreset: 'watercolor',
sceneCustomStyle: '',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
generationReferences: [
{
id: 'scene-reference-1',
label: '场景参考一',
src: '/scene-reference-1.png',
objectKey: 'users/user-1/scene-reference-1.png',
resourceId: 'scene-resource-1',
},
{
id: 'scene-reference-2',
label: '场景参考二',
src: '/scene-reference-2.png',
objectKey: 'users/user-1/scene-reference-2.png',
resourceId: 'scene-resource-2',
},
],
},
layers: [],
nextGeneratedIndex: 2,
});
expect(plan).toMatchObject({
kind: 'scene',
normalizedPrompt: '雨夜中的欧洲小镇街道',
input: {
sceneContent: '雨夜中的欧洲小镇街道',
stylePreset: 'watercolor',
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
referenceImageSrcs: [
'users/user-1/scene-reference-1.png',
'users/user-1/scene-reference-2.png',
],
},
result: {
assetKind: 'scene',
title: '游戏场景 2',
generationInputs: {
fields: [
{ title: '画面内容', value: '雨夜中的欧洲小镇街道' },
{ title: '视觉风格', value: '清透水彩' },
],
references: [
{
title: '场景参考图 1',
label: '场景参考一',
refType: 'project-resource',
refId: 'scene-resource-1',
},
{
title: '场景参考图 2',
label: '场景参考二',
refType: 'project-resource',
refId: 'scene-resource-2',
},
],
},
},
});
expect(JSON.stringify(plan)).not.toContain('仅生成环境背景');
});
it('requires custom style content for scene generation', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'scene',
prompt: '海边车站',
status: 'idle',
sceneStylePreset: 'custom',
sceneCustomStyle: ' ',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('请填写自定义画风');
});
it('trims custom asset names and limits them to 80 characters', () => {
const customName = ` ${'名'.repeat(81)} `;
const plan = buildImageGenerationSubmissionPlan({
@@ -1,9 +1,14 @@
import {
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
} from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageEditInput,
EditorImageGenerationInput,
EditorSceneGenerationInput,
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
@@ -23,6 +28,7 @@ import {
buildPublicationMaterialsGenerationPrompt,
buildPublicationMaterialsPrompt,
buildQuickEditGenerationInputs,
buildSceneGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
@@ -58,6 +64,9 @@ type ImageGenerationSubmissionOptions = {
canonicalBackgroundMusicPrompt?: string;
};
export const EDITOR_SCENE_CONTENT_REQUIRED_ERROR = '请填写画面内容';
export const EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR = '请填写自定义画风';
export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80;
export function resolveGenerationAssetLabel(
@@ -131,14 +140,31 @@ function buildSeedanceVideoReferenceInput(
};
}
// 使用 map 避免嵌套 if else
const DEFAULT_GENERATION_PROMPTS = new Map<
GenerateDialogState['mode'],
string
>([
['edit', '修改当前图片'],
['audio-sound-effect', '游戏音效'],
['audio-background-music', '游戏背景音乐'],
]);
const REQUIRED_GENERATION_PROMPT_MODES = new Set<
GenerateDialogState['mode']
>(['scene']);
function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
if (mode === 'edit') {
return '修改当前图片';
return DEFAULT_GENERATION_PROMPTS.get(mode) ?? 'AI 生成图片';
}
export function resolveImageGenerationDialogPrompt(
dialog: GenerateDialogState,
) {
const prompt = dialog.prompt.trim();
if (prompt || REQUIRED_GENERATION_PROMPT_MODES.has(dialog.mode)) {
return prompt;
}
if (mode === 'audio-sound-effect') {
return '游戏音效';
}
return 'AI 生成图片';
return getDialogDefaultPrompt(dialog.mode);
}
export type ImageGenerationSubmissionPlan =
@@ -160,6 +186,17 @@ export type ImageGenerationSubmissionPlan =
};
rememberImageModel?: string;
}
| {
kind: 'scene';
normalizedPrompt: string;
input: EditorSceneGenerationInput;
result: {
assetKind: 'scene';
title: string;
generationInputs: CanvasGenerationInputs;
};
rememberImageModel?: string;
}
| {
kind: 'quick-edit';
normalizedPrompt: string;
@@ -252,8 +289,7 @@ export function buildImageGenerationSubmissionPlan({
};
}
const normalizedPrompt =
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
const normalizedPrompt = resolveImageGenerationDialogPrompt(dialog);
if (dialog.mode === 'edit') {
const sourceLayer = layers.find(
@@ -313,6 +349,58 @@ export function buildImageGenerationSubmissionPlan({
};
}
if (dialog.mode === 'scene') {
const sceneContent = dialog.prompt.trim();
if (!sceneContent) {
throw new Error(EDITOR_SCENE_CONTENT_REQUIRED_ERROR);
}
const stylePreset =
dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET;
const customStyle = dialog.sceneCustomStyle?.trim() ?? '';
if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET && !customStyle) {
throw new Error(EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR);
}
const references = dialog.generationReferences ?? [];
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const generationInputs = buildSceneGenerationInputs(
sceneContent,
stylePreset,
customStyle,
references,
);
return {
kind: 'scene',
normalizedPrompt: sceneContent,
input: {
sceneContent,
stylePreset,
...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET
? { customStyle }
: {}),
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
...(references.length
? {
referenceImageSrcs: references.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
generationInputs,
},
result: {
assetKind: 'scene',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏场景 ${nextGeneratedIndex}`,
),
generationInputs,
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
@@ -89,6 +89,20 @@ describe('ImageCanvasMetadataModalView', () => {
expect(within(dialog).getByText('123')).toBeTruthy();
});
it('renders game scene semantics for generated scene metadata', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({ assetKind: 'scene' })}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '图片信息' });
expect(within(dialog).getByText('游戏场景')).toBeTruthy();
expect(within(dialog).queryByText('生成图片')).toBeNull();
});
it('keeps the generation model while hiding internal processing models', () => {
const generationInputsWithInternalMattingMetadata = {
fields: [
@@ -204,6 +204,7 @@ export function isCanvasGenerationComposerVisible(
): dialog is GenerateDialogState & { mode: CanvasGenerationDialogMode } {
return (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||

Some files were not shown because too many files have changed in this diff Show More