接入原生壳音频文件导入能力
新增 file.importAudio HostBridge 契约和 H5 facade 移动端通过 Expo DocumentPicker 读取受控音频文件 桌面端通过 Tauri 文件选择框读取受控音频文件 通用音频输入面板在原生壳复用宿主导入和现有音频处理管线 更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getHostNetworkStatus,
|
||||
getHostRuntime,
|
||||
getNativeAppHostRuntime,
|
||||
importHostAudioFile,
|
||||
importHostImageFile,
|
||||
importHostTextFile,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
@@ -617,6 +618,14 @@ describe('hostBridge', () => {
|
||||
mimeType: 'text/markdown',
|
||||
bytes: 12,
|
||||
}
|
||||
: request.method === 'file.importAudio'
|
||||
? {
|
||||
action: 'selected',
|
||||
fileName: '敲击音效.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
}
|
||||
: true,
|
||||
};
|
||||
});
|
||||
@@ -641,6 +650,7 @@ describe('hostBridge', () => {
|
||||
'file.importText',
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'file.importAudio',
|
||||
'file.imageDropped',
|
||||
'notification.showLocal',
|
||||
]),
|
||||
@@ -717,6 +727,13 @@ describe('hostBridge', () => {
|
||||
mimeType: 'text/markdown',
|
||||
bytes: 12,
|
||||
});
|
||||
await expect(importHostAudioFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: '敲击音效.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
});
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: ' 生成完成 ',
|
||||
@@ -837,6 +854,12 @@ describe('hostBridge', () => {
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'file.importAudio',
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'notification.showLocal',
|
||||
@@ -915,6 +938,7 @@ describe('hostBridge', () => {
|
||||
).resolves.toBe(false);
|
||||
await expect(importHostImageFile()).resolves.toBe(false);
|
||||
await expect(importHostTextFile()).resolves.toBe(false);
|
||||
await expect(importHostAudioFile()).resolves.toBe(false);
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: '生成完成',
|
||||
@@ -941,6 +965,7 @@ describe('hostBridge', () => {
|
||||
).resolves.toBe(false);
|
||||
await expect(importHostImageFile()).resolves.toBe(false);
|
||||
await expect(importHostTextFile()).resolves.toBe(false);
|
||||
await expect(importHostAudioFile()).resolves.toBe(false);
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: '生成完成',
|
||||
@@ -1236,6 +1261,82 @@ describe('hostBridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: ' 敲击音效.webm ',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['file.importAudio']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(importHostAudioFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: '敲击音效.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'file.importAudio',
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('原生 App 宿主取消导入音频时回退为 false', 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: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'file import cancelled',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['file.importAudio']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(importHostAudioFile()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('原生 App 宿主取消导入文本时回退为 false', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -6,9 +6,11 @@ import type {
|
||||
FileExportImageResult,
|
||||
FileExportTextPayload,
|
||||
FileExportTextResult,
|
||||
FileImportAudioResult,
|
||||
FileImportImageResult,
|
||||
FileImportTextResult,
|
||||
HapticsImpactPayload,
|
||||
HostBridgeAudioMimeType,
|
||||
HostBridgeCapability,
|
||||
HostBridgeImageMimeType,
|
||||
HostBridgeMethod,
|
||||
@@ -98,6 +100,8 @@ export type HostFileImportImageResult = FileImportImageResult;
|
||||
|
||||
export type HostFileImportTextResult = FileImportTextResult;
|
||||
|
||||
export type HostFileImportAudioResult = FileImportAudioResult;
|
||||
|
||||
export type HostClipboardWriteTextRequest = {
|
||||
text: string;
|
||||
};
|
||||
@@ -901,6 +905,63 @@ export async function importHostTextFile() {
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
]);
|
||||
|
||||
function normalizeHostAudioImportResult(
|
||||
payload: FileImportAudioResult | null | undefined,
|
||||
): FileImportAudioResult | false {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
payload.action !== 'selected' ||
|
||||
!HOST_BRIDGE_AUDIO_MIME_TYPES.has(payload.mimeType) ||
|
||||
typeof payload.fileName !== 'string' ||
|
||||
!payload.fileName.trim() ||
|
||||
typeof payload.base64Data !== 'string' ||
|
||||
!payload.base64Data.trim() ||
|
||||
typeof payload.bytes !== 'number' ||
|
||||
!Number.isInteger(payload.bytes) ||
|
||||
payload.bytes <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'selected',
|
||||
fileName: payload.fileName.trim(),
|
||||
base64Data: payload.base64Data,
|
||||
mimeType: payload.mimeType,
|
||||
bytes: payload.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importHostAudioFile() {
|
||||
if (!canUseNativeHostCapability('file.importAudio')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostAudioImportResult(
|
||||
await requestNativeAppHostBridge<FileImportAudioResult>(
|
||||
'file.importAudio',
|
||||
undefined,
|
||||
{ timeoutMs: 30000 },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestHostHapticsImpact(
|
||||
params: HostHapticsImpactRequest = {},
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user