接入RPG角色参考图原生导入

RPG角色资产工作室参考图在原生壳内优先走宿主图片导入

取消原生图片选择时不触发浏览器文件输入

同步角色资产工作室测试、宿主壳协议和共享记忆
This commit is contained in:
2026-06-19 11:53:59 +08:00
parent 98daf41790
commit bf50c6fab6
6 changed files with 155 additions and 9 deletions
@@ -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"