接入视觉小说原生素材导入

视觉小说结果页图片和音频上传优先消费原生壳受控导入

保留平台素材上传、历史素材和 AI 图片生成原链路

补齐原生壳门禁、测试和架构文档
This commit is contained in:
2026-06-19 11:12:39 +08:00
parent ce0765b298
commit 48cd76918b
7 changed files with 243 additions and 12 deletions
@@ -2,8 +2,13 @@
import { render, screen, 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 {
canUseNativeHostCapability,
importHostAudioFile,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import { VisualNovelResultView } from './VisualNovelResultView';
@@ -24,6 +29,23 @@ vi.mock('../../services/assetReadUrlService', () => ({
shouldResolveAssetReadUrl: vi.fn(() => false),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostAudioFile: vi.fn(),
importHostImageFile: vi.fn(),
}));
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostAudioFileMock = vi.mocked(importHostAudioFile);
const importHostImageFileMock = vi.mocked(importHostImageFile);
afterEach(() => {
vi.clearAllMocks();
canUseNativeHostCapabilityMock.mockReturnValue(false);
importHostAudioFileMock.mockResolvedValue(false);
importHostImageFileMock.mockResolvedValue(false);
});
test('visual novel profile tab uses PlatformSubpanel shells', () => {
const { container } = render(
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
@@ -283,6 +305,134 @@ test('visual novel result uploads scene and character assets into platform refer
).toContain('/generated-custom-world-scenes/');
});
test('visual novel result imports scene image through native host before platform upload', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import(
'../../services/visual-novel-creation'
);
const uploadMock = vi.mocked(visualNovelCreation.uploadVisualNovelAsset);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importImage',
);
importHostImageFileMock.mockResolvedValue({
action: 'selected',
fileName: 'native-scene.png',
mimeType: 'image/png',
base64Data: 'bmF0aXZlLWltYWdl',
bytes: 12,
});
uploadMock.mockResolvedValue({
assetObjectId: 'asset-scene-native',
assetKind: 'scene_image',
objectKey: 'generated-custom-world-scenes/native-scene.png',
imageSrc: '/generated-custom-world-scenes/native-scene.png',
});
render(
<VisualNovelResultView
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: /风雪站台/u }));
const dialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(
within(dialog).getAllByRole('button', { name: '背景图' })[0]!,
);
await user.click(
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
name: '上传',
}),
);
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
expect(uploadMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'scene_background',
file: expect.any(File),
}),
);
const uploadedFile = uploadMock.mock.calls[0]?.[0].file;
expect(uploadedFile?.name).toBe('native-scene.png');
expect(uploadedFile?.type).toBe('image/png');
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/native-scene.png',
);
});
test('visual novel result imports scene audio through native host before platform upload', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import(
'../../services/visual-novel-creation'
);
const uploadMock = vi.mocked(visualNovelCreation.uploadVisualNovelAsset);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importAudio',
);
importHostAudioFileMock.mockResolvedValue({
action: 'selected',
fileName: 'native-music.webm',
mimeType: 'audio/webm',
base64Data: 'bmF0aXZlLWF1ZGlv',
bytes: 12,
});
uploadMock.mockResolvedValue({
assetObjectId: 'asset-audio-native',
assetKind: 'music',
objectKey: 'generated-custom-world-scenes/native-music.webm',
imageSrc: '/generated-custom-world-scenes/native-music.webm',
});
render(
<VisualNovelResultView
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: /风雪站台/u }));
const dialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(within(dialog).getByRole('button', { name: '音乐' }));
await user.click(
within(screen.getByRole('dialog', { name: '音乐' })).getByRole('button', {
name: '上传',
}),
);
expect(importHostAudioFileMock).toHaveBeenCalledTimes(1);
expect(uploadMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'music',
file: expect.any(File),
}),
);
const uploadedFile = uploadMock.mock.calls[0]?.[0].file;
expect(uploadedFile?.name).toBe('native-music.webm');
expect(uploadedFile?.type).toBe('audio/webm');
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.musicSrc).toBe(
'/generated-custom-world-scenes/native-music.webm',
);
});
test('visual novel result generates scene background from asset picker', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
@@ -26,6 +26,11 @@ import type {
VisualNovelValidationIssue,
} from '../../../packages/shared/src/contracts/visualNovel';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import {
canUseNativeHostCapability,
importHostAudioFile,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import {
buildVisualNovelImageGenerationPrompt,
createVisualNovelBackgroundMusicTask,
@@ -398,6 +403,19 @@ function formatHistoryAssetDate(value: string | undefined) {
});
}
function visualNovelBase64DataToFile(
base64Data: string,
fileName: string,
mimeType: string,
) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType });
}
function VisualNovelAssetPickerDialog({
config,
disabled,
@@ -420,6 +438,11 @@ function VisualNovelAssetPickerDialog({
const [isUploading, setIsUploading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
const canImportHostImage =
config.previewTone === 'image' && canUseNativeHostCapability('file.importImage');
const canImportHostAudio =
config.previewTone === 'audio' && canUseNativeHostCapability('file.importAudio');
const canImportHostAsset = canImportHostImage || canImportHostAudio;
useEffect(() => {
if (!config.historyKind) {
@@ -498,6 +521,22 @@ function VisualNovelAssetPickerDialog({
}
};
const uploadFile = async (file: File) => {
if (!file) {
return null;
}
const asset = await uploadVisualNovelAsset({
kind: config.uploadKind,
file,
ownerUserId: authUi?.user?.id ?? null,
profileId: config.profileId ?? null,
entityId: config.entityId ?? null,
});
onSelect(asset);
return asset;
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
@@ -508,14 +547,40 @@ function VisualNovelAssetPickerDialog({
setIsUploading(true);
setError(null);
try {
const asset = await uploadVisualNovelAsset({
kind: config.uploadKind,
file,
ownerUserId: authUi?.user?.id ?? null,
profileId: config.profileId ?? null,
entityId: config.entityId ?? null,
});
onSelect(asset);
await uploadFile(file);
} catch (uploadError) {
setError(
uploadError instanceof Error
? uploadError.message
: '平台素材上传失败。',
);
} finally {
setIsUploading(false);
}
};
const handleUploadAction = async () => {
if (!canImportHostAsset) {
fileInputRef.current?.click();
return;
}
setIsUploading(true);
setError(null);
try {
const importedAsset = canImportHostImage
? await importHostImageFile()
: await importHostAudioFile();
if (!importedAsset) {
return;
}
await uploadFile(
visualNovelBase64DataToFile(
importedAsset.base64Data,
importedAsset.fileName,
importedAsset.mimeType,
),
);
} catch (uploadError) {
setError(
uploadError instanceof Error
@@ -541,7 +606,9 @@ function VisualNovelAssetPickerDialog({
<PlatformActionButton
tone="secondary"
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => fileInputRef.current?.click()}
onClick={() => {
void handleUploadAction();
}}
className="min-h-10"
>
{isUploading ? (