接入原生壳音频文件导入能力

新增 file.importAudio HostBridge 契约和 H5 facade

移动端通过 Expo DocumentPicker 读取受控音频文件

桌面端通过 Tauri 文件选择框读取受控音频文件

通用音频输入面板在原生壳复用宿主导入和现有音频处理管线

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 05:25:39 +08:00
parent 51dcff6d16
commit 3be997e286
15 changed files with 627 additions and 4 deletions
@@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge';
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
@@ -18,6 +20,10 @@ afterEach(() => {
configurable: true,
value: originalMediaDevices,
});
window.history.replaceState(null, '', '/');
delete window.__TAURI__;
resetNativeAppHostBridgeForTest();
resetHostRuntimeCacheForTest();
vi.restoreAllMocks();
});
@@ -159,6 +165,58 @@ test('上传音频成功后清空错误并写入资产', async () => {
expect(onError).toHaveBeenCalledWith(null);
});
test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
action: 'selected',
fileName: 'hit.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
},
};
},
);
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostCapabilities=file.importAudio',
);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
command: string,
args?: Record<string, unknown>,
) => (await invoke(command, args)) as Result,
},
};
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
fireEvent.click(screen.getByText('上传'));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
const [file, source] = readFileAsAsset.mock.calls[0]!;
expect(file).toBeInstanceOf(File);
expect((file as File).name).toBe('hit.webm');
expect((file as File).type).toBe('audio/webm');
expect(source).toBe('uploaded');
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(onError).toHaveBeenCalledWith(null);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importAudio',
timeoutMs: 30000,
}),
});
});
test('上传音频失败时提示错误且不写入资产', async () => {
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频最长 1 秒。');
@@ -1,6 +1,11 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import {
canUseNativeHostCapability,
type HostFileImportAudioResult,
importHostAudioFile,
} from '../../services/host-bridge/hostBridge';
import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
@@ -36,8 +41,34 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
readFileAsAsset = readCreativeAudioFileAsAsset,
}: CreativeAudioInputPanelProps<TAsset>) {
const [isRecording, setIsRecording] = useState(false);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const canImportHostAudio = canUseNativeHostCapability('file.importAudio');
const hostAudioImportResultToFile = (result: HostFileImportAudioResult) => {
const binary = atob(result.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], result.fileName, {
type: result.mimeType,
});
};
const importHostAudioAsUploadedAsset = async () => {
const result = await importHostAudioFile();
if (!result) {
return;
}
const file = hostAudioImportResultToFile(result);
const nextAsset = await readFileAsAsset(file, 'uploaded');
onError(null);
onAssetChange(nextAsset);
};
const startRecording = async () => {
if (disabled || isRecording) {
@@ -129,10 +160,28 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
className={`min-h-10 cursor-pointer gap-2 px-3 ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
onClick={(event) => {
if (disabled) {
return;
}
if (!canImportHostAudio) {
return;
}
event.preventDefault();
void importHostAudioAsUploadedAsset().catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
>
<Upload className="h-4 w-4" />
<input
ref={uploadInputRef}
type="file"
accept="audio/*"
disabled={disabled}