跳到正文
跟我做
自媒体自动化6 分钟FFmpeg

视频转音频或 GIF:只留声音,或者做动图

用 ffmpeg 从视频里抽出 MP3,或截一段做成 GIF。支持只转声音、只做动图,也能两个一起出。结果写到 out/,原视频不动。

约 6 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 装 ffmpeg
  3. 建两个文件
  4. 写核心模块
  5. 写命令入口
  6. 抽成 MP3
  7. 截一段做 GIF
  8. 参数怎么调
  9. 常见坑
  10. 1. 竖屏视频转出来很高
  11. 2. 想要声音,但只导出了 GIF
  12. 3. 视频本身没有音轨
  13. 4. 截取点差一两帧
  14. 下一步读什么

30 秒速查

  • 先确认 ffmpeg -version 能跑
  • 只要声音:node resources/video-to-audio-gif/extract.mjs input/demo.mp4 --audio
  • 只要动图:把 --audio 换成 --gif --start 1 --duration 3
  • 两个都要:--audio --gif 一起加
  • 输出在 input/out/,原视频不动

这篇教程解决什么问题

一条三分钟的视频,可能只想要里面的背景音乐,或者只想要一个三秒动图做预览。打开剪辑软件、导出、再压缩,为了一件小事装一整套工具。

这篇用 ffmpeg 两条命令解决:抽音轨成 MP3,或者截一段做成 GIF。都能在终端里跑,原视频只读。

源视频卡片:demo.mp4,3.0 秒、640×360、56 KB;下方终端是 ffmpeg -i 读出的 Duration、Video、Audio 三行
① 时长 3.0 秒 ② 分辨率 640×360 ③ 体积 56 KB(本机 ffmpeg 生成的测试片,数字来自文件本身;卡片界面为重画)

装 ffmpeg

macOS:

brew install ffmpeg

Windows:

winget install Gyan.FFmpeg

Ubuntu 或 Debian:

sudo apt install ffmpeg

装完确认:

ffmpeg -version

如果提示「不是内部或外部命令」,先看ffmpeg PATH 排错,不用急着重装。

建两个文件

在 resources/video-to-audio-gif/ 下放两个文件:core.mjs 负责拼参数和调用 ffmpeg;extract.mjs 负责读参数、打印结果。

video-to-audio-gif/
├── core.mjs
└── extract.mjs

写核心模块

保存为 resources/video-to-audio-gif/core.mjs:

import { execFile } from "node:child_process";
import { mkdir, rm, stat } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";

const run = promisify(execFile);
const EXEC = { maxBuffer: 20 * 1024 * 1024 };

export function resolveFfmpeg(explicit) {
  return explicit || process.env.FFMPEG_PATH || "ffmpeg";
}

export function buildAudioArgs(input, output) {
  return ["-y", "-i", input, "-vn", "-c:a", "libmp3lame", "-q:a", "2",
    "-map_metadata", "-1", output];
}

export function buildGifArgs({
  input,
  output,
  palette,
  start,
  duration,
  fps = 12,
  width = 480,
}) {
  const seek = [];
  if (start) seek.push("-ss", String(start));
  if (duration) seek.push("-t", String(duration));

  const filter = `fps=${fps},scale=${width}:-1:flags=lanczos`;
  return {
    palette: [
      "-y", ...seek, "-i", input,
      "-vf", `${filter},palettegen=stats_mode=diff`,
      "-update", "1", palette,
    ],
    render: [
      "-y", ...seek, "-i", input, "-i", palette,
      "-lavfi", `${filter}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`,
      "-loop", "0", output,
    ],
  };
}

export async function probeMedia(input, { ffmpeg } = {}) {
  let info = "";
  try {
    await run(resolveFfmpeg(ffmpeg), ["-hide_banner", "-i", input]);
  } catch (error) {
    if (error.code === "ENOENT") {
      throw new Error("找不到 ffmpeg。先装 ffmpeg,或设置 FFMPEG_PATH。");
    }
    info = error.stderr ?? "";
  }

  const duration = /Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/.exec(info);
  if (!duration) throw new Error(`读不出视频信息:${input}`);

  const video = /Video:.*?,\s*(\d{2,5})x(\d{2,5})/.exec(info);
  return {
    seconds: Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]),
    width: video ? Number(video[1]) : null,
    height: video ? Number(video[2]) : null,
    hasAudio: /Audio:/.test(info),
  };
}

