接入RPG角色参考图原生导入
RPG角色资产工作室参考图在原生壳内优先走宿主图片导入 取消原生图片选择时不触发浏览器文件输入 同步角色资产工作室测试、宿主壳协议和共享记忆
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { EditableCustomWorldRole } from './roleAssetStudioModel';
|
||||
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
|
||||
import { RpgCreationRoleAssetStudioModal } from './RpgCreationRoleAssetStudioModal';
|
||||
|
||||
const generateVisualCandidatesForRole = vi.fn();
|
||||
@@ -14,6 +15,11 @@ vi.mock('../asset-studio/projectPixelStyleReference', () => ({
|
||||
buildProjectPixelStyleReferenceBoard: vi.fn(async () => ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(() => false),
|
||||
importHostImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../asset-studio/characterAssetWorkflowPersistence', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../asset-studio/characterAssetWorkflowPersistence')
|
||||
@@ -61,12 +67,18 @@ vi.mock('../CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>动作预览</div>,
|
||||
}));
|
||||
|
||||
test('角色形象生成通过统一确认弹窗确认泥点消耗', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi
|
||||
.spyOn(window, 'confirm')
|
||||
.mockImplementation(() => false);
|
||||
const role: EditableCustomWorldRole = {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
|
||||
generateVisualCandidatesForRole.mockReset();
|
||||
applyVisualDraftToRole.mockReset();
|
||||
});
|
||||
|
||||
function createRole(): EditableCustomWorldRole {
|
||||
return {
|
||||
id: 'role-shen-li',
|
||||
name: '沈砺',
|
||||
title: '潮灯守望者',
|
||||
@@ -74,6 +86,14 @@ test('角色形象生成通过统一确认弹窗确认泥点消耗', async () =>
|
||||
visualDescription: '银发剑客,青色长衫。',
|
||||
actionDescription: '轻盈奔跑动作。',
|
||||
};
|
||||
}
|
||||
|
||||
test('角色形象生成通过统一确认弹窗确认泥点消耗', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi
|
||||
.spyOn(window, 'confirm')
|
||||
.mockImplementation(() => false);
|
||||
const role = createRole();
|
||||
|
||||
generateVisualCandidatesForRole.mockResolvedValue({
|
||||
drafts: [
|
||||
@@ -157,3 +177,69 @@ test('角色形象生成通过统一确认弹窗确认泥点消耗', async () =>
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('角色参考图在原生壳内优先走 HostBridge 图片导入', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: 'role-reference.png',
|
||||
base64Data: 'cm9sZS1yZWZlcmVuY2U=',
|
||||
mimeType: 'image/png',
|
||||
bytes: 14,
|
||||
});
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={createRole()}
|
||||
roleKind="playable"
|
||||
onClose={() => {}}
|
||||
visualPointCost={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /生成角色形象/u });
|
||||
|
||||
await user.click(screen.getByText('上传角色参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('已载入 1 张参考图。')).toBeTruthy();
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('角色参考图取消原生导入时不触发浏览器文件输入', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={createRole()}
|
||||
roleKind="playable"
|
||||
onClose={() => {}}
|
||||
visualPointCost={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /生成角色形象/u });
|
||||
|
||||
await user.click(screen.getByText('上传角色参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('已载入 1 张参考图。')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CharacterAnimator } from '../CharacterAnimator';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
|
||||
import {
|
||||
CORE_ACTIONS,
|
||||
type CustomWorldAiActionConfig,
|
||||
@@ -809,6 +814,39 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const replaceReferenceImagesFromFiles = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedDataUrls = await Promise.all(
|
||||
files.slice(0, 4).map((file) => readFileAsDataUrl(file)),
|
||||
);
|
||||
setReferenceImageDataUrls(uploadedDataUrls);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
|
||||
};
|
||||
|
||||
const handleReferenceImageUploadClick = (fallback: () => void) => {
|
||||
if (!canUseNativeHostCapability('file.importImage')) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const importedImage = await importHostImageFile();
|
||||
if (!importedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await replaceReferenceImagesFromFiles([
|
||||
puzzleReferenceImageDataUrlToFile(
|
||||
`data:${importedImage.mimeType};base64,${importedImage.base64Data}`,
|
||||
importedImage.fileName,
|
||||
),
|
||||
]);
|
||||
})();
|
||||
};
|
||||
|
||||
const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
|
||||
setIsApplyingVisual(true);
|
||||
try {
|
||||
@@ -1056,6 +1094,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
onGenerateVisuals={() => {
|
||||
requestGenerateVisuals();
|
||||
}}
|
||||
onReferenceImageUploadClick={handleReferenceImageUploadClick}
|
||||
onVisualPromptChange={setVisualPromptText}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ImagePlus, RefreshCcw } from 'lucide-react';
|
||||
import { type ChangeEvent, type ReactNode, useId } from 'react';
|
||||
import { type ChangeEvent, type ReactNode, useId, useRef } from 'react';
|
||||
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
@@ -54,6 +54,7 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
workingRoleName: string;
|
||||
onClearReferenceImages: () => void;
|
||||
onGenerateVisuals: () => void;
|
||||
onReferenceImageUploadClick: (fallback: () => void) => void;
|
||||
onVisualPromptChange: (value: string) => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -76,9 +77,11 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
workingRoleName,
|
||||
onClearReferenceImages,
|
||||
onGenerateVisuals,
|
||||
onReferenceImageUploadClick,
|
||||
onVisualPromptChange,
|
||||
} = props;
|
||||
const referenceImageInputId = useId();
|
||||
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const visualPreviewImageSrc =
|
||||
previewImageSrc || selectedTemplatePortrait || '';
|
||||
const visualPreviewAlt = previewImageSrc
|
||||
@@ -122,8 +125,18 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
size="compact"
|
||||
surface="editorDark"
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (isGeneratingVisuals || isApplyingVisual || syncBusy) {
|
||||
return;
|
||||
}
|
||||
onReferenceImageUploadClick(() =>
|
||||
referenceImageInputRef.current?.click(),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={referenceImageInputRef}
|
||||
id={referenceImageInputId}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
|
||||
Reference in New Issue
Block a user