合并主分支

解决合并冲突
This commit is contained in:
2026-08-08 10:47:31 +08:00
140 changed files with 11920 additions and 2046 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

+53 -1
View File
@@ -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: [],
+24 -20
View File
@@ -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