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

新增 file.exportAudio HostBridge 契约和 H5 facade

移动端通过 Expo 缓存文件与系统分享导出受控音频

桌面端通过 Tauri 保存对话框写入受控音频字节

通用音频输入面板仅对本地音频资产展示宿主导出入口

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 05:42:16 +08:00
parent 3be997e286
commit b3278739a5
15 changed files with 734 additions and 18 deletions
@@ -10,6 +10,11 @@ import type { CreativeAudioAsset } from './creativeAudioFileAsset';
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
type TestAudioAsset = CreativeAudioAsset;
type ExportableTestAudioAsset = TestAudioAsset & {
blob: Blob;
fileName: string;
mimeType: string;
};
const originalMediaRecorder = globalThis.MediaRecorder;
const originalMediaDevices = navigator.mediaDevices;
@@ -40,6 +45,18 @@ function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
};
}
function buildExportableAsset(
overrides: Partial<ExportableTestAudioAsset> = {},
): ExportableTestAudioAsset {
return {
...buildAsset(),
blob: new Blob(['audio'], { type: 'audio/wav' }),
fileName: 'hit.wav',
mimeType: 'audio/wav',
...overrides,
};
}
function renderPanel(
overrides: Partial<
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
@@ -217,6 +234,122 @@ test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async
});
});
test('原生 App 宿主可用且当前音频为本地资产时可以导出音频', 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: 'saved',
fileName: 'hit.wav',
bytes: 5,
},
};
},
);
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
command: string,
args?: Record<string, unknown>,
) => (await invoke(command, args)) as Result,
},
};
const { onError } = renderPanel({
asset: buildExportableAsset(),
});
fireEvent.click(screen.getByRole('button', { name: '导出' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.exportAudio',
payload: {
fileName: 'hit.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
},
timeoutMs: 30000,
}),
}),
);
expect(onError).toHaveBeenCalledWith(null);
});
test('非本地音频资产或未声明能力时不显示导出入口', () => {
const { rerender } = renderPanel({
asset: buildExportableAsset(),
});
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
rerender(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={buildAsset({ audioSrc: '/generated/hit.wav' })}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={() => {}}
onError={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
});
test('导出音频失败时提示错误', 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: 'host_error',
message: '系统保存失败。',
},
};
},
);
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
command: string,
args?: Record<string, unknown>,
) => (await invoke(command, args)) as Result,
},
};
const { onError } = renderPanel({
asset: buildExportableAsset(),
});
fireEvent.click(screen.getByRole('button', { name: '导出' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('系统保存失败。'));
});
test('上传音频失败时提示错误且不写入资产', async () => {
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频最长 1 秒。');
+115 -14
View File
@@ -1,8 +1,9 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { Download, Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import {
canUseNativeHostCapability,
exportHostAudioFile,
type HostFileImportAudioResult,
importHostAudioFile,
} from '../../services/host-bridge/hostBridge';
@@ -29,6 +30,67 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
) => Promise<TAsset>;
};
type ExportableCreativeAudioAsset = CreativeAudioAsset & {
blob?: Blob;
fileName?: string;
mimeType?: string;
};
const HOST_EXPORT_AUDIO_MIME_TYPES = new Set([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
]);
function resolveExportableAudioAsset<TAsset extends CreativeAudioAsset>(
asset: TAsset | null,
) {
const candidate = asset as ExportableCreativeAudioAsset | null;
if (
!candidate?.blob ||
!(candidate.blob instanceof Blob) ||
!candidate.fileName?.trim() ||
!candidate.mimeType?.trim() ||
!HOST_EXPORT_AUDIO_MIME_TYPES.has(candidate.mimeType)
) {
return null;
}
return {
blob: candidate.blob,
fileName: candidate.fileName.trim(),
mimeType: candidate.mimeType as
| 'audio/mpeg'
| 'audio/mp4'
| 'audio/wav'
| 'audio/ogg'
| 'audio/webm',
};
}
function blobToBase64Data(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频导出失败。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频导出失败。'));
return;
}
const base64Data = reader.result.split(',')[1] ?? '';
if (!base64Data) {
reject(new Error('音频导出失败。'));
return;
}
resolve(base64Data);
};
reader.readAsDataURL(blob);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
@@ -41,10 +103,11 @@ 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 canExportHostAudio = canUseNativeHostCapability('file.exportAudio');
const exportableAsset = resolveExportableAudioAsset(asset);
const hostAudioImportResultToFile = (result: HostFileImportAudioResult) => {
const binary = atob(result.base64Data);
@@ -70,6 +133,22 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
onAssetChange(nextAsset);
};
const exportHostAudioAsset = async () => {
if (!exportableAsset) {
return;
}
const base64Data = await blobToBase64Data(exportableAsset.blob);
const exported = await exportHostAudioFile({
fileName: exportableAsset.fileName,
base64Data,
mimeType: exportableAsset.mimeType,
});
if (exported) {
onError(null);
}
};
const startRecording = async () => {
if (disabled || isRecording) {
return;
@@ -140,17 +219,40 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
}
titleVariant="strong"
actions={
asset ? (
<PlatformActionButton
onClick={() => onAssetChange(null)}
disabled={disabled}
tone="ghost"
size="xs"
className="min-h-0"
>
重置
</PlatformActionButton>
) : null
<div className="flex items-center gap-2">
{canExportHostAudio && exportableAsset ? (
<PlatformActionButton
onClick={() => {
void exportHostAudioAsset().catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频导出失败。',
);
});
}}
disabled={disabled}
tone="ghost"
size="xs"
className="min-h-0 gap-1"
title="导出音频"
>
<Download className="h-3.5 w-3.5" />
导出
</PlatformActionButton>
) : null}
{asset ? (
<PlatformActionButton
onClick={() => onAssetChange(null)}
disabled={disabled}
tone="ghost"
size="xs"
className="min-h-0"
>
重置
</PlatformActionButton>
) : null}
</div>
}
bodyClassName="mt-3 flex flex-wrap items-center gap-2"
>
@@ -181,7 +283,6 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
<Upload className="h-4 w-4" />
上传
<input
ref={uploadInputRef}
type="file"
accept="audio/*"
disabled={disabled}
@@ -6,6 +6,7 @@ import type { HostBridgeCapability } from '../../../packages/shared/src/contract
import {
canUseHostShareGrid,
canUseNativeHostCapability,
exportHostAudioFile,
exportHostImageFile,
exportHostTextFile,
getHostAppearanceColorScheme,
@@ -651,6 +652,7 @@ describe('hostBridge', () => {
'file.exportImage',
'file.importImage',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
'notification.showLocal',
]),
@@ -734,6 +736,13 @@ describe('hostBridge', () => {
mimeType: 'audio/webm',
bytes: 5,
});
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(true);
await expect(
showHostLocalNotification({
title: ' 生成完成 ',
@@ -939,6 +948,13 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
@@ -966,6 +982,13 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
@@ -1307,6 +1330,59 @@ 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: 'saved',
fileName: '敲击音效.wav',
bytes: 5,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.exportAudio']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toEqual({
action: 'saved',
fileName: '敲击音效.wav',
bytes: 5,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.exportAudio',
payload: {
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
},
timeoutMs: 30000,
}),
});
});
test('原生 App 宿主取消导入音频时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
+25
View File
@@ -2,6 +2,8 @@ import type {
AppearanceColorSchemeResult,
AppLifecycleEventPayload,
ClipboardReadTextResult,
FileExportAudioPayload,
FileExportAudioResult,
FileExportImagePayload,
FileExportImageResult,
FileExportTextPayload,
@@ -96,6 +98,8 @@ export type HostFileExportTextRequest = FileExportTextPayload;
export type HostFileExportImageRequest = FileExportImagePayload;
export type HostFileExportAudioRequest = FileExportAudioPayload;
export type HostFileImportImageResult = FileImportImageResult;
export type HostFileImportTextResult = FileImportTextResult;
@@ -782,6 +786,27 @@ export async function exportHostImageFile(
}
}
export async function exportHostAudioFile(
params: HostFileExportAudioRequest,
) {
if (!canUseNativeHostCapability('file.exportAudio')) {
return false;
}
try {
return await requestNativeAppHostBridge<FileExportAudioResult>(
'file.exportAudio',
params,
{ timeoutMs: 30000 },
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
'image/png',
'image/jpeg',