选一个本地视频,点"提取音频",最后下载 MP3。这个页面看起来只需要一个上传控件和一个按钮。
真正需要交代的是中间的状态:第一次点击时引擎还没加载,视频可能没有音轨,用户也可能连续点击两次。上一份结果还在播放器里时,又换了一个文件,旧结果该什么时候释放?
上一篇介绍了 Vimorph 的整体分工。这次只展开浏览器媒体工具中的一个问题。项目当前把命令组装放在 commands.ts,把加载、文件读写、执行和释放放在 ffmpeg-core.ts。下面做一个独立的 React + Vite 示例,把这些环节连起来。
先把一次任务的边界定下来
示例只提取第一条音轨,输出 MP3 或 WAV。每次任务创建一个单线程引擎,结束后销毁,先把失败和清理边界定下来。本文是独立教学示例,沿用项目的分工思路,不代表整套业务实现。
text
本地 File → 加载引擎 → 写入内存文件系统 → 执行命令
→ 检查返回码 → 读取输出字节 → Blob → 页面播放/下载
| 依赖 | 示例使用的版本 |
|---|---|
@ffmpeg/ffmpeg |
0.12.15 |
@ffmpeg/core |
0.12.10,单线程 |
| React / ReactDOM | 19.2.7 |
| Vite | 8.2.1 |
配套工程要求 Node.js 22.12.0 或更高,依赖版本固定在 package.json 中。
完整示例工程包含 React 页面、提取函数、Vite 配置和资源复制脚本。解压后进入 browser-audio-demo/examples 目录运行:
text
npm install
npm run dev
打开终端给出的地址,选择视频,输出格式选 MP3 或 WAV。不要直接双击 index.html。需要验证构建页面时,先执行 npm run build,再执行 npm run preview。
predev 和 prebuild 会从 @ffmpeg/core 的 ESM 目录复制 ffmpeg-core.js、ffmpeg-core.wasm 到 public/ffmpeg。函数从同源 /ffmpeg/ 加载它们,页面会请求引擎资源;本示例没有上传输入文件的逻辑。
Vite 配置把 @ffmpeg/ffmpeg 排除在依赖预构建之外,保留其模块 Worker 的加载路径。配置也允许本地链接的依赖目录,方便复用已有开发环境。Vite 使用 ESM core 的要求可参考 ffmpeg.wasm 官方用法。
这里按站点根目录部署。若部署在子路径,要一起调整 Vite base 和函数中的 /ffmpeg/ 地址。接入 Next.js 时还要处理客户端组件与 Worker 打包,不能把这套 Vite 配置原样套进去。
实现音频提取与结果校验
这里把输入校验、执行和结果检查放在同一个函数里。exec 返回非零值时直接报错;返回零后,还要确认输出是非空字节,才把它交给界面。
下面是完整的 src/extract-audio.js,在配套工程中运行:
javascript
import { FFmpeg } from '@ffmpeg/ffmpeg';
export const MAX_INPUT_BYTES = 100 * 1024 * 1024;
export async function extractAudio(file, {
format = 'mp3', onProgress, onPhase, signal,
} = {}) {
const cancelledError = new DOMException('任务已取消', 'AbortError');
const checkAbort = () => {
if (signal?.aborted) throw cancelledError;
};
checkAbort();
if (!(file instanceof File) || file.size === 0) {
throw new Error('请选择非空的视频文件');
}
if (file.size > MAX_INPUT_BYTES) {
throw new Error('本示例只处理不超过 100 MiB 的输入');
}
if (!['mp3', 'wav'].includes(format)) {
throw new Error('本示例只支持 MP3 和 WAV');
}
const ffmpeg = new FFmpeg();
const inputName = 'input.bin';
const outputName = `output.${format}`;
let rejectCancel;
const cancelled = new Promise((_, reject) => { rejectCancel = reject; });
// 已取消时可能还没有进入第一次 wait;提前挂上拒绝处理。
cancelled.catch(() => {});
const abort = () => {
rejectCancel(cancelledError);
ffmpeg.terminate();
};
const wait = action => {
checkAbort();
return Promise.race([action(), cancelled]);
};
let stage = 'load';
let exitCode;
let diagnostic = '';
let loadTimer;
const log = ({ message }) => {
diagnostic = (diagnostic + '\n' + message).slice(-2000);
};
const progress = ({ progress: value }) => {
if (Number.isFinite(value)) {
onProgress?.(Math.max(0, Math.min(99, Math.round(value * 100))));
}
};
try {
ffmpeg.on('log', log);
ffmpeg.on('progress', progress);
signal?.addEventListener('abort', abort, { once: true });
// 监听注册前发生的取消不会补发事件,必须立即重新检查。
checkAbort();
onPhase?.('loading');
await wait(() => Promise.race([
ffmpeg.load({
coreURL: new URL('/ffmpeg/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/ffmpeg/ffmpeg-core.wasm', location.href).href,
}),
new Promise((_, reject) => {
// 某些 Worker 加载错误不会由 SDK 回传,避免界面一直等待。
loadTimer = setTimeout(() => reject(new Error('引擎加载等待超时')), 60_000);
}),
]));
clearTimeout(loadTimer);
stage = 'read';
const input = new Uint8Array(await wait(() => file.arrayBuffer()));
checkAbort();
stage = 'write';
await wait(() => ffmpeg.writeFile(inputName, input));
checkAbort();
stage = 'exec';
onPhase?.('processing');
const codec = format === 'mp3'
? ['-c:a', 'libmp3lame', '-b:a', '128k']
: ['-c:a', 'pcm_s16le'];
exitCode = await wait(() => ffmpeg.exec([
'-i', inputName, '-map', '0:a:0', '-vn', ...codec, '-y', outputName,
], 120_000));
checkAbort();
if (exitCode !== 0) {
throw new Error('FFmpeg 命令未成功');
}
stage = 'output';
const data = await wait(() => ffmpeg.readFile(outputName));
checkAbort();
if (!(data instanceof Uint8Array) || data.byteLength === 0) {
stage = 'empty';
throw new Error('音频输出为空或不是二进制数据');
}
stage = 'blob';
const bytes = new Uint8Array(data.byteLength);
bytes.set(data);
const blob = new Blob([bytes.buffer], {
type: format === 'mp3' ? 'audio/mpeg' : 'audio/wav',
});
onProgress?.(100);
checkAbort();
return blob;
} catch (error) {
// 仅识别本次取消产生的错误,不因 signal 后来变为 aborted 改写普通错误。
if (error === cancelledError) throw error;
let message = {
load: '音频引擎加载失败或等待超时,请检查 Worker、WASM 资源路径和网络后重试',
read: '无法读取本地文件,请重新选择文件后重试',
write: '无法把文件写入音频引擎,请尝试较小的文件',
exec: '音频处理失败,输入可能损坏、格式不支持或执行超时,请换一个文件重试',
output: '无法读取音频结果,请重新执行任务',
empty: '没有取得有效的音频输出,请换一个文件重试',
blob: '无法生成可下载的音频文件,请重新执行任务',
}[stage];
if (stage === 'exec' && exitCode !== undefined) {
if (/Stream map ['"]?0:a:0['"]? matches no streams/i.test(diagnostic)) {
message = '未找到可提取的音轨,请选择含音频的视频';
} else if (/Unknown (?:decoder|encoder)|(?:Decoder|Encoder).*not found/i.test(diagnostic)) {
message = '当前引擎不支持所需编解码器,请转换格式后重试';
}
message += `(FFmpeg 返回码 ${exitCode})`;
}
throw new Error(message, { cause: error });
} finally {
clearTimeout(loadTimer);
signal?.removeEventListener('abort', abort);
ffmpeg.off('log', log);
ffmpeg.off('progress', progress);
// 每次调用独占引擎;结束后销毁 Worker 及其内存文件系统。
ffmpeg.terminate();
}
}
命令中的 -map 0:a:0 明确选择第一个输入的第一条音轨,-vn 禁用视频输出。没有音轨时,让任务失败并显示原因,避免给用户一个看似成功的下载入口。
MP3 使用 libmp3lame,目标码率 128 kbit/s;WAV 使用 pcm_s16le。这两条路径都会重新编码,不是把原音轨无损复制出来。选 MP3 还是 WAV,要由后续用途决定,不能只改输出文件扩展名。
取消与错误提示
取消要先检查信号,再注册监听,注册后立即复查,操作开始前也要检查。否则,已经发生的取消可能没有触发新监听,或者阶段回调取消后又启动了 load() / exec()。
这里用同一个 AbortError 表达主动取消。取消 Promise 与每个异步操作竞争,取消回调先拒绝该 Promise,再 terminate()。即便 SDK 随后以普通终止错误拒绝正在进行的调用,界面也能识别取消;已经先返回的普通错误则保留自己的含义。读取本地文件本身不会被强行中止,但取消后不会再写入或执行后续步骤。
错误按当前阶段给出提示:本地文件读不出、引擎资源加载失败、结果无法读取或输出为空,都有各自的说明。执行失败时,只在日志明确匹配"没有音轨"或"缺少编解码器"时提供更具体的原因,其余保留"输入损坏、格式不支持或执行超时"的通用提示,并保留原始异常为 cause。
exec() 的非零返回码不是唯一业务错误码,不能把返回码 1 一律写成超时。主动取消可以识别为本次产生的 AbortError,执行超时与其他命令失败则不一定能仅靠返回值区分。进度与返回值定义见 官方 FFmpeg API。
引擎加载另有 60 秒等待保护:资源没有完成加载时结束等待,并在 finally 终止 Worker。它与 exec() 的 120 秒限制分开;两者都不是整个任务从选文件到下载的总时限。
避免重复提交和任务并发
busy 状态用于禁用按钮,busyRef 在异步调用开始前同步置为 true,防止状态更新生效前的连续触发。每次任务创建新的 AbortController,局部变量 current 就是该控制器;current.signal 是传给函数的 AbortSignal,controller.current 保存它供取消按钮使用。
下面是 start() 中的接口节选,与配套工程一致,不能脱离组件直接运行。file、format 和各个 set... 函数来自 React state;controller、mounted、urlRef、busyRef 来自 useRef。进入这段代码前,任务入口已加锁、清除旧结果并重置界面:
jsx
const current = new AbortController();
controller.current = current;
try {
const blob = await extractAudio(file, {
format, signal: current.signal,
onProgress: value => { if (mounted.current) setPercent(value); },
onPhase: value => {
if (mounted.current) setPhase(value === 'loading' ? '正在加载引擎' : '正在提取');
},
});
if (!mounted.current || current.signal.aborted) return;
const url = URL.createObjectURL(blob);
urlRef.current = url;
setOutput({ url, name: `audio.${format}`, size: blob.size });
setPhase('完成');
} catch (cause) {
if (mounted.current) {
const cancelled = cause instanceof DOMException && cause.name === 'AbortError';
setPhase(cancelled ? '已取消' : '失败');
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
}
} finally {
if (controller.current === current) controller.current = null;
busyRef.current = false;
if (mounted.current) setBusy(false);
}
取消按钮调用 controller.current?.abort()。组件卸载时也取消当前任务,并回收输出 URL;异步结果回来时先检查是否仍挂载、是否已取消,再更新界面。effect 的 setup 会重新设置挂载标记,适配本例启用的 React StrictMode。finally 清除当前控制器和任务锁,使下一次任务可以正常开始。
Worker 与 Blob URL 的生命周期
函数先把读到的字节复制到独立缓冲区,再创建 Blob。任务结束后,finally 中移除事件监听并终止独占 Worker;返回的 Blob 继续由页面持有。
页面的 Blob URL 要单独管理。选新文件、改格式、开始下一次任务时回收旧 URL,组件卸载时再回收当前 URL。转换一结束就回收正在使用的 URL,会破坏后续播放或下载;只终止 Worker,又不会自动替页面回收这些 URL。
本示例每次任务重新创建引擎,代价是重复初始化。Vimorph 当前封装有引擎缓存、临时文件删除和统一释放逻辑。若改为复用实例,就要显式删除输入、输出文件,移除本次监听,并串行管理共享实例上的操作;不能照搬这里的固定文件名和每次 terminate()。
进度分为加载状态与命令执行估计值:加载引擎时显示"正在加载引擎",执行期间最多显示 99%,命令成功且取得非空二进制、创建 Blob 后才显示 100%。progress 不保证在所有格式、输入输出时长或处理场景下准确,也不等于剩余时间。exec() 的 120 秒超时只覆盖命令执行,不包括引擎加载和文件读取。
100 MiB 是教学示例的输入限制,不是浏览器最大处理能力。原始文件、读取的字节、WASM 文件系统、编码缓冲和输出数据可能同时占用内存,峰值可能明显高于输入大小。设备与浏览器的内存限制也不同,不能仅根据文件大小判断任务一定能完成。
实际检查了哪些情况
常规转换使用本地生成的两秒纯色视频:一份带 440 Hz 正弦音,一份没有音轨。执行取消使用额外的 180 秒合成视频,让测试在收到真实 FFmpeg 进度事件、尚未收到执行回复时取消;不用延时猜测执行时机。
| 浏览器检查 | 结果 |
|---|---|
| 有声视频生成 MP3 / WAV | 下载成功,ffprobe 分别识别为 mp3 和 pcm_s16le |
| 连续转换与重复点击 | 第二次成功;旧 URL 回收;连续两次点击只启动一项任务 |
| 无音轨与损坏文件 | 分别显示无音轨、通用执行失败提示,之后可重试 |
| 空文件与超限输入 | 加载前拒绝;超限只测了元数据校验 |
| core / WASM 404、模拟 Worker 构造异常 | 显示加载失败,恢复后重试成功 |
| 调用前取消、监听注册窗口取消 | AbortError,未创建 Worker;窗口测试为确定性故障注入 |
| 加载期间、执行进度期间取消 | Worker 结束,取消后再次转换成功 |
| 请求观察 | 本次没有 POST、PUT、PATCH 上传请求 |
在 macOS、Node.js 26.7.0 和 Chromium 148 上,开发页面的 15 项检查通过,构建页面也完成 MP3/WAV 转换、播放与下载。四份下载文件经原生 FFmpeg 完整解码通过。两秒样本的 MP3/WAV 时长分别为 2.016 秒和 1.984 秒,不据此承诺采样级时长一致。
另外执行了 22 项受控单元测试,使用真实函数源码和 FFmpeg/计时器替身,检查取消竞态、普通错误保留、读取失败、空输出及清理分支。加载等待超时、缺少编解码器提示等替身测试不等同于真实设备上的专项验证。
测试页面的 15 个 Worker 均已结束,当前结果保留一个 Blob URL 供播放和下载,旧 URL 已回收。图中是配套示例的真实页面。
验证范围分开看:组件卸载分支已核对源码,尚未专项触发;Safari、移动端、大文件与内存峰值、多线程、真实 60 秒加载保护和 120 秒执行超时触发、任意执行阶段取消、Vimorph 业务系统端到端均未验证。本次复用了项目已安装的依赖,未从空环境重跑 npm 安装;结果来自本地合成素材,没有做人工听感评价。
引擎复用的取舍
这个版本为了让任务边界清楚,选择重复创建引擎。做成常用工具后,可以考虑缓存实例来减少初始化,但相应地也要管理共享引擎上的任务、临时文件和事件监听。
音频提取只是一个入口。同样的加载、失败恢复和下载生命周期,也会出现在裁剪、格式转换等页面里。先把这些行为核对清楚,再扩展命令,页面会更容易维护。