ffmpeg.wasm 实战:浏览器提取视频音频,如何处理进度、取消和资源释放?

选一个本地视频,点"提取音频",最后下载 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 安装;结果来自本地合成素材,没有做人工听感评价。

引擎复用的取舍

这个版本为了让任务边界清楚,选择重复创建引擎。做成常用工具后,可以考虑缓存实例来减少初始化,但相应地也要管理共享引擎上的任务、临时文件和事件监听。

音频提取只是一个入口。同样的加载、失败恢复和下载生命周期,也会出现在裁剪、格式转换等页面里。先把这些行为核对清楚,再扩展命令,页面会更容易维护。

相关推荐
小小龙学IT2 小时前
FFmpeg 开源多媒体处理框架深度解析:从命令行到 libav* 全家桶
c语言·ffmpeg·开源
ofoxcoding1 天前
Gemini TTS 与 ElevenLabs 语音合成实测:同一段视频旁白效果对比
ai·ffmpeg·音视频
_大峰_2 天前
索尼 A7M4 误删视频恢复实战:基于原始卡镜像重建 MP4 数据
ffmpeg·数据恢复·音视频·mp4·exfat
Anhty2 天前
3 款手机音频剪辑 APP 横评,短视频配音后期,免费工具怎么选?
功能测试·ffmpeg·音视频
VidDown3 天前
自动给视频挑一张能看的封面:帧评分、构图与批量生成
python·ffmpeg·音视频·视频
可乐鸡翅yeah_4 天前
HLS 业务 Referer‑Policy 网页元标签引发播放异常排错
前端·javascript·网络·ffmpeg·php·音视频
开开心心就好5 天前
本地文件搜索工具推荐,占用小速度还快
智能手机·ffmpeg·ocr·word·音视频·网络攻击模型·安全架构
我科绝伦(Huanhuan Zhou)5 天前
oracle坏块导致备份不了
数据库·oracle·ffmpeg
RS迷途小书童5 天前
Python+FFmpeg提取大疆无人机MP4内嵌mov_text遥测SRT字幕并做完整性校验
python·ffmpeg·大疆·srt字幕·dji