接入原生壳音频文件导出能力
新增 file.exportAudio HostBridge 契约和 H5 facade 移动端通过 Expo 缓存文件与系统分享导出受控音频 桌面端通过 Tauri 保存对话框写入受控音频字节 通用音频输入面板仅对本地音频资产展示宿主导出入口 更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
@@ -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 秒。');
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user