接入视觉小说原生素材导入
视觉小说结果页图片和音频上传优先消费原生壳受控导入 保留平台素材上传、历史素材和 AI 图片生成原链路 补齐原生壳门禁、测试和架构文档
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user