export async function extractAudio({ input, output, ffmpeg } = {}) {
  await mkdir(path.dirname(output), { recursive: true });
  const media = await probeMedia(input, { ffmpeg });
  await run(resolveFfmpeg(ffmpeg), buildAudioArgs(input, output), EXEC);
  return { output, bytes: (await stat(output)).size, media };
}

export async function extractGif({
  input,
  output,
  start,
  duration,
  fps = 12,
  width = 480,
  ffmpeg,
} = {}) {
  await mkdir(path.dirname(output), { recursive: true });
  const palette = path.join(path.dirname(output), `.palette-${process.pid}-${Date.now()}.png`);
  const args = buildGifArgs({ input, output, palette, start, duration, fps, width });

  try {
    await run(resolveFfmpeg(ffmpeg), args.palette, EXEC);
    await run(resolveFfmpeg(ffmpeg), args.render, EXEC);
  } finally {
    await rm(palette, { force: true });
  }

  return { output, bytes: (await stat(output)).size };
}

几个参数值得单独说:

  • -vn:不要视频流,只留音频;
  • -q:a 2:MP3 可变码率,大约 190 kbps,人声和音乐都够用;
  • -map_metadata -1:去掉原视频的标题、作者等元数据;
  • palettegen + paletteuse:GIF 只有 256 色,先生成专属调色板再套用,比直接转颜色干净得多。

写命令入口

保存为 resources/video-to-audio-gif/extract.mjs:

import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";

import { extractAudio, extractGif, probeMedia, resolveFfmpeg } from "./core.mjs";

function kb(bytes) {
  return Math.max(1, Math.round(bytes / 1024)) + " KB";
}

function display(file) {
  const relative = path.relative(process.cwd(), file);
  return relative.startsWith("..") ? file : relative;
}

const isDirectRun =
  process.argv[1] &&
  pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (isDirectRun) {
  const { values, positionals } = parseArgs({
    options: {
      audio: { type: "boolean", default: false },
      gif: { type: "boolean", default: false },
      out: { type: "string" },
      start: { type: "string" },
      duration: { type: "string" },
      fps: { type: "string", default: "12" },
      width: { type: "string", default: "480" },
      ffmpeg: { type: "string" },
    },
    allowPositionals: true,
  });
  const input = positionals[0];

  if (!input || (!values.audio && !values.gif)) {
    console.error(
      "用法:node resources/video-to-audio-gif/extract.mjs input/demo.mp4 --audio 或 --gif [--start 1 --duration 3]",
    );
    process.exitCode = 1;
  } else {
    try {
      const media = await probeMedia(input, { ffmpeg: values.ffmpeg });
      const outDir = values.out ?? path.join(path.dirname(input), "out");
      const base = path.basename(input, path.extname(input));
      const ffmpeg = resolveFfmpeg(values.ffmpeg);

      console.log(`文件:${input}  ${media.seconds.toFixed(1)} 秒  ${media.width}×${media.height}`);
      if (!media.hasAudio) console.log("提示:这个视频没有音轨,--audio 会失败。");

      if (values.audio) {
        const result = await extractAudio({
          input,
          output: path.join(outDir, `${base}.mp3`),
          ffmpeg,
        });
        console.log(`[音频] ${display(result.output)}  ${kb(result.bytes)}`);
      }

      if (values.gif) {
        const result = await extractGif({
          input,
          output: path.join(outDir, `${base}.gif`),
          start: values.start,
          duration: values.duration,
          fps: Number(values.fps),
          width: Number(values.width),
          ffmpeg,
        });
        console.log(
          `[动图] ${display(result.output)}  ${kb(result.bytes)}  ${values.fps} fps · ${values.width} px 宽`,
        );
      }
    } catch (error) {
      console.error(error.message);
      process.exitCode = 1;
    }
  }
}

抽成 MP3

node resources/video-to-audio-gif/extract.mjs input/demo.mp4 --audio

真实输出:

文件:input/demo.mp4  3.0 秒  640×360
[音频] input/out/demo.mp3  15 KB

三秒测试视频抽出 15 KB 的 MP3。如果是十分钟的播客,体积会到十几 MB,属正常。

左右两张卡片:demo.mp4 56 KB 箭头指向 demo.mp3 15 KB;下方终端是 extract.mjs 真实输出,末尾写省了 73.5%
① 原视频 56 KB ② 抽出 MP3 只剩 15 KB ③ 省 73.5%(本机真跑,按字节 1 − 15285 ÷ 57779 算;卡片界面为重画)

截一段做 GIF

GIF 不建议整段转。先截 2–3 秒,宽度 480 就够了:

node resources/video-to-audio-gif/extract.mjs input/demo.mp4 --gif --start 0.5 --duration 2
文件:input/demo.mp4  3.0 秒  640×360
[动图] input/out/demo.gif  231 KB  12 fps · 480 px 宽
demo.gif 231 KB 的卡片,与条形图对照:视频 56 KB、GIF 231 KB,相差 4.1 倍;下方说明十秒 GIF 实测 1092 KB
① GIF 231 KB,比原视频还大 ② 尺寸 480×270、12 fps ③ 只有 256 色(本机真转;倍率与十秒数字都是实测,界面为重画)

两个一起要就加两个开关:

node resources/video-to-audio-gif/extract.mjs input/demo.mp4 --audio --gif --start 0.5 --duration 2
文件:input/demo.mp4  3.0 秒  640×360
[音频] input/out/demo.mp3  15 KB
[动图] input/out/demo.gif  231 KB  12 fps · 480 px 宽

GIF 会先写一份临时调色板再合成,跑完自动删掉,out/ 里只留 .mp3 和 .gif。

参数怎么调

参数 默认 说明
--audio 关 输出同名 MP3
--gif 关 输出同名 GIF
--start 从 0 开始 从第几秒截
--duration 到结尾 截几秒
--fps / --width 12 / 480 动图流畅度和宽度

动图体积主要看三件事:时长、宽度、帧率。三秒 480px 是 231 KB;拉到十秒就会接近 1 MB。发聊天表情,360px 宽、10 fps 通常够。

常见坑

1. 竖屏视频转出来很高

scale=480:-1 固定宽度,高度按比例走。竖屏 1080×1920 会变成 480×853,体积比横屏大。竖屏动图把 --width 调到 360。

2. 想要声音,但只导出了 GIF

GIF 不存音频。发布时要么用 MP3 配图,要么直接发视频。两个格式不能塞进同一个文件。

3. 视频本身没有音轨

屏幕录制、无声素材很常见。脚本会提示「这个视频没有音轨」,加 --audio 会失败,去掉它只做 GIF。

4. 截取点差一两帧

--start 会先跳到附近关键帧,再解码丢掉前面的帧,所以不是逐帧精确。做表情包够用;要对齐台词,回剪辑软件。

下一步读什么

同一条视频还要发多个平台,看一条视频三个平台批量改尺寸、抽封面。

抽出来的 MP3 和 GIF 要压体积,可以接批量压缩图片;这条路线收在文件转换合集里。

常见问题

GIF 为什么没有声音?

GIF 格式本身不存音频。要声音就同时加 --audio 生成 MP3,发布时再和动图配合使用。

转出来的 GIF 有几百 KB,算大吗?

3 秒、480px 宽的动图 231 KB 是正常范围。GIF 逐帧存图,时间一长体积涨得很快;超过十秒建议直接发视频。

视频没有音轨会怎样?

脚本会先提示「这个视频没有音轨」。仍然加 --audio 会失败,去掉 --audio 只做 GIF 就行。

截取的时间点不准?

--start 会从附近的关键帧开始解码,再丢掉前面的帧,通常差不到一帧。要逐帧精确,得改用剪辑软件。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程