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

新增 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}