合并主分支
解决合并冲突
This commit is contained in:
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 |
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
@@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const browserReloadMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const walletLifecycleMocks = vi.hoisted(() => ({
|
||||
usePlatformWalletLifecycle: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
}
|
||||
|
||||
const memoryLocalStorage = createMemoryStorage();
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
@@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks);
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
getHostRuntime: vi.fn(() => ({
|
||||
kind: 'browser',
|
||||
@@ -136,6 +158,10 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: memoryLocalStorage,
|
||||
});
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
@@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth root binds the single wallet lifecycle under StrictMode', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
|
||||
authMocks.refreshStoredAccessToken.mockRejectedValue(
|
||||
new Error('refresh cookie 失效'),
|
||||
);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
walletLifecycleMocks.usePlatformWalletLifecycle,
|
||||
).toHaveBeenCalledWith('user-1', true);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
reloadHostWebView,
|
||||
requestHostLogin,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
@@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods(
|
||||
|
||||
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
|
||||
return Array.from(
|
||||
new Set<AuthLoginMethod>([
|
||||
...REQUIRED_LOGIN_METHODS,
|
||||
...normalizedMethods,
|
||||
]),
|
||||
new Set<AuthLoginMethod>([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
const markAuthStateReloadIfChanged = useCallback(
|
||||
(
|
||||
nextUser: AuthUser | null,
|
||||
options: { reloadOnChange?: boolean } = {},
|
||||
) => {
|
||||
(nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => {
|
||||
const nextHasUser = Boolean(nextUser);
|
||||
const previousHasUser = lastStableAuthPresenceRef.current;
|
||||
if (previousHasUser === null) {
|
||||
@@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
if (
|
||||
previousHasUser !== nextHasUser &&
|
||||
options.reloadOnChange !== false
|
||||
) {
|
||||
if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) {
|
||||
pendingAuthStateReloadRef.current = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, [markAuthStateReloadIfChanged]);
|
||||
const activateReadyUser = useCallback(
|
||||
(nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
},
|
||||
[markAuthStateReloadIfChanged],
|
||||
);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(
|
||||
(options: { reloadOnChange?: boolean } = {}) => {
|
||||
@@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
],
|
||||
);
|
||||
|
||||
usePlatformWalletLifecycle(
|
||||
readyUser?.id ?? null,
|
||||
status === 'ready' && Boolean(readyUser),
|
||||
);
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div
|
||||
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const registrationInviteCode =
|
||||
pendingInviteCode || readInviteCodeFromLocation();
|
||||
const response = registrationInviteCode
|
||||
? await loginWithPhoneCode(phone, code, registrationInviteCode)
|
||||
? await loginWithPhoneCode(
|
||||
phone,
|
||||
code,
|
||||
registrationInviteCode,
|
||||
)
|
||||
: await loginWithPhoneCode(phone, code);
|
||||
const autoRedeemedInvite = response.referral?.ok === true;
|
||||
setStoredLastLoginPhone(phone);
|
||||
|
||||
@@ -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',
|
||||
@@ -480,7 +480,11 @@ describe('CreationLandingView', () => {
|
||||
|
||||
it('plays a character action on card focus and in the preview dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setIntervalSpy = vi.spyOn(window, 'setInterval');
|
||||
const setIntervalSpy = vi
|
||||
.spyOn(window, 'setInterval')
|
||||
.mockImplementation(
|
||||
() => 1 as unknown as ReturnType<typeof window.setInterval>,
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -994,9 +998,11 @@ describe('CreationLandingView', () => {
|
||||
'creation-landing__asset-preview--campaign',
|
||||
);
|
||||
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
|
||||
const campaignImage = await screen.findByRole('img', {
|
||||
name: '活动精选',
|
||||
});
|
||||
const campaignImage = await screen.findByRole(
|
||||
'img',
|
||||
{ name: '活动精选' },
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -1531,6 +1531,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,
|
||||
@@ -1241,6 +1242,10 @@ export function hydrateCanvasGenerationDialog(
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
sceneStylePreset: isEditorSceneStylePreset(snapshot.sceneStylePreset)
|
||||
? snapshot.sceneStylePreset
|
||||
: undefined,
|
||||
sceneCustomStyle: stringOrUndefined(snapshot.sceneCustomStyle),
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
style,
|
||||
videoModel:
|
||||
@@ -2133,7 +2138,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;
|
||||
}
|
||||
@@ -2162,6 +2168,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';
|
||||
|
||||
@@ -176,7 +178,8 @@ export type CanvasTool =
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design';
|
||||
| 'ui-design'
|
||||
| 'scene';
|
||||
|
||||
export type SidebarPanel = 'assets' | 'layers';
|
||||
|
||||
@@ -247,7 +250,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';
|
||||
@@ -267,6 +271,8 @@ export type GenerateDialogState = {
|
||||
publicationGameInfo?: PublicationMaterialsGameInfo;
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
sceneStylePreset?: EditorSceneStylePreset;
|
||||
sceneCustomStyle?: string;
|
||||
imageModel?: string;
|
||||
style?: EditorImageGenerationStyle;
|
||||
videoModel?: EditorVideoModel;
|
||||
|
||||
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
|
||||
import {
|
||||
ApiClientError,
|
||||
@@ -294,6 +295,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
loadFrontendRuntimeConfigMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
@@ -532,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);
|
||||
@@ -593,6 +626,21 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-1');
|
||||
const walletSnapshot = usePlatformWalletStore
|
||||
.getState()
|
||||
.captureWalletBalanceSnapshot('user-1');
|
||||
usePlatformWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot(walletSnapshot!, {
|
||||
totalPoints: 1234,
|
||||
permanentPoints: 1000,
|
||||
limitedPoints: 214,
|
||||
limitedExpiresAt: '2026-07-31T16:00:00Z',
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -614,12 +662,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
|
||||
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
});
|
||||
expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the account modal from the canvas topbar avatar entry', async () => {
|
||||
@@ -658,8 +701,97 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-1');
|
||||
const walletSnapshot = usePlatformWalletStore
|
||||
.getState()
|
||||
.captureWalletBalanceSnapshot('user-1');
|
||||
usePlatformWalletStore
|
||||
.getState()
|
||||
.applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37);
|
||||
getPlatformProfileRechargeCenterMock.mockResolvedValue({
|
||||
walletBalance: 37,
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const walletButton = await screen.findByRole('button', {
|
||||
name: '泥点 37',
|
||||
});
|
||||
await user.hover(walletButton);
|
||||
|
||||
const details = await screen.findByRole('dialog', {
|
||||
name: '泥点账户详情',
|
||||
});
|
||||
expect(within(details).getByText('泥点明细读取失败')).toBeTruthy();
|
||||
expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull();
|
||||
expect(within(details).queryByText('不限时泥点')).toBeNull();
|
||||
expect(within(details).queryByText('每日免费泥点')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the wallet entry loading while the authenticated owner is not bound yet', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole('button', { name: '泥点 --' })).getAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-1');
|
||||
const walletSnapshot = usePlatformWalletStore
|
||||
.getState()
|
||||
.captureWalletBalanceSnapshot('user-1');
|
||||
usePlatformWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot(walletSnapshot!, {
|
||||
totalPoints: 1234,
|
||||
permanentPoints: 1000,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 234,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
@@ -126,7 +126,8 @@ type CanvasStartupTool =
|
||||
| 'ui-design'
|
||||
| 'background-music'
|
||||
| 'image'
|
||||
| 'publication-cover';
|
||||
| 'publication-cover'
|
||||
| 'scene';
|
||||
|
||||
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
|
||||
'character-spec',
|
||||
@@ -135,9 +136,11 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
|
||||
'background-music',
|
||||
'image',
|
||||
'publication-cover',
|
||||
'scene',
|
||||
];
|
||||
|
||||
type ImageCanvasEditorViewProps = {
|
||||
legacyWalletBalance?: number | null;
|
||||
onProjectAccessLost?: () => void;
|
||||
};
|
||||
|
||||
@@ -192,6 +195,10 @@ function openCanvasStartupTool(
|
||||
generationSurface.openBackgroundMusicGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'scene') {
|
||||
generationSurface.openSceneGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'publication-cover') {
|
||||
generationSurface.openPublicationGenerationDialog(
|
||||
'publication-cover-image',
|
||||
@@ -321,12 +328,50 @@ const DEAD_INLINE_PLACEHOLDER_NOTICE =
|
||||
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
|
||||
|
||||
export function ImageCanvasEditorView({
|
||||
legacyWalletBalance = null,
|
||||
onProjectAccessLost,
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const [walletBalance, setWalletBalance] = useState<number | null>(null);
|
||||
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
|
||||
const walletOwnerUserId = usePlatformWalletStore(
|
||||
(state) => state.ownerUserId,
|
||||
);
|
||||
const storedMudPointBalance = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalance,
|
||||
);
|
||||
const storedLegacyWalletBalance = usePlatformWalletStore(
|
||||
(state) => state.legacyWalletBalance,
|
||||
);
|
||||
const storedMudPointBalanceStatus = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceStatus,
|
||||
);
|
||||
const storedMudPointBalanceError = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceError,
|
||||
);
|
||||
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
|
||||
(state) => state.onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
const currentWalletOwnerUserId =
|
||||
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentWalletOwnerUserId) &&
|
||||
walletOwnerUserId === currentWalletOwnerUserId;
|
||||
const mudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalance
|
||||
: null;
|
||||
const mudPointBalanceError = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalanceError
|
||||
: '';
|
||||
const walletBalance =
|
||||
mudPointBalance?.totalPoints ??
|
||||
(walletOwnerMatchesCurrentUser
|
||||
? (storedLegacyWalletBalance ?? legacyWalletBalance)
|
||||
: null);
|
||||
const isWalletBalanceLoading =
|
||||
Boolean(currentWalletOwnerUserId) &&
|
||||
(!walletOwnerMatchesCurrentUser ||
|
||||
storedMudPointBalanceStatus === 'idle' ||
|
||||
storedMudPointBalanceStatus === 'loading');
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -509,21 +554,6 @@ export function ImageCanvasEditorView({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const refreshEditorWalletBalance = useCallback(() => {
|
||||
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
|
||||
return;
|
||||
}
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const {
|
||||
buyRechargeProduct,
|
||||
closeNativeWechatPayment,
|
||||
@@ -559,7 +589,6 @@ export function ImageCanvasEditorView({
|
||||
activeTab: 'editor-canvas',
|
||||
isAuthenticated: Boolean(authUi?.user),
|
||||
showRechargeEntry,
|
||||
onRechargeSuccess: refreshEditorWalletBalance,
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
@@ -567,9 +596,8 @@ export function ImageCanvasEditorView({
|
||||
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
|
||||
return;
|
||||
}
|
||||
refreshEditorWalletBalance();
|
||||
loadRechargeCenter();
|
||||
}, [loadRechargeCenter, refreshEditorWalletBalance]);
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}, [onWalletBalanceMayHaveChanged]);
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
@@ -591,66 +619,6 @@ export function ImageCanvasEditorView({
|
||||
window.location.reload();
|
||||
});
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalance(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
let requestId = 0;
|
||||
|
||||
const refreshWalletBalance = () => {
|
||||
const currentRequestId = requestId + 1;
|
||||
requestId = currentRequestId;
|
||||
setIsWalletBalanceLoading(true);
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalance(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setIsWalletBalanceLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
refreshWalletBalance();
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
refreshWalletBalance();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshWalletBalance();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUi?.canAccessProtectedData, authUi?.user?.id]);
|
||||
const {
|
||||
projectTitle,
|
||||
setProjectTitle,
|
||||
@@ -1083,8 +1051,10 @@ export function ImageCanvasEditorView({
|
||||
layer.assetKind === 'character-animation' &&
|
||||
(persistedAssetKind !== 'character-animation' ||
|
||||
(asset.imageSequenceFrames?.length ?? 0) < 2 ||
|
||||
!(asset.imageSequenceDurationMs &&
|
||||
asset.imageSequenceDurationMs > 0))
|
||||
!(
|
||||
asset.imageSequenceDurationMs &&
|
||||
asset.imageSequenceDurationMs > 0
|
||||
))
|
||||
) {
|
||||
throw new Error('服务器未返回完整的角色动作正式字段');
|
||||
}
|
||||
@@ -1515,9 +1485,8 @@ export function ImageCanvasEditorView({
|
||||
if (warning) {
|
||||
showGenerationWarning(warning);
|
||||
}
|
||||
refreshEditorWalletState();
|
||||
},
|
||||
[projectId, refreshEditorWalletState, showGenerationWarning],
|
||||
[projectId, showGenerationWarning],
|
||||
);
|
||||
const effectiveIsAgentConversationOpen =
|
||||
isAgentConversationEnabled && isAgentConversationOpen;
|
||||
@@ -2516,10 +2485,10 @@ export function ImageCanvasEditorView({
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalance,
|
||||
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
|
||||
walletBreakdown: mudPointBalance,
|
||||
isWalletBalanceLoading,
|
||||
isWalletDetailsLoading: isLoadingRechargeCenter,
|
||||
walletDetailsError: rechargeError,
|
||||
walletDetailsError: rechargeError || mudPointBalanceError || null,
|
||||
currentUser: authUi?.user,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -2627,6 +2596,7 @@ export function ImageCanvasEditorView({
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
|
||||
onExternalTaskWalletMayHaveChanged: refreshEditorWalletState,
|
||||
onEditorAgentConfirmSent: handleEditorAgentConfirmSent,
|
||||
onToggleTaskSidebar: toggleTaskSidebar,
|
||||
onToggleAgentConversation: toggleAgentConversation,
|
||||
|
||||
@@ -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('uses the reference fallback label when export metadata is blank', () => {
|
||||
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,
|
||||
@@ -160,6 +161,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 };
|
||||
@@ -1404,6 +1428,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,
|
||||
@@ -207,6 +211,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';
|
||||
}
|
||||
@@ -472,6 +514,7 @@ const USER_PROMPT_INPUT_TITLES = new Set(
|
||||
'快速编辑提示词',
|
||||
'重绘提示词',
|
||||
'动作描述',
|
||||
'画面内容',
|
||||
].map((title) => title.toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -636,6 +679,9 @@ function resolveGeneratedSourceDialogMode({
|
||||
if (sourceLayer.assetKind === 'character') {
|
||||
return 'character';
|
||||
}
|
||||
if (sourceLayer.assetKind === 'scene') {
|
||||
return 'scene';
|
||||
}
|
||||
if (sourceLayer.assetKind === 'ui-design') {
|
||||
return 'ui-design';
|
||||
}
|
||||
@@ -1463,6 +1509,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) {
|
||||
@@ -1950,6 +2023,7 @@ export function appendGenerationReference(
|
||||
}
|
||||
if (
|
||||
dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'scene' ||
|
||||
dialog?.mode === 'quick-edit' ||
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'ui-design'
|
||||
@@ -2110,6 +2184,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' ||
|
||||
@@ -2132,6 +2207,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 {
|
||||
@@ -753,6 +757,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 '视频';
|
||||
}
|
||||
@@ -834,6 +841,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 '生成视频';
|
||||
}
|
||||
@@ -1615,6 +1625,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,
|
||||
references?: CharacterReferenceImage[],
|
||||
@@ -2122,7 +2153,8 @@ export function isCanvasGenerationDialog(
|
||||
return Boolean(
|
||||
dialog?.id &&
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'scene' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
@@ -2141,6 +2173,9 @@ export function getGenerationFrameAriaLabel(
|
||||
if (dialog.mode === 'character') {
|
||||
return '角色生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'scene') {
|
||||
return '游戏场景生成占位图';
|
||||
}
|
||||
if (dialog.mode === 'spec') {
|
||||
return '规范生成占位图';
|
||||
}
|
||||
@@ -2175,6 +2210,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';
|
||||
}
|
||||
|
||||
@@ -66,6 +66,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,
|
||||
@@ -64,6 +70,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(
|
||||
@@ -137,14 +146,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);
|
||||
}
|
||||
|
||||
function resolveOptionalFieldWithFallback<T extends string>(
|
||||
@@ -322,6 +348,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;
|
||||
@@ -417,8 +454,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPrompt =
|
||||
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
|
||||
const normalizedPrompt = resolveImageGenerationDialogPrompt(dialog);
|
||||
|
||||
if (dialog.mode === 'edit') {
|
||||
const sourceLayer = layers.find(
|
||||
@@ -554,6 +590,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];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